From 7296d92526565fc194a48d8814d48ed1dc60a333 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:47:11 -0500 Subject: [PATCH 01/15] feat(layout): scale pinned placement with spatial hash --- public/src/lib/layout.ts | 446 +++++++++++++++++++++++++++++---------- 1 file changed, 336 insertions(+), 110 deletions(-) diff --git a/public/src/lib/layout.ts b/public/src/lib/layout.ts index 3bb730b..57b6825 100644 --- a/public/src/lib/layout.ts +++ b/public/src/lib/layout.ts @@ -5,8 +5,13 @@ import { isoToScreen } from './iso'; const GRID_SCALE = 2; const TILE_W = 96; const TILE_H = 48; +const CELL_W = TILE_W; +const CELL_H = TILE_H; const MAX_PULSE = 7; const MAX_LAYOUT_HEIGHT = 120 + MAX_PULSE; +const CELL_OFFSET = 32768; + +type CellBucket = string | string[]; interface BoundsRect { left: number; @@ -15,20 +20,50 @@ interface BoundsRect { bottom: number; } +interface SpatialIndex { + cells: Map; + bounds: Map; +} + +interface SpiralState { + cx: number; + cy: number; + dir: 0 | 1 | 2 | 3; + legLen: number; + legProgress: number; + legsAtLen: 0 | 1; + started: boolean; +} + +interface GroupState { + anchor: Coordinate; + spiral: SpiralState; + freeStack: number[]; +} + export interface LayoutState { layout: Map; - occupied: Map; - bounds: Map; groupAnchors: Map; + agentGroupKey: Map; + groups: Map; + spatial: SpatialIndex; + seenGen: Map; + generation: number; locked: boolean; } export function createLayoutState(): LayoutState { return { layout: new Map(), - occupied: new Map(), - bounds: new Map(), groupAnchors: new Map(), + agentGroupKey: new Map(), + groups: new Map(), + spatial: { + cells: new Map(), + bounds: new Map(), + }, + seenGen: new Map(), + generation: 0, locked: false, }; } @@ -45,10 +80,66 @@ function hashString(input: string): number { function layoutIdForAgent(agent: AgentSnapshot): string { const identity = agentIdentity(agent); if (identity) return identity; + if (typeof agent.pid === 'number') return `${agent.pid}`; + if (agent.id) return agent.id; const groupKey = groupKeyForAgent(agent); if (groupKey) return groupKey; - if (typeof agent.pid === 'number') return `${agent.pid}`; - return agent.id || 'unknown'; + return 'unknown'; +} + +function worldToCellX(x: number): number { + return Math.floor(x / CELL_W); +} + +function worldToCellY(y: number): number { + return Math.floor(y / CELL_H); +} + +function packCell(cx: number, cy: number): number { + const ux = (cx + CELL_OFFSET) & 0xffff; + const uy = (cy + CELL_OFFSET) & 0xffff; + return ((ux << 16) | uy) >>> 0; +} + +function unpackCell(key: number): { cx: number; cy: number } { + const ux = key >>> 16; + const uy = key & 0xffff; + return { cx: ux - CELL_OFFSET, cy: uy - CELL_OFFSET }; +} + +function cellToWorld(cx: number, cy: number): Coordinate { + return { x: cx * GRID_SCALE, y: cy * GRID_SCALE }; +} + +function gridKeyFromWorld(coord: Coordinate): number { + const cx = Math.round(coord.x / GRID_SCALE); + const cy = Math.round(coord.y / GRID_SCALE); + return packCell(cx, cy); +} + +function bucketAdd(bucket: CellBucket | undefined, id: string): CellBucket { + if (bucket === undefined) return id; + if (typeof bucket === 'string') { + if (bucket === id) return bucket; + return [bucket, id]; + } + for (let i = 0; i < bucket.length; i += 1) { + if (bucket[i] === id) return bucket; + } + bucket.push(id); + return bucket; +} + +function bucketRemove(bucket: CellBucket | undefined, id: string): CellBucket | undefined { + if (bucket === undefined) return undefined; + if (typeof bucket === 'string') return bucket === id ? undefined : bucket; + const index = bucket.indexOf(id); + if (index === -1) return bucket; + const last = bucket.pop(); + if (last === undefined) return undefined; + if (index < bucket.length) bucket[index] = last; + if (bucket.length === 1) return bucket[0]; + return bucket.length ? bucket : undefined; } function boundsForCoord(coord: Coordinate): BoundsRect { @@ -67,46 +158,123 @@ function boundsIntersect(a: BoundsRect, b: BoundsRect): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } -function hasBoundsOverlap(testBounds: BoundsRect, placedBounds: Map): boolean { - for (const bounds of placedBounds.values()) { - if (boundsIntersect(testBounds, bounds)) return true; +function cellRangeForBounds(bounds: BoundsRect): { + minCx: number; + maxCx: number; + minCy: number; + maxCy: number; +} { + const minCx = worldToCellX(bounds.left); + const maxCx = worldToCellX(bounds.right - 1); + const minCy = worldToCellY(bounds.top); + const maxCy = worldToCellY(bounds.bottom - 1); + return { minCx, maxCx, minCy, maxCy }; +} + +function indexAgent(id: string, coord: Coordinate, spatial: SpatialIndex): void { + const bounds = boundsForCoord(coord); + spatial.bounds.set(id, bounds); + + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + spatial.cells.set(key, bucketAdd(bucket, id)); + } } - return false; } -function tryPlaceCoordinate( - coord: Coordinate, - nextOccupied: Map, - nextBounds: Map -): { coord: Coordinate; bounds: BoundsRect; cellKey: string } | null { - const cellKey = `${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`; - if (nextOccupied.has(cellKey)) return null; - const testBounds = boundsForCoord(coord); - if (hasBoundsOverlap(testBounds, nextBounds)) return null; - return { coord, bounds: testBounds, cellKey }; -} - -function findPlacementNearAnchor( - anchor: Coordinate, - maxRadius: number, - nextOccupied: Map, - nextBounds: Map -): { coord: Coordinate; bounds: BoundsRect; cellKey: string } | null { - const baseX = Math.round(anchor.x / GRID_SCALE); - const baseY = Math.round(anchor.y / GRID_SCALE); - - for (let radius = 0; radius <= maxRadius; radius += 1) { - for (let dx = -radius; dx <= radius; dx += 1) { - for (let dy = -radius; dy <= radius; dy += 1) { - if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; - const coord = { x: (baseX + dx) * GRID_SCALE, y: (baseY + dy) * GRID_SCALE }; - const placed = tryPlaceCoordinate(coord, nextOccupied, nextBounds); - if (placed) return placed; +function unindexAgent(id: string, spatial: SpatialIndex): void { + const bounds = spatial.bounds.get(id); + if (!bounds) return; + + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + const next = bucketRemove(bucket, id); + if (next === undefined) { + spatial.cells.delete(key); + } else { + spatial.cells.set(key, next); } } } - return null; + spatial.bounds.delete(id); +} + +function hasCollision(bounds: BoundsRect, spatial: SpatialIndex): boolean { + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + if (!bucket) continue; + if (typeof bucket === 'string') { + const other = spatial.bounds.get(bucket); + if (other && boundsIntersect(bounds, other)) return true; + } else { + for (let i = 0; i < bucket.length; i += 1) { + const other = spatial.bounds.get(bucket[i]); + if (other && boundsIntersect(bounds, other)) return true; + } + } + } + } + return false; +} + +function spiralInit(anchor: Coordinate): SpiralState { + const cx = Math.round(anchor.x / GRID_SCALE); + const cy = Math.round(anchor.y / GRID_SCALE); + return { + cx, + cy, + dir: 0, + legLen: 1, + legProgress: 0, + legsAtLen: 0, + started: false, + }; +} + +function spiralNext(state: SpiralState): { cx: number; cy: number } { + if (!state.started) { + state.started = true; + return { cx: state.cx, cy: state.cy }; + } + + switch (state.dir) { + case 0: + state.cx += 1; + break; + case 1: + state.cy -= 1; + break; + case 2: + state.cx -= 1; + break; + case 3: + state.cy += 1; + break; + } + + state.legProgress += 1; + if (state.legProgress === state.legLen) { + state.legProgress = 0; + state.dir = ((state.dir + 1) & 3) as 0 | 1 | 2 | 3; + if (state.legsAtLen === 1) { + state.legsAtLen = 0; + state.legLen += 1; + } else { + state.legsAtLen = 1; + } + } + + return { cx: state.cx, cy: state.cy }; } function hashedAnchorForGroup(groupKey: string): Coordinate { @@ -116,22 +284,97 @@ function hashedAnchorForGroup(groupKey: string): Coordinate { return { x: baseX * GRID_SCALE, y: baseY * GRID_SCALE }; } +function ensureGroupState( + state: LayoutState, + groupKey: string, + fallbackAnchor?: Coordinate +): GroupState { + let group = state.groups.get(groupKey); + if (!group) { + const anchor = state.groupAnchors.get(groupKey) ?? fallbackAnchor ?? hashedAnchorForGroup(groupKey); + group = { + anchor, + spiral: spiralInit(anchor), + freeStack: [], + }; + state.groups.set(groupKey, group); + if (!state.groupAnchors.has(groupKey)) { + state.groupAnchors.set(groupKey, anchor); + } + } else if (!state.groupAnchors.has(groupKey)) { + state.groupAnchors.set(groupKey, group.anchor); + } + return group; +} + +function findPlacement( + group: GroupState, + spatial: SpatialIndex, + maxAttempts = 256 +): Coordinate | null { + while (group.freeStack.length) { + const key = group.freeStack.pop(); + if (key === undefined) break; + const { cx, cy } = unpackCell(key); + const coord = cellToWorld(cx, cy); + const bounds = boundsForCoord(coord); + if (!hasCollision(bounds, spatial)) return coord; + } + + for (let i = 0; i < maxAttempts; i += 1) { + const { cx, cy } = spiralNext(group.spiral); + const coord = cellToWorld(cx, cy); + const bounds = boundsForCoord(coord); + if (!hasCollision(bounds, spatial)) return coord; + } + + return null; +} + +function addAgent( + state: LayoutState, + id: string, + groupKey: string +): void { + const hadGroup = state.groups.has(groupKey); + const group = ensureGroupState(state, groupKey); + const placement = findPlacement(group, state.spatial); + if (!placement) return; + state.layout.set(id, placement); + state.agentGroupKey.set(id, groupKey); + indexAgent(id, placement, state.spatial); + + if (!hadGroup) { + group.anchor = placement; + group.spiral = spiralInit(placement); + state.groupAnchors.set(groupKey, placement); + } +} + +function removeAgent(state: LayoutState, id: string): void { + const coord = state.layout.get(id); + if (coord) { + unindexAgent(id, state.spatial); + } + state.layout.delete(id); + + const groupKey = state.agentGroupKey.get(id); + if (groupKey && coord) { + const group = state.groups.get(groupKey); + if (group) { + group.freeStack.push(gridKeyFromWorld(coord)); + } + } + state.agentGroupKey.delete(id); +} + export function assignCoordinate( state: LayoutState, key: string, baseKey: string ): void { if (state.layout.has(key)) return; - const anchor = state.groupAnchors.get(baseKey) ?? hashedAnchorForGroup(baseKey || key); - const maxRadius = Math.max(24, Math.ceil(Math.sqrt(state.layout.size + 1)) * 32); - const placement = findPlacementNearAnchor(anchor, maxRadius, state.occupied, state.bounds); - if (!placement) return; - state.layout.set(key, placement.coord); - state.occupied.set(placement.cellKey, key); - state.bounds.set(key, placement.bounds); - if (!state.groupAnchors.has(baseKey)) { - state.groupAnchors.set(baseKey, placement.coord); - } + addAgent(state, key, baseKey || key); } export function updateLayout( @@ -139,74 +382,49 @@ export function updateLayout( agents: AgentSnapshot[] ): void { if (state.locked) return; + if (agents.length === 0) { state.layout.clear(); - state.occupied.clear(); - state.bounds.clear(); state.groupAnchors.clear(); + state.agentGroupKey.clear(); + state.groups.clear(); + state.spatial.cells.clear(); + state.spatial.bounds.clear(); + state.seenGen.clear(); return; } - const agentMap = new Map(); + state.generation += 1; + const gen = state.generation; + const added: Array<{ id: string; agent: AgentSnapshot; groupKey: string }> = []; + for (const agent of agents) { const id = layoutIdForAgent(agent); const groupKey = groupKeyForAgent(agent) || id; - agentMap.set(id, { agent, groupKey }); - } + state.seenGen.set(id, gen); + state.agentGroupKey.set(id, groupKey); - for (const id of state.layout.keys()) { - if (!agentMap.has(id)) { - const coord = state.layout.get(id); - if (coord) { - state.occupied.delete(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`); - } - state.layout.delete(id); - state.bounds.delete(id); + const existingCoord = state.layout.get(id); + if (existingCoord) { + ensureGroupState(state, groupKey, existingCoord); + continue; } + added.push({ id, agent, groupKey }); } - state.occupied.clear(); - state.bounds.clear(); - for (const [id, coord] of state.layout.entries()) { - state.occupied.set(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`, id); - state.bounds.set(id, boundsForCoord(coord)); - } - - const activeGroups = new Set(); - for (const { agent, groupKey } of agentMap.values()) { - activeGroups.add(groupKey); - if (!state.groupAnchors.has(groupKey)) { - const coord = state.layout.get(layoutIdForAgent(agent)); - if (coord) state.groupAnchors.set(groupKey, coord); + for (const id of Array.from(state.layout.keys())) { + if (state.seenGen.get(id) !== gen) { + removeAgent(state, id); } } - for (const key of state.groupAnchors.keys()) { - if (!activeGroups.has(key)) state.groupAnchors.delete(key); - } - const addedAgents = Array.from(agentMap.values()) - .filter(({ agent }) => !state.layout.has(layoutIdForAgent(agent))) - .sort((a, b) => { - if (a.groupKey === b.groupKey) { - return layoutIdForAgent(a.agent).localeCompare(layoutIdForAgent(b.agent)); - } - return a.groupKey.localeCompare(b.groupKey); - }); - - const maxRadius = Math.max(32, Math.ceil(Math.sqrt(state.layout.size + addedAgents.length)) * 32); - - for (const entry of addedAgents) { - const layoutId = layoutIdForAgent(entry.agent); - const groupKey = entry.groupKey || layoutId; - const anchor = state.groupAnchors.get(groupKey) ?? hashedAnchorForGroup(groupKey); - const placement = findPlacementNearAnchor(anchor, maxRadius, state.occupied, state.bounds); - if (!placement) continue; - state.layout.set(layoutId, placement.coord); - state.occupied.set(placement.cellKey, layoutId); - state.bounds.set(layoutId, placement.bounds); - if (!state.groupAnchors.has(groupKey)) { - state.groupAnchors.set(groupKey, placement.coord); - } + added.sort((a, b) => { + if (a.groupKey === b.groupKey) return a.id.localeCompare(b.id); + return a.groupKey.localeCompare(b.groupKey); + }); + + for (const entry of added) { + addAgent(state, entry.id, entry.groupKey); } } @@ -231,11 +449,14 @@ export function setLayoutPositions( positions: Array<{ id?: string; pid?: number; x: number; y: number }> ): void { state.layout.clear(); - state.occupied.clear(); - state.bounds.clear(); state.groupAnchors.clear(); + state.agentGroupKey.clear(); + state.groups.clear(); + state.spatial.cells.clear(); + state.spatial.bounds.clear(); + state.seenGen.clear(); state.locked = true; - + const byIdentity = new Map( agents.map((agent) => [agentIdentity(agent), agent]) ); @@ -244,21 +465,26 @@ export function setLayoutPositions( .filter((agent) => typeof agent.pid === 'number') .map((agent) => [`${agent.pid}`, agent]) ); - + for (const entry of positions) { const keyId = entry?.id ?? entry?.pid; if (keyId === undefined || keyId === null) continue; const agent = byIdentity.get(String(keyId)) || byPid.get(String(keyId)) || null; if (!agent) continue; - const key = layoutIdForAgent(agent); + const id = layoutIdForAgent(agent); + const groupKey = groupKeyForAgent(agent) || id; const coord = { x: Number(entry.x) || 0, y: Number(entry.y) || 0 }; - state.layout.set(key, coord); - state.occupied.set(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`, key); - state.bounds.set(key, boundsForCoord(coord)); - const groupKey = groupKeyForAgent(agent) || key; + state.layout.set(id, coord); + state.agentGroupKey.set(id, groupKey); + indexAgent(id, coord, state.spatial); + const group = ensureGroupState(state, groupKey, coord); if (!state.groupAnchors.has(groupKey)) { state.groupAnchors.set(groupKey, coord); } + if (!group.spiral.started) { + group.anchor = coord; + group.spiral = spiralInit(coord); + } } } From f68594c99ccb95d73675e29bed6400e6d94e1a38 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:47:27 -0500 Subject: [PATCH 02/15] perf(ui): stabilize canvas rendering loop --- public/src/components/CanvasScene.tsx | 182 ++------------------------ public/src/hooks/useCanvasRenderer.ts | 142 ++++++++++++++------ 2 files changed, 117 insertions(+), 207 deletions(-) diff --git a/public/src/components/CanvasScene.tsx b/public/src/components/CanvasScene.tsx index d87de34..c9259a6 100644 --- a/public/src/components/CanvasScene.tsx +++ b/public/src/components/CanvasScene.tsx @@ -1,32 +1,10 @@ import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react'; import type { AgentSnapshot, ViewState } from '../types'; import { useCanvasRenderer } from '../hooks/useCanvasRenderer'; -import { createLayoutState, updateLayout, getCoordinate } from '../lib/layout'; -import { agentIdentity, keyForAgent } from '../lib/format'; -import { pointInDiamond, pointInQuad, isoToScreen } from '../lib/iso'; +import { createLayoutState, updateLayout } from '../lib/layout'; +import { agentIdentity } from '../lib/format'; import { Tooltip } from './Tooltip'; -const TILE_W = 96; -const TILE_H = 48; -const ROOF_SCALE = 0.28; -const ROOF_HIT_SCALE = 0.44; -const MARKER_SCALE = 0.36; -const MARKER_OFFSET = TILE_H * 0.6; - -interface HitItem { - x: number; - y: number; - roofY: number; - roofW: number; - roofH: number; - roofHitW: number; - roofHitH: number; - height: number; - agent: AgentSnapshot; - key: string; - markerY?: number; -} - interface CanvasSceneProps { agents: AgentSnapshot[]; view: ViewState; @@ -42,21 +20,19 @@ export function CanvasScene({ agents, view, selected, - searchMatches, + searchMatches: _searchMatches, onSelect, onMouseDown, onKeyDown, onWheel, }: CanvasSceneProps) { - const containerRef = useRef(null); const layoutRef = useRef(createLayoutState()); const spawnTimesRef = useRef>(new Map()); const knownIdsRef = useRef>(new Set()); const [hovered, setHovered] = useState(null); const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); - const hitListRef = useRef([]); - const { canvasRef, startRender, stopRender } = useCanvasRenderer(); + const { canvasRef, startRender, stopRender, getAgentAtPoint, getHitList } = useCanvasRenderer(); // Update layout and spawn times when agents change useLayoutEffect(() => { @@ -81,99 +57,16 @@ export function CanvasScene({ updateLayout(layoutRef.current, agents); }, [agents]); - // Rebuild hit list whenever agents or view changes - useEffect(() => { - const roofW = TILE_W * ROOF_SCALE; - const roofH = roofW * 0.5; - const roofHitW = TILE_W * ROOF_HIT_SCALE; - const roofHitH = roofHitW * 0.5; - - // Sort by depth (same as render order) - const sortedAgents = [...agents].sort((a, b) => { - const coordA = getCoordinate(layoutRef.current, a) ?? { x: 0, y: 0 }; - const coordB = getCoordinate(layoutRef.current, b) ?? { x: 0, y: 0 }; - return coordA.x + coordA.y - (coordB.x + coordB.y); - }); - - const hitList: HitItem[] = []; - - for (const agent of sortedAgents) { - const coord = getCoordinate(layoutRef.current, agent); - if (!coord) continue; - - const screen = isoToScreen(coord.x, coord.y, TILE_W, TILE_H); - const memMB = agent.mem / (1024 * 1024); - const heightBase = Math.min(120, Math.max(18, memMB * 0.4)); - const idleScale = agent.state === 'idle' ? 0.6 : 1; - const height = heightBase * idleScale; - const roofY = screen.y - height - TILE_H * 0.15; - - hitList.push({ - x: screen.x, - y: screen.y, - roofY, - roofW, - roofH, - roofHitW, - roofHitH, - height, - agent, - key: keyForAgent(agent), - }); - } - - // Calculate obstruction and marker positions - const obstructedIds = new Set(); - for (const a of hitList) { - const roofPoint = { x: a.x, y: a.roofY }; - for (const b of hitList) { - if (a === b) continue; - const topY = b.y - b.height; - const halfW = TILE_W / 2; - const halfH = TILE_H / 2; - - const leftA = { x: b.x - halfW, y: topY }; - const leftB = { x: b.x, y: topY + halfH }; - const leftC = { x: b.x, y: b.y + halfH }; - const leftD = { x: b.x - halfW, y: b.y }; - - const rightA = { x: b.x + halfW, y: topY }; - const rightB = { x: b.x, y: topY + halfH }; - const rightC = { x: b.x, y: b.y + halfH }; - const rightD = { x: b.x + halfW, y: b.y }; - - if ( - pointInQuad(roofPoint, leftA, leftB, leftC, leftD) || - pointInQuad(roofPoint, rightA, rightB, rightC, rightD) - ) { - obstructedIds.add(agentIdentity(a.agent)); - break; - } - } - } - - for (const item of hitList) { - if (obstructedIds.has(agentIdentity(item.agent))) { - item.markerY = item.roofY - MARKER_OFFSET; - } - } - - hitListRef.current = hitList; - }, [agents, view]); - useEffect(() => { const win = window as any; if (!win.__consensusMock) { win.__consensusMock = {}; } - win.__consensusMock.getHitList = () => hitListRef.current; + win.__consensusMock.getHitList = () => getHitList(); win.__consensusMock.getView = () => ({ x: view.x, y: view.y, scale: view.scale }); - }, [view]); + }, [view, getHitList]); - // Start/stop render loop useEffect(() => { - if (!canvasRef.current) return; - startRender(view, agents, { layout: layoutRef.current, hovered, @@ -181,62 +74,19 @@ export function CanvasScene({ spawnTimes: spawnTimesRef.current, deviceScale: window.devicePixelRatio || 1, }); + }, [agents, view, hovered, selected, startRender]); + useEffect(() => { return () => { stopRender(); }; - }, [agents, view, hovered, selected, searchMatches, startRender, stopRender, canvasRef]); - - const findAgentAt = useCallback((canvasX: number, canvasY: number): AgentSnapshot | null => { - // Transform canvas coordinates to world coordinates - const worldX = (canvasX - view.x) / view.scale; - const worldY = (canvasY - view.y) / view.scale; - - const markerW = TILE_W * MARKER_SCALE; - const markerH = markerW * 0.5; - const hitList = hitListRef.current; - - if (!hitList.length) return null; - - // Check markers first (for obstructed agents) - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (!item.markerY) continue; - if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) { - return item.agent; - } - } - - // Check roofs - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (pointInDiamond(worldX, worldY, item.x, item.roofY, item.roofHitW, item.roofHitH)) { - return item.agent; - } - } - - // Check base tiles - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) { - return item.agent; - } - } - - return null; - }, [view]); + }, [stopRender]); const handleMouseMove = useCallback((e: React.MouseEvent) => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const found = findAgentAt(x, y); + const found = getAgentAtPoint(e.clientX, e.clientY); setHovered(found); setTooltipPos({ x: e.clientX + 12, y: e.clientY + 12 }); - }, [findAgentAt]); + }, [getAgentAtPoint]); const handleMouseLeave = useCallback(() => { setHovered(null); @@ -249,18 +99,14 @@ export function CanvasScene({ return; } - // Calculate from click position (for direct clicks without hover) - const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const found = findAgentAt(x, y); + const found = getAgentAtPoint(e.clientX, e.clientY); if (found) { onSelect(found); } - }, [hovered, onSelect, findAgentAt]); + }, [hovered, onSelect, getAgentAtPoint]); return ( -
+
b.x + halfW || + roofPoint.y < topY || + roofPoint.y > b.y + halfH + ) { + continue; + } + const leftA = { x: b.x - halfW, y: topY }; const leftB = { x: b.x, y: topY + halfH }; const leftC = { x: b.x, y: b.y + halfH }; const leftD = { x: b.x - halfW, y: b.y }; - + const rightA = { x: b.x + halfW, y: topY }; const rightB = { x: b.x, y: topY + halfH }; const rightC = { x: b.x, y: b.y + halfH }; const rightD = { x: b.x + halfW, y: b.y }; - + if ( pointInQuad(roofPoint, leftA, leftB, leftC, leftD) || pointInQuad(roofPoint, rightA, rightB, rightC, rightD) @@ -362,6 +372,54 @@ export function useCanvasRenderer() { const hitListRef = useRef([]); const rafRef = useRef(null); const deviceScaleRef = useRef(1); + const ctxRef = useRef(null); + const viewRef = useRef({ x: 0, y: 0, scale: 1 }); + const agentsRef = useRef([]); + const optionsRef = useRef(null); + const reducedMotionRef = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => { + reducedMotionRef.current = mql.matches; + }; + update(); + if (mql.addEventListener) { + mql.addEventListener('change', update); + } else { + mql.addListener(update); + } + return () => { + if (mql.removeEventListener) { + mql.removeEventListener('change', update); + } else { + mql.removeListener(update); + } + }; + }, []); + + const syncCanvasSize = useCallback((canvas: HTMLCanvasElement) => { + const dpr = window.devicePixelRatio || 1; + const cssW = window.innerWidth; + const cssH = window.innerHeight; + const nextW = Math.max(1, Math.floor(cssW * dpr)); + const nextH = Math.max(1, Math.floor(cssH * dpr)); + + if ( + canvas.width !== nextW || + canvas.height !== nextH || + deviceScaleRef.current !== dpr + ) { + deviceScaleRef.current = dpr; + canvas.width = nextW; + canvas.height = nextH; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + } + + return { width: canvas.width, height: canvas.height, dpr }; + }, []); const startRender = useCallback(( view: ViewState, @@ -371,37 +429,45 @@ export function useCanvasRenderer() { const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; + viewRef.current = view; + agentsRef.current = agents; + optionsRef.current = options; + + if (!ctxRef.current) { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctxRef.current = ctx; + } + + if (rafRef.current) return; - // Update canvas size - deviceScaleRef.current = window.devicePixelRatio || 1; - canvas.width = window.innerWidth * deviceScaleRef.current; - canvas.height = window.innerHeight * deviceScaleRef.current; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight}px`; + syncCanvasSize(canvas); const render = () => { - const width = canvas.width; - const height = canvas.height; - + const canvasEl = canvasRef.current; + const ctx = ctxRef.current; + const opts = optionsRef.current; + if (!canvasEl || !ctx || !opts) { + rafRef.current = null; + return; + } + + const { width, height, dpr } = syncCanvasSize(canvasEl); hitListRef.current = renderFrame({ ctx, - view, - agents, - options: { ...options, deviceScale: deviceScaleRef.current }, + view: viewRef.current, + agents: agentsRef.current, + options: { ...opts, deviceScale: dpr }, width, height, + reducedMotion: reducedMotionRef.current, }); - + rafRef.current = requestAnimationFrame(render); }; - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } rafRef.current = requestAnimationFrame(render); - }, []); + }, [syncCanvasSize]); const stopRender = useCallback(() => { if (rafRef.current) { @@ -410,19 +476,16 @@ export function useCanvasRenderer() { } }, []); + const getHitList = useCallback(() => hitListRef.current, []); + const getAgentAtPoint = useCallback((screenX: number, screenY: number): AgentSnapshot | null => { const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); - const x = screenX - rect.left; - const y = screenY - rect.top; - - const viewX = (x - canvas.width / 2 / deviceScaleRef.current) / 1; // simplified - const viewY = (y - canvas.height / 2 / deviceScaleRef.current) / 1; - - // Transform to world space (this needs proper view transform) - // For now, return simplified hit detection + const view = viewRef.current; + const worldX = (screenX - rect.left - view.x) / view.scale; + const worldY = (screenY - rect.top - view.y) / view.scale; const hitList = hitListRef.current; if (!hitList.length) return null; @@ -432,7 +495,7 @@ export function useCanvasRenderer() { if (!item.markerY) continue; const markerW = TILE_W * MARKER_SCALE; const markerH = markerW * 0.5; - if (pointInDiamond(viewX, viewY, item.x, item.markerY, markerW, markerH)) { + if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) { return item.agent; } } @@ -442,7 +505,7 @@ export function useCanvasRenderer() { const item = hitList[i]; const roofHitW = item.roofHitW; const roofHitH = item.roofHitH; - if (pointInDiamond(viewX, viewY, item.x, item.roofY, roofHitW, roofHitH)) { + if (pointInDiamond(worldX, worldY, item.x, item.roofY, roofHitW, roofHitH)) { return item.agent; } } @@ -450,7 +513,7 @@ export function useCanvasRenderer() { // Check base for (let i = hitList.length - 1; i >= 0; i -= 1) { const item = hitList[i]; - if (pointInDiamond(viewX, viewY, item.x, item.y, TILE_W, TILE_H)) { + if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) { return item.agent; } } @@ -469,5 +532,6 @@ export function useCanvasRenderer() { startRender, stopRender, getAgentAtPoint, + getHitList, }; } From 374f06aeb00b41b6741d11ffb7266b5773feb573 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:47:43 -0500 Subject: [PATCH 03/15] chore: update continuity ledger --- CONTINUITY.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CONTINUITY.md b/CONTINUITY.md index d36e8c7..54c7509 100644 --- a/CONTINUITY.md +++ b/CONTINUITY.md @@ -34,6 +34,7 @@ Key decisions: - React UI is primary; server should serve React build when present. - Agent lane follows vanilla JS behavior: show all open sessions (active + idle) and sort by state rank then CPU. - Layout uses pinned positions per agent identity; only new agents are placed; existing tiles never move; placement uses max-height bounds to prevent overlap. +- Scale placement with spatial hash buckets (cell -> occupants) and persistent per-group spiral frontier; collisions checked by bounds against bucket occupants. - Codex prompt-like titles containing temp paths/turn markers are ignored for lane labels; fallback to repo or codex#pid. - Agent lane now shows only active/error sessions and uses stable sort (state rank then identity) to prevent reordering. - Active lane items now glow via `is-active` class (lane item box-shadow) to make active codex sessions visibly highlighted. @@ -133,6 +134,11 @@ State: - Tests re-run: `npm run test:unit` (149 pass), `npm run test:integration` (54 pass), `npm run test:ui` (19 pass). - Build run after pinned layout changes: `npm run build` (pass). - Copied edited files to clipboard via pbcopy; temp bundle at `/tmp/consensus-edited-files.txt`. + - Implemented spatial hash buckets + persistent group spiral frontier; fixed cell packing and bounds-based collision checks (public/src/lib/layout.ts). + - Renderer now keeps a single RAF loop, exposes hit list, and uses correct view transform for hit tests (public/src/hooks/useCanvasRenderer.ts). + - CanvasScene no longer rebuilds hit list; uses renderer hit list and stable render loop (public/src/components/CanvasScene.tsx). + - Obstruction detection checks only front occluders with AABB guard (public/src/hooks/useCanvasRenderer.ts). + - Tests run after layout/renderer updates: `npm run test:unit` (152 pass), `npm run test:integration` (58 pass), `npm run test:ui` (19 pass). - Now: - Answer user question about hot reload configuration (Vite HMR + server watch + live reload SSE). - Answer whether any additional setup is missing when using Vite. @@ -161,6 +167,7 @@ State: - Claude CLI hooks not firing for real sessions; user reports Claude does not show active when working. - Need to validate live Claude session while recording via agent-browser and inspect hook delivery. - Copy edited layout/test files to clipboard via pbcopy. (DONE) + - Implement spatial hash buckets + persistent group frontier with correct cell packing and bounds checks. (DONE) - User requested a best-practice plan (no code edits) to address review comment: `npm run dev` no longer starts Vite, so TSX client fails unless dev:client or build is run. - Q&A answered for dev workflow: always start Vite; use CONSENSUS_UI_PORT for port; on port conflict try next port. - Review plan requested for new P2/P3 items: Codex in-flight timeout default in codexLogs; TOML [tui] notifications insertion in cli/setup. From dc7fe61e58ea8c9dc52e14108a17eefecb28375e Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:32:42 -0500 Subject: [PATCH 04/15] fix(activity): stabilize codex and opencode states Ensure codex in-flight ends within 2.5s of completion, pick active JSONL per process, and make opencode message completion authoritative. Remove wheel passive listener warnings and align docs/tests. --- README.md | 11 +- docs/configuration.md | 15 +- public/src/components/AgentPanel.tsx | 32 ++- public/src/components/CanvasScene.tsx | 13 +- public/src/hooks/useCanvasRenderer.ts | 14 +- public/src/hooks/useViewState.ts | 4 +- src/codexLogs.ts | 168 ++++++++++++--- src/codexState.ts | 2 +- src/opencodeApi.ts | 38 +++- src/opencodeEvents.ts | 109 +++++++--- src/opencodeState.ts | 4 +- src/scan.ts | 199 ++++++++++++++---- tests/integration/codexLogs.test.ts | 86 +++++++- tests/integration/opencodeActivity.test.ts | 39 ++++ .../opencodeSessionActivity.test.ts | 11 + tests/unit/opencodeSessionActivity.test.ts | 23 +- 16 files changed, 608 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 852f3e4..475b327 100644 --- a/README.md +++ b/README.md @@ -83,20 +83,21 @@ consensus dev server running on http://127.0.0.1:8787 - `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream. - `CONSENSUS_OPENCODE_HOME`: override OpenCode storage (default `~/.local/share/opencode`). - `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS`: OpenCode active window after last event in ms (default `0`). -- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `3000`). +- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `0`). - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS`: OpenCode in-flight idle timeout in ms (defaults to `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`). -- `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `15000`). +- `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `2500`). +- `CONSENSUS_OPENCODE_INFLIGHT_STALE_MS`: OpenCode stale in-flight cutoff in ms (default `0`). - `CONSENSUS_PROCESS_MATCH`: regex to match codex processes. - `CONSENSUS_REDACT_PII`: set to `0` to disable redaction (default enabled). - `CONSENSUS_UI_PORT`: dev UI port for Vite when running `npm run dev` (default `5173`). - `CONSENSUS_DEBUG_OPENCODE`: set to `1` to log OpenCode server discovery. - `CONSENSUS_CODEX_EVENT_ACTIVE_MS`: Codex active window after last event in ms (default `30000`). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `3000`). +- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `0`). - `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`: Codex in-flight idle timeout in ms (default `30000`, set to `0` to disable). - `CONSENSUS_CODEX_CPU_SUSTAIN_MS`: sustained CPU window before Codex becomes active without logs (default `500`). - `CONSENSUS_CODEX_CPU_SPIKE`: Codex CPU spike threshold for immediate activation (default derived). -- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`: Codex in-flight timeout in ms (default `3000`). -- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS`: Codex max event age for in-flight signals (default `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`). +- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`: Codex in-flight timeout in ms (default `2500`). +- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS`: Codex max event age for in-flight signals (default `max(CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS, 2500)`). - `CONSENSUS_PROCESS_CACHE_MS`: process cache TTL in ms for full scans (default `1000`). - `CONSENSUS_PROCESS_CACHE_FAST_MS`: process cache TTL in ms for fast scans (default `500`). - `CONSENSUS_SESSION_CACHE_MS`: Codex session list cache TTL in ms for full scans (default `1000`). diff --git a/docs/configuration.md b/docs/configuration.md index 1b4e7f6..1ade105 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,14 +87,17 @@ All configuration is via environment variables. - Default: `0` - OpenCode event window before dropping to idle. - `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS` - - Default: `3000` + - Default: `0` - OpenCode hold window after activity. - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS` - Default: `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - OpenCode in-flight idle timeout in ms before dropping to idle if no activity is observed. - `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - - Default: `15000` + - Default: `2500` - Hard timeout (ms) used to clear OpenCode in-flight when no fresh events are observed. +- `CONSENSUS_OPENCODE_INFLIGHT_STALE_MS` + - Default: `0` + - Treat OpenCode in-flight signals older than this as stale to avoid ghost sessions. - `CONSENSUS_PROCESS_MATCH` - Default: unset - Regex to match process name or command line. @@ -114,7 +117,7 @@ All configuration is via environment variables. - Default: `750` - Treat recent Codex JSONL file mtime as activity within this window (bridges log write lag). - `CONSENSUS_CODEX_ACTIVE_HOLD_MS` - - Default: `3000` + - Default: `0` - Codex hold window after activity. - `CONSENSUS_CODEX_INFLIGHT_GRACE_MS` - Default: `750` @@ -126,10 +129,10 @@ All configuration is via environment variables. - Default: `30000` - Idle timeout to clear Codex in-flight when activity is stale. Set to `0` to disable. - `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` - - Default: `3000` + - Default: `2500` - Hard timeout to clear Codex in-flight if no recent signals and file is not fresh. - `CONSENSUS_CODEX_FILE_FRESH_MS` - - Default: `10000` + - Default: `2500` - Treat recent Codex JSONL file mtime within this window as fresh and keep in-flight on. - `CONSENSUS_CODEX_CPU_SUSTAIN_MS` - Default: `500` @@ -141,7 +144,7 @@ All configuration is via environment variables. - Default: `120000` - Treat Codex JSONL files older than this as stale to prevent stale sessions from staying active. - `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS` - - Default: `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` + - Default: `max(CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS, 2500)` - Ignore Codex events older than this when setting in-flight signals (prevents stale sessions after restart). - `CONSENSUS_PROCESS_CACHE_MS` - Default: `1000` diff --git a/public/src/components/AgentPanel.tsx b/public/src/components/AgentPanel.tsx index 38a25d6..a5ee475 100644 --- a/public/src/components/AgentPanel.tsx +++ b/public/src/components/AgentPanel.tsx @@ -1,10 +1,8 @@ import type { AgentSnapshot } from '../types'; import { - agentIdentity, labelFor, formatBytes, formatPercent, - escapeHtml, formatDate, formatDateFull, } from '../lib/format'; @@ -56,7 +54,7 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
name - {escapeHtml(labelFor(agent))} + {labelFor(agent)}
pid @@ -64,16 +62,16 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
kind - {escapeHtml(agent.kind)} + {agent.kind}
state - {escapeHtml(agent.state)} + {agent.state}
{startedAt && (
started - {escapeHtml(startedAt)} + {startedAt}
)}
@@ -85,8 +83,8 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) { {summaryRows.length > 0 ? ( summaryRows.map(([label, value]) => (
- {escapeHtml(label)} - {escapeHtml(value)} + {label} + {value}
)) ) : ( @@ -95,19 +93,19 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) { {agent.activityReason && (
activity reason - {escapeHtml(agent.activityReason)} + {agent.activityReason}
)} {lastActivityAt && (
last activity - {escapeHtml(lastActivityAt)} + {lastActivityAt}
)} {lastEventAt && (
last event - {escapeHtml(lastEventAt)} + {lastEventAt}
)}
@@ -127,23 +125,23 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
repo - {escapeHtml(agent.repo || '-')} + {agent.repo || '-'}
cwd - {escapeHtml(agent.cwd || '-')} + {agent.cwd || '-'}
session - {escapeHtml(agent.sessionPath || '-')} + {agent.sessionPath || '-'}
cmd - {escapeHtml(agent.cmd || '-')} + {agent.cmd || '-'}
model - {escapeHtml(agent.model || '-')} + {agent.model || '-'}
@@ -164,7 +162,7 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) { const time = new Date(ev.ts).toLocaleTimeString(); return (
- [{escapeHtml(time)}] {escapeHtml(truncate(ev.summary, 120))} + [{time}] {truncate(ev.summary, 120)}
); }) diff --git a/public/src/components/CanvasScene.tsx b/public/src/components/CanvasScene.tsx index c9259a6..0a2bd63 100644 --- a/public/src/components/CanvasScene.tsx +++ b/public/src/components/CanvasScene.tsx @@ -13,7 +13,7 @@ interface CanvasSceneProps { onSelect: (agent: AgentSnapshot | null) => void; onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: React.WheelEvent) => void; + onWheel: (e: WheelEvent) => void; } export function CanvasScene({ @@ -92,6 +92,16 @@ export function CanvasScene({ setHovered(null); }, []); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const handleWheel = (e: WheelEvent) => onWheel(e); + canvas.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + canvas.removeEventListener('wheel', handleWheel); + }; + }, [canvasRef, onWheel]); + const handleClick = useCallback((e: React.MouseEvent) => { // First try hovered state, otherwise calculate from click position if (hovered) { @@ -118,7 +128,6 @@ export function CanvasScene({ onClick={handleClick} onMouseDown={onMouseDown} onKeyDown={onKeyDown} - onWheel={onWheel} />
diff --git a/public/src/hooks/useCanvasRenderer.ts b/public/src/hooks/useCanvasRenderer.ts index d288b0e..46ed383 100644 --- a/public/src/hooks/useCanvasRenderer.ts +++ b/public/src/hooks/useCanvasRenderer.ts @@ -44,6 +44,18 @@ interface HitItem { markerY?: number; } +interface CanvasRendererHook { + canvasRef: React.RefObject; + startRender: ( + view: ViewState, + agents: AgentSnapshot[], + options: RendererOptions + ) => void; + stopRender: () => void; + getAgentAtPoint: (screenX: number, screenY: number) => AgentSnapshot | null; + getHitList: () => HitItem[]; +} + function drawRoundedRect( ctx: CanvasRenderingContext2D, x: number, @@ -367,7 +379,7 @@ function renderFrame(context: RenderContext): HitItem[] { return hitList; } -export function useCanvasRenderer() { +export function useCanvasRenderer(): CanvasRendererHook { const canvasRef = useRef(null); const hitListRef = useRef([]); const rafRef = useRef(null); diff --git a/public/src/hooks/useViewState.ts b/public/src/hooks/useViewState.ts index 103d05a..869c568 100644 --- a/public/src/hooks/useViewState.ts +++ b/public/src/hooks/useViewState.ts @@ -26,7 +26,7 @@ export function useViewState( ): [ViewState, ViewActions, DragState, { onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: React.WheelEvent) => void; + onWheel: (e: WheelEvent) => void; }] { const [view, setView] = useState({ x: initialView.x ?? window.innerWidth / 2, @@ -149,7 +149,7 @@ export function useViewState( } }, [pan, zoom]); - const onWheel = useCallback((e: React.WheelEvent) => { + const onWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const delta = Math.sign(e.deltaY) * -WHEEL_SENSITIVITY; zoom(delta); diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 6871a78..99e353d 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -42,6 +42,9 @@ interface TailState { lastInFlightSignalAt?: number; turnOpen?: boolean; reviewMode?: boolean; + pendingEndAt?: number; + lastEndAt?: number; + lastToolSignalAt?: number; openCallIds?: Set; lastCommand?: EventSummary; lastEdit?: EventSummary; @@ -176,13 +179,17 @@ export function hydrateTailNotify(sessionPath: string, codexHome?: string): void export async function findSessionByCwd( sessions: SessionFile[], cwd?: string, - startMs?: number + startMs?: number, + excludePaths?: Set ): Promise { if (!cwd) return undefined; const target = path.resolve(cwd); let best: SessionFile | undefined; let bestDelta = Number.POSITIVE_INFINITY; for (const session of sessions.slice(0, SESSION_CWD_CHECK_MAX)) { + if (excludePaths && excludePaths.has(path.resolve(session.path))) { + continue; + } const meta = await getSessionMeta(session.path); if (!meta?.cwd) continue; if (path.resolve(meta.cwd) !== target) continue; @@ -656,10 +663,14 @@ function summarizeEvent(ev: any): { return { kind: "other", isError, model, type }; } -export async function updateTail(sessionPath: string): Promise { +export async function updateTail( + sessionPath: string, + options?: { keepStale?: boolean } +): Promise { const nowMs = Date.now(); + const keepStale = options?.keepStale === true; const inflightEnv = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - const defaultInflightTimeoutMs = 3000; + const defaultInflightTimeoutMs = 2500; const inflightTimeoutMs = (() => { if (inflightEnv === undefined || inflightEnv.trim() === "") { return defaultInflightTimeoutMs; @@ -669,16 +680,17 @@ export async function updateTail(sessionPath: string): Promise if (parsed <= 0) return 0; return parsed; })(); + const defaultSignalFreshMs = Math.max(inflightTimeoutMs, 2500); const signalFreshMs = (() => { const raw = process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; - if (raw === undefined || raw.trim() === "") return inflightTimeoutMs; + if (raw === undefined || raw.trim() === "") return defaultSignalFreshMs; const parsed = Number(raw); - if (!Number.isFinite(parsed)) return inflightTimeoutMs; + if (!Number.isFinite(parsed)) return defaultSignalFreshMs; if (parsed <= 0) return 0; return parsed; })(); const fileFreshMs = Number( - process.env.CONSENSUS_CODEX_FILE_FRESH_MS || 1500 + process.env.CONSENSUS_CODEX_FILE_FRESH_MS || 2500 ); const staleFileMs = Number(process.env.CONSENSUS_CODEX_STALE_FILE_MS || 120000); let stat: fs.Stats; @@ -692,7 +704,10 @@ export async function updateTail(sessionPath: string): Promise fileFreshMs > 0 && nowMs - stat.mtimeMs <= fileFreshMs; const isStaleFile = - Number.isFinite(staleFileMs) && staleFileMs > 0 && nowMs - stat.mtimeMs > staleFileMs; + !keepStale && + Number.isFinite(staleFileMs) && + staleFileMs > 0 && + nowMs - stat.mtimeMs > staleFileMs; const prev = tailStates.get(sessionPath); const state: TailState = @@ -712,13 +727,48 @@ export async function updateTail(sessionPath: string): Promise state.lastInFlightSignalAt = undefined; state.lastIngestAt = undefined; }; + const clearEndMarkers = () => { + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + }; + const recordToolSignal = (ts: number) => { + state.lastToolSignalAt = Math.max(state.lastToolSignalAt || 0, ts); + }; + const finalizeEnd = (ts: number, { clearReview = false }: { clearReview?: boolean } = {}) => { + if (clearReview) state.reviewMode = false; + state.turnOpen = false; + state.inFlight = false; + state.inFlightStart = false; + state.pendingEndAt = undefined; + state.lastEndAt = ts; + clearActivitySignals(); + if (state.openCallIds) state.openCallIds.clear(); + }; + const deferEnd = (ts: number) => { + state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); + }; const expireInFlight = () => { if (!state.inFlight) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; + if (state.pendingEndAt) { + const elapsed = nowMs - state.pendingEndAt; + const forceEndMs = inflightTimeoutMs > 0 ? inflightTimeoutMs : defaultInflightTimeoutMs; + if (elapsed >= forceEndMs) { + finalizeEnd(state.pendingEndAt); + return; + } + } if (state.reviewMode) return; if (state.openCallIds && state.openCallIds.size > 0) return; + if (state.lastEndAt) { + state.inFlight = false; + state.inFlightStart = false; + state.pendingEndAt = undefined; + clearActivitySignals(); + return; + } if (fileFresh) { return; } @@ -729,11 +779,15 @@ export async function updateTail(sessionPath: string): Promise state.lastEventAt; if ( typeof lastSignal === "number" && - nowMs - lastSignal > inflightTimeoutMs + nowMs - lastSignal >= inflightTimeoutMs ) { state.inFlight = false; state.inFlightStart = false; + state.turnOpen = false; + state.pendingEndAt = undefined; + state.lastEndAt = nowMs; clearActivitySignals(); + if (state.openCallIds) state.openCallIds.clear(); } }; @@ -742,6 +796,10 @@ export async function updateTail(sessionPath: string): Promise state.inFlight = false; state.inFlightStart = false; state.turnOpen = false; + state.reviewMode = false; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.lastToolSignalAt = undefined; state.lastInFlightSignalAt = undefined; if (state.openCallIds) state.openCallIds.clear(); } @@ -755,6 +813,10 @@ export async function updateTail(sessionPath: string): Promise state.partial = ""; state.events = []; state.lastEventAt = undefined; + state.reviewMode = false; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.lastToolSignalAt = undefined; state.lastCommand = undefined; state.lastEdit = undefined; state.lastMessage = undefined; @@ -766,6 +828,7 @@ export async function updateTail(sessionPath: string): Promise state.turnOpen = undefined; state.openCallIds = undefined; state.lastInFlightSignalAt = undefined; + state.lastToolSignalAt = undefined; } if (stat.size === state.offset) { @@ -920,6 +983,7 @@ export async function updateTail(sessionPath: string): Promise ]); if (typeof type === "string") { if (isTurnStart) { + clearEndMarkers(); state.turnOpen = true; if (canSignal) { state.inFlight = true; @@ -929,6 +993,7 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isResponseStart) { + clearEndMarkers(); state.turnOpen = true; if (canSignal) { state.inFlight = true; @@ -938,6 +1003,7 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isItemStarted && itemStartWorkTypes.has(itemTypeLower)) { + clearEndMarkers(); if (canSignal) { state.turnOpen = true; state.inFlight = true; @@ -947,6 +1013,7 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isResponseDelta) { + clearEndMarkers(); state.turnOpen = true; if (canSignal) { state.inFlight = true; @@ -956,26 +1023,31 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isReviewEnter) { + clearEndMarkers(); state.reviewMode = true; state.turnOpen = true; + state.inFlight = true; + state.inFlightStart = true; if (canSignal) { - state.inFlight = true; - state.inFlightStart = true; markInFlightSignal(); } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isReviewExit) { state.reviewMode = false; - state.turnOpen = false; - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts, { clearReview: false }); + } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } } if (payloadType.includes("agent_reasoning") || payloadType === "reasoning") { if (canSignal) { + clearEndMarkers(); state.turnOpen = true; state.inFlight = true; state.inFlightStart = true; @@ -984,6 +1056,7 @@ export async function updateTail(sessionPath: string): Promise } if (payloadType.includes("user_message") || payloadRole === "user") { if (canSignal) { + clearEndMarkers(); state.turnOpen = true; state.inFlight = true; state.inFlightStart = true; @@ -992,12 +1065,7 @@ export async function updateTail(sessionPath: string): Promise } } if (payloadType === "token_count") { - if (canSignal) { - state.turnOpen = true; - if (!state.inFlight) { - state.inFlight = true; - state.inFlightStart = true; - } + if (canSignal && (state.inFlight || state.turnOpen)) { markInFlightSignal(); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } @@ -1021,7 +1089,9 @@ export async function updateTail(sessionPath: string): Promise if (isToolCall) { if (!state.openCallIds) state.openCallIds = new Set(); if (callId) state.openCallIds.add(callId); + recordToolSignal(ts); if (canSignal) { + clearEndMarkers(); state.turnOpen = true; state.inFlight = true; state.inFlightStart = true; @@ -1041,7 +1111,11 @@ export async function updateTail(sessionPath: string): Promise if (state.openCallIds && callId) { state.openCallIds.delete(callId); } + recordToolSignal(ts); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + if ((state.openCallIds?.size ?? 0) === 0 && state.pendingEndAt && !state.reviewMode) { + finalizeEnd(state.pendingEndAt); + } } if (payloadType === "reasoning") { state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); @@ -1065,11 +1139,13 @@ export async function updateTail(sessionPath: string): Promise } } if (isResponseEnd) { - if (state.openCallIds) state.openCallIds.clear(); - state.turnOpen = false; - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls || state.reviewMode) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts); + } } const itemTypeIsAgentReasoning = itemTypeLower.includes("agent_reasoning"); const itemTypeIsAgentMessage = itemTypeLower.includes("agent_message"); @@ -1109,17 +1185,22 @@ export async function updateTail(sessionPath: string): Promise } } if (itemTypeIsTurnAbort) { - if (state.openCallIds) state.openCallIds.clear(); - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls || state.reviewMode) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts, { clearReview: false }); + } } if (isTurnEnd) { - if (state.openCallIds) state.openCallIds.clear(); - state.turnOpen = false; - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls || state.reviewMode) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts); + } } if (summary) { @@ -1198,6 +1279,12 @@ export async function updateTail(sessionPath: string): Promise } } + if (state.pendingEndAt) { + if ((state.openCallIds?.size ?? 0) === 0 && !state.turnOpen) { + finalizeEnd(state.pendingEndAt); + } + } + state.offset = stat.size; expireInFlight(); tailStates.set(sessionPath, state); @@ -1227,6 +1314,10 @@ export function summarizeTail(state: TailState): { lastPromptAt?: number; lastInFlightSignalAt?: number; lastIngestAt?: number; + lastEndAt?: number; + reviewMode?: boolean; + openCallCount?: number; + lastToolSignalAt?: number; inFlight?: boolean; notifyLastAt?: number; notifyLastIngestAt?: number; @@ -1248,6 +1339,9 @@ export function summarizeTail(state: TailState): { lastTool: state.lastTool?.summary, lastPrompt: state.lastPrompt?.summary, }; + const openCallCount = state.openCallIds?.size ?? 0; + const hasOpenCalls = openCallCount > 0; + const inFlight = state.inFlight || state.reviewMode || hasOpenCalls; return { doing, title, @@ -1260,7 +1354,11 @@ export function summarizeTail(state: TailState): { lastPromptAt: state.lastPrompt?.ts, lastInFlightSignalAt: state.lastInFlightSignalAt, lastIngestAt: state.lastIngestAt, - inFlight: state.inFlight, + lastEndAt: state.lastEndAt, + lastToolSignalAt: state.lastToolSignalAt, + reviewMode: state.reviewMode, + openCallCount, + inFlight: inFlight ? true : undefined, notifyLastAt: state.notifyLastAt, notifyLastIngestAt: state.notifyLastIngestAt, }; diff --git a/src/codexState.ts b/src/codexState.ts index 4648390..5df1c4c 100644 --- a/src/codexState.ts +++ b/src/codexState.ts @@ -3,7 +3,7 @@ import { deriveStateWithHold } from "./activity.js"; const DEFAULT_CODEX_CPU_SUSTAIN_MS = 500; const DEFAULT_CODEX_INFLIGHT_IDLE_MS = 30_000; -const DEFAULT_CODEX_HOLD_MS = 3000; +const DEFAULT_CODEX_HOLD_MS = 0; export interface CodexStateInput { cpu: number; diff --git a/src/opencodeApi.ts b/src/opencodeApi.ts index 75736ab..fe5537e 100644 --- a/src/opencodeApi.ts +++ b/src/opencodeApi.ts @@ -189,6 +189,11 @@ export async function getOpenCodeSessionActivity( port: number = 4096, options?: OpenCodeApiOptions ): Promise { + const staleMsRaw = process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; + const staleMs = + staleMsRaw !== undefined && staleMsRaw !== "" + ? Number(staleMsRaw) + : 0; const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 3000; const timeoutId = setTimeout(() => controller.abort(), timeoutMs); @@ -276,6 +281,7 @@ export async function getOpenCodeSessionActivity( // Also check for pending/running tool calls in message parts let hasPendingTool = false; let hasIncompletePart = false; + let latestPartStart: number | undefined; if (Array.isArray(latestAssistant.parts)) { for (const part of latestAssistant.parts) { @@ -287,17 +293,29 @@ export async function getOpenCodeSessionActivity( } } // Check for parts with start but no end time (still in progress) - if (typeof part?.time?.start === "number" && typeof part?.time?.end !== "number") { - hasIncompletePart = true; + if (typeof part?.time?.start === "number") { + if (typeof part?.time?.end !== "number") { + hasIncompletePart = true; + } + latestPartStart = latestPartStart + ? Math.max(latestPartStart, part.time.start) + : part.time.start; } } } // Session is in flight if: // 1. Assistant message has no completed timestamp, OR - // 2. There's a pending/running tool call, OR - // 3. There's a part still in progress - let inFlight = !hasCompleted || hasPendingTool || hasIncompletePart; + // 2. There's a pending/running tool call + // Incomplete parts only matter when the message is not completed. + let inFlight = hasPendingTool || !hasCompleted; + const assistantCreatedAt = latestAssistant?.info?.time?.created; + const inFlightSignalAt = + !hasCompleted && typeof assistantCreatedAt === "number" + ? assistantCreatedAt + : hasPendingTool && typeof latestPartStart === "number" + ? latestPartStart + : undefined; if (!inFlight && latestMessageRole === "user" && typeof latestMessageAt === "number") { const windowMsRaw = process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS; @@ -310,6 +328,16 @@ export async function getOpenCodeSessionActivity( } } + if (inFlight && Number.isFinite(staleMs) && staleMs > 0) { + if (typeof inFlightSignalAt === "number") { + if (Date.now() - inFlightSignalAt > staleMs) { + inFlight = false; + } + } else if (typeof latestActivityAt !== "number") { + inFlight = false; + } + } + return { ok: true, inFlight, diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index 0f3a697..a9da1e5 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -31,6 +31,8 @@ interface ActivityState { lastEventAt?: number; lastActivityAt?: number; lastInFlightSignalAt?: number; + lastStatus?: string; + lastStatusAt?: number; lastCommand?: EventSummary; lastEdit?: EventSummary; lastMessage?: EventSummary; @@ -87,6 +89,7 @@ function logDebug(message: string): void { function expireInFlight(state: ActivityState, now: number): void { if (!state.inFlight) return; + if (typeof state.lastStatusAt === "number") return; const lastSignal = state.lastInFlightSignalAt ?? state.lastActivityAt ?? state.lastEventAt; if (typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS) { state.inFlight = false; @@ -191,6 +194,7 @@ function summarizeEvent(raw: any): { isError?: boolean; type?: string; inFlight?: boolean; + status?: string; } { const typeRaw = raw?.type || @@ -204,8 +208,8 @@ function summarizeEvent(raw: any): { const normalizedType = lowerType.startsWith("tui.") ? lowerType.slice(4) : lowerType; - const status = raw?.status || raw?.state || raw?.properties?.status; - const statusStr = typeof status === "string" ? status.toLowerCase() : ""; + const statusRaw = raw?.status || raw?.state || raw?.properties?.status; + const statusStr = typeof statusRaw === "string" ? statusRaw.toLowerCase() : ""; const isError = !!raw?.error || lowerType.includes("error") || statusStr.includes("error"); let inFlight: boolean | undefined; @@ -226,6 +230,12 @@ function summarizeEvent(raw: any): { // Handle session.status event with status property const sessionStatus = raw?.properties?.status?.type || raw?.properties?.status; const sessionStatusStr = typeof sessionStatus === "string" ? sessionStatus : ""; + const status = + normalizedType === "session.status" + ? sessionStatusStr + : normalizedType === "session.idle" + ? "idle" + : undefined; if (normalizedType === "session.status") { // session.status event: check if status is "busy" for in-flight @@ -234,6 +244,8 @@ function summarizeEvent(raw: any): { } else { inFlight = false; } + } else if (normalizedType === "session.idle") { + inFlight = false; } else if (START_EVENT_RE.test(normalizedType) || DELTA_EVENT_RE.test(normalizedType)) { if (!isMessagePartUpdated || shouldTreatMessagePartActive) { inFlight = true; @@ -245,7 +257,7 @@ function summarizeEvent(raw: any): { if (normalizedType.includes("compaction")) { const phase = statusStr || raw?.phase || raw?.properties?.phase; const summary = phase ? `compaction: ${phase}` : "compaction"; - return { summary, kind: "other", isError, type, inFlight }; + return { summary, kind: "other", isError, type, inFlight, status }; } const cmd = @@ -258,7 +270,7 @@ function summarizeEvent(raw: any): { (Array.isArray(raw?.args) ? raw.args.join(" ") : undefined); if (typeof cmd === "string" && cmd.trim()) { const summary = redactText(`cmd: ${cmd.trim()}`) || `cmd: ${cmd.trim()}`; - return { summary, kind: "command", isError, type, inFlight }; + return { summary, kind: "command", isError, type, inFlight, status }; } const pathHint = @@ -270,7 +282,7 @@ function summarizeEvent(raw: any): { raw?.properties?.file; if (typeof pathHint === "string" && pathHint.trim() && normalizedType.includes("file")) { const summary = redactText(`edit: ${pathHint.trim()}`) || `edit: ${pathHint.trim()}`; - return { summary, kind: "edit", isError, type, inFlight }; + return { summary, kind: "edit", isError, type, inFlight, status }; } const tool = @@ -281,7 +293,7 @@ function summarizeEvent(raw: any): { raw?.properties?.tool_name; if (typeof tool === "string" && tool.trim() && lowerType.includes("tool")) { const summary = redactText(`tool: ${tool.trim()}`) || `tool: ${tool.trim()}`; - return { summary, kind: "tool", isError, type, inFlight }; + return { summary, kind: "tool", isError, type, inFlight, status }; } const promptText = @@ -293,7 +305,7 @@ function summarizeEvent(raw: any): { const trimmed = promptText.replace(/\s+/g, " ").trim(); const snippet = trimmed.slice(0, 120); const summary = redactText(`prompt: ${snippet}`) || `prompt: ${snippet}`; - return { summary, kind: "prompt", isError, type, inFlight }; + return { summary, kind: "prompt", isError, type, inFlight, status }; } const roleRaw = @@ -320,20 +332,27 @@ function summarizeEvent(raw: any): { const snippet = trimmed.slice(0, 80); if (role === "assistant" || role === "agent") { const summary = redactText(snippet) || snippet; - return { summary, kind: "message", isError, type, inFlight: isMessagePartUpdated ? true : undefined }; + return { + summary, + kind: "message", + isError, + type, + inFlight: isMessagePartUpdated ? true : undefined, + status, + }; } if (role === "user") { const summary = redactText(`prompt: ${snippet}`) || `prompt: ${snippet}`; - return { summary, kind: "prompt", isError, type, inFlight }; + return { summary, kind: "prompt", isError, type, inFlight, status }; } } if (type && type !== "event") { const summary = redactText(`event: ${type}`) || `event: ${type}`; - return { summary, kind: "other", isError, type, inFlight }; + return { summary, kind: "other", isError, type, inFlight, status }; } - return { kind: "other", isError, type, inFlight }; + return { kind: "other", isError, type, inFlight, status }; } function isActivityEvent(input: { @@ -422,7 +441,7 @@ function handleRawEvent(raw: any): void { const ts = parsedTs ?? now; const sessionId = getSessionId(raw); const pid = getPid(raw); - const { summary, kind, isError, type, inFlight } = summarizeEvent(raw); + const { summary, kind, isError, type, inFlight, status } = summarizeEvent(raw); const activity = isActivityEvent({ kind, type: typeof type === "string" ? type : undefined, @@ -468,6 +487,17 @@ function handleRawEvent(raw: any): void { } else if (activity && state.inFlight) { state.lastInFlightSignalAt = now; } + if (typeof status === "string" && status.trim()) { + state.lastStatus = status; + state.lastStatusAt = now; + if (status.toLowerCase() === "idle") { + state.inFlight = false; + state.lastInFlightSignalAt = undefined; + } else { + state.inFlight = true; + state.lastInFlightSignalAt = now; + } + } if (prevInFlight !== state.inFlight) { logDebug( `inFlight ${prevInFlight ? "on" : "off"} -> ${state.inFlight ? "on" : "off"} ` + @@ -505,6 +535,17 @@ function handleRawEvent(raw: any): void { } else if (activity && state.inFlight) { state.lastInFlightSignalAt = now; } + if (typeof status === "string" && status.trim()) { + state.lastStatus = status; + state.lastStatusAt = now; + if (status.toLowerCase() === "idle") { + state.inFlight = false; + state.lastInFlightSignalAt = undefined; + } else { + state.inFlight = true; + state.lastInFlightSignalAt = now; + } + } } if (touched) notifyListeners(); if (rawListeners.size) notifyRawListeners(raw); @@ -619,6 +660,8 @@ export function getOpenCodeActivityBySession( lastActivityAt?: number; hasError?: boolean; inFlight?: boolean; + lastStatus?: string; + lastStatusAt?: number; } | null { if (!sessionId) return null; const state = sessionActivity.get(sessionId); @@ -626,15 +669,17 @@ export function getOpenCodeActivityBySession( expireInFlight(state, nowMs()); const events = state.events.slice(-20); const hasError = !!state.lastError || events.some((ev) => ev.isError); - return { - events, - summary: state.summary, - lastEventAt: state.lastEventAt, - lastActivityAt: state.lastActivityAt, - hasError, - inFlight: state.inFlight, - }; -} + return { + events, + summary: state.summary, + lastEventAt: state.lastEventAt, + lastActivityAt: state.lastActivityAt, + hasError, + inFlight: state.inFlight, + lastStatus: state.lastStatus, + lastStatusAt: state.lastStatusAt, + }; + } export function getOpenCodeActivityByPid( pid?: number @@ -645,6 +690,8 @@ export function getOpenCodeActivityByPid( lastActivityAt?: number; hasError?: boolean; inFlight?: boolean; + lastStatus?: string; + lastStatusAt?: number; } | null { if (typeof pid !== "number") return null; const state = pidActivity.get(pid); @@ -652,12 +699,14 @@ export function getOpenCodeActivityByPid( expireInFlight(state, nowMs()); const events = state.events.slice(-20); const hasError = !!state.lastError || events.some((ev) => ev.isError); - return { - events, - summary: state.summary, - lastEventAt: state.lastEventAt, - lastActivityAt: state.lastActivityAt, - hasError, - inFlight: state.inFlight, - }; -} + return { + events, + summary: state.summary, + lastEventAt: state.lastEventAt, + lastActivityAt: state.lastActivityAt, + hasError, + inFlight: state.inFlight, + lastStatus: state.lastStatus, + lastStatusAt: state.lastStatusAt, + }; + } diff --git a/src/opencodeState.ts b/src/opencodeState.ts index 16f8a62..a28fad6 100644 --- a/src/opencodeState.ts +++ b/src/opencodeState.ts @@ -2,7 +2,7 @@ import type { AgentState } from "./types.js"; import type { ActivityHoldResult } from "./activity.js"; import { deriveStateWithHold } from "./activity.js"; -const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 15000; +const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 2500; export interface OpenCodeStateInput { hasError: boolean; @@ -33,7 +33,7 @@ export function deriveOpenCodeState(input: OpenCodeStateInput): ActivityHoldResu return { state, lastActiveAt: input.inFlight ? now : undefined, reason }; } const holdMs = - input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 3000); + input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 0); const envInFlightIdle = process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS; const envInFlightTimeout = process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS; let inFlightIdleMs: number | undefined = diff --git a/src/scan.ts b/src/scan.ts index 47096a5..42271f3 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -816,6 +816,24 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { + const jsonlCandidates = Array.from( + new Set([ + ...codexWrapperProcs.map((proc) => proc.pid), + ...codexVendorChildren.map((proc) => proc.pid), + ]) + ); + if (jsonlCandidates.length > 0) { + const jsonlTimer = startProfile("jsonl", "fast"); + try { + jsonlByPid = await getJsonlForPids(jsonlCandidates); + processCache.jsonlByPid = jsonlByPid; + } catch { + // ignore refresh failures + } + endProfile(jsonlTimer, { count: jsonlByPid.size }); + } + } } if (!shouldUseProcessCache) { const usageTimer = startProfile("pidusage"); @@ -999,18 +1017,33 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise[] = []; for (const [dir, sessions] of opencodeAllSessionsByDir.entries()) { const tuiCount = opencodeTuiByDir.get(dir)?.length ?? 0; - if (tuiCount <= 1) continue; - const candidates = sessions.slice(0, Math.max(tuiCount, 2)); + if (tuiCount === 0) continue; + const minCandidates = Math.max(tuiCount, 2); tasks.push( (async () => { const activeIds: string[] = []; - for (const session of candidates) { + let checked = 0; + for (const session of sessions) { + if (checked >= minCandidates && activeIds.length >= tuiCount) break; const id = getOpenCodeSessionId(session); - if (!id) continue; + if (!id) { + checked += 1; + continue; + } + const statusActivity = getOpenCodeActivityBySession(id); + const statusValue = statusActivity?.lastStatus?.toLowerCase(); + if (statusValue) { + if (statusValue !== "idle") { + activeIds.push(id); + } + checked += 1; + continue; + } const activity = await getCachedOpenCodeSessionActivity(id); if (activity.ok && activity.inFlight) { activeIds.push(id); } + checked += 1; } if (activeIds.length > 0) { opencodeActiveSessionIdsByDir.set(dir, activeIds); @@ -1057,7 +1090,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const codexHoldMs = resolveMs( process.env.CONSENSUS_CODEX_ACTIVE_HOLD_MS, - 3000 + 0 ); const codexEventIdleMs = resolveMs( process.env.CONSENSUS_CODEX_EVENT_IDLE_MS, @@ -1074,6 +1107,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); @@ -1136,28 +1170,48 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise item.path.includes(sessionId))) || (sessionId ? await findSessionById(codexHome, sessionId) : undefined) || - cachedSession || - session; + session || + cwdSession || + cachedSession; + if (cwdSession && mappedSession) { + const mappedMtime = mappedSession.mtimeMs ?? 0; + const cwdMtime = cwdSession.mtimeMs ?? 0; + if (cwdMtime > mappedMtime + 1000) { + session = cwdSession; + } + } const hasExplicitSession = !!sessionId || !!mappedJsonl; const allowReuse = hasExplicitSession || /\bresume\b/i.test(cmdRaw); const initialSessionPath = normalizeSessionPath(session?.path); + let reuseBlocked = false; if (initialSessionPath && usedSessionPaths.has(initialSessionPath) && !allowReuse) { - const alternate = await findSessionByCwd(sessions, cwdRaw, startMs); + const alternate = cwdSession; const alternatePath = normalizeSessionPath(alternate?.path); if (alternate && alternatePath && alternatePath !== initialSessionPath) { session = alternate; + } else { + reuseBlocked = true; } } - if (!session && cwdRaw) { - session = await findSessionByCwd(sessions, cwdRaw, startMs); + if (!session) { + session = cwdSession; + reuseBlocked = false; } const sessionPath = normalizeSessionPath(session?.path); if (sessionPath) { @@ -1172,21 +1226,37 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const cachedTails = new Map>>(); + const tailOptionsByPath = new Map(); if (includeActivity) { if (dirtySessions) { for (const dirtyPath of dirtySessions) { - tailTargets.add(path.resolve(dirtyPath)); + const resolved = path.resolve(dirtyPath); + tailTargets.add(resolved); + if (!tailOptionsByPath.has(resolved)) { + tailOptionsByPath.set(resolved, { keepStale: false }); + } } } for (const ctx of codexContexts) { const sessionPath = normalizeSessionPath(ctx.session?.path); if (!sessionPath) continue; tailTargets.add(sessionPath); + tailOptionsByPath.set(sessionPath, { keepStale: true }); + if (ctx.jsonlPaths?.length) { + for (const jsonlPath of ctx.jsonlPaths) { + tailTargets.add(jsonlPath); + if (!tailOptionsByPath.has(jsonlPath)) { + tailOptionsByPath.set(jsonlPath, { keepStale: true }); + } + } + } } } const tailsTimer = startProfile("tails"); @@ -1194,7 +1264,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { - const tail = await updateTail(sessionPath); + const tail = await updateTail(sessionPath, tailOptionsByPath.get(sessionPath)); return [sessionPath, tail] as const; }) ) @@ -1205,7 +1275,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { + if (!paths || paths.length === 0) return undefined; + let bestPath: string | undefined; + let bestScore = -1; + let bestActivity = -1; + for (const candidate of paths) { + const tail = includeActivity ? tailsByPath.get(candidate) : getTailState(candidate); + const summary = tail ? summarizeTail(tail) : undefined; + const inFlightScore = summary?.inFlight ? 1 : 0; + const activity = summary?.lastActivityAt ?? summary?.lastEventAt ?? 0; + const score = inFlightScore * 10 + (activity > 0 ? 1 : 0); + if (score > bestScore || (score === bestScore && activity > bestActivity)) { + bestScore = score; + bestActivity = activity; + bestPath = candidate; + } + } + if (bestPath) return bestPath; + const fallback = pickNewestJsonl(paths, jsonlMtimes); + return normalizeSessionPath(fallback); + }; + const sessionPath = + pickBestJsonl(ctx.jsonlPaths) || normalizeSessionPath(session?.path); // Get thread state from event store (webhook-based events) let threadId: string | undefined; @@ -1238,6 +1330,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise= tailActivityAtCandidate); + const notifyShouldEnd = tailAllowsNotifyEnd && notifyEndIsFresh && !tailEndAt; + if (notifyShouldEnd) { + inFlight = false; + } + const explicitEndAt = tailEndAt ?? (notifyShouldEnd ? notifyEndAt : undefined); + const effectiveHoldMs = explicitEndAt ? 0 : codexHoldMs; const effectiveIdleMs = inFlight ? 0 : codexEventIdleMs; const eventState = deriveCodexEventState({ inFlight, lastActivityAt: activityAt, hasError, now, - holdMs: codexHoldMs, + holdMs: effectiveHoldMs, idleMs: effectiveIdleMs, }); state = eventState.state; @@ -1382,7 +1495,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { const second = await updateTail(file); assert.ok(second); const secondSummary = summarizeTail(second); - assert.equal(secondSummary.inFlight, false); + assert.equal(secondSummary.inFlight, undefined); const turnCompleted = { type: "turn.completed", @@ -775,7 +775,7 @@ test("response.completed clears in-flight even without turn end", async () => { const third = await updateTail(file); assert.ok(third); const thirdSummary = summarizeTail(third); - assert.equal(thirdSummary.inFlight, false); + assert.equal(thirdSummary.inFlight, undefined); Date.now = originalNow; await fs.rm(dir, { recursive: true, force: true }); @@ -854,13 +854,77 @@ test("review mode keeps in-flight until exited", async () => { const stateExit = await updateTail(file); assert.ok(stateExit); const summaryExit = summarizeTail(stateExit); - assert.equal(summaryExit.inFlight, false); + assert.equal(summaryExit.inFlight, undefined); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; await fs.rm(dir, { recursive: true, force: true }); }); +test("forces end after timeout when tool outputs never arrive", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + delete process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { type: "response.started", ts: 1 }, + { type: "response_item", ts: 2, payload: { type: "function_call", name: "tool", call_id: "call_1" } }, + { type: "response.completed", ts: 3 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 6_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("turnOpen expires after timeout without explicit end", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { type: "turn.started", ts: 1 }, + { type: "event_msg", ts: 2, payload: { type: "agent_message", message: "working" } }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 6_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + test("open tool call keeps in-flight without new events", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); @@ -898,7 +962,7 @@ test("open tool call keeps in-flight without new events", async () => { await fs.rm(dir, { recursive: true, force: true }); }); -test("expires in-flight codex state by default", async () => { +test("keeps in-flight codex state within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); const originalNow = Date.now; @@ -919,18 +983,18 @@ test("expires in-flight codex state by default", async () => { const summaryStart = summarizeTail(stateStart); assert.equal(summaryStart.inFlight, true); - Date.now = () => 5_000; + Date.now = () => 2_000; const stateLater = await updateTail(file); assert.ok(stateLater); const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, false); + assert.equal(summaryLater.inFlight, true); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; await fs.rm(dir, { recursive: true, force: true }); }); -test("invalid in-flight timeout falls back to default", async () => { +test("invalid in-flight timeout keeps in-flight within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); const originalNow = Date.now; @@ -951,11 +1015,11 @@ test("invalid in-flight timeout falls back to default", async () => { const summaryStart = summarizeTail(stateStart); assert.equal(summaryStart.inFlight, true); - Date.now = () => 5_000; + Date.now = () => 2_000; const stateLater = await updateTail(file); assert.ok(stateLater); const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, false); + assert.equal(summaryLater.inFlight, true); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; @@ -998,7 +1062,7 @@ test("turn.completed clears in-flight after response completion", async () => { const stateEnd = await updateTail(file); assert.ok(stateEnd); const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, false); + assert.equal(summaryEnd.inFlight, undefined); Date.now = originalNow; await fs.rm(dir, { recursive: true, force: true }); diff --git a/tests/integration/opencodeActivity.test.ts b/tests/integration/opencodeActivity.test.ts index 2441b7b..5fb9a46 100644 --- a/tests/integration/opencodeActivity.test.ts +++ b/tests/integration/opencodeActivity.test.ts @@ -4,6 +4,9 @@ import http from "node:http"; import { getOpenCodeSessionActivity } from "../../src/opencodeApi.js"; import { deriveOpenCodeState } from "../../src/opencodeState.ts"; +const originalStale = process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; +process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = "0"; + // Helper to create a mock HTTP server that returns specified messages function createMockServer( responseData: unknown, @@ -186,6 +189,34 @@ test("getOpenCodeSessionActivity returns inFlight=true for incomplete part (no e } }); +test("getOpenCodeSessionActivity returns inFlight=false when message completed but parts incomplete", async () => { + const now = Date.now(); + const messages = [ + { + info: { + id: "msg_1", + sessionID: "ses_test", + role: "assistant", + time: { created: now - 2000, completed: now - 1000 }, + }, + parts: [ + { id: "prt_1", type: "reasoning", time: { start: now - 1500 } }, + ], + }, + ]; + const mock = await createMockServer(messages); + try { + const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { + silent: true, + timeoutMs: 5000, + }); + assert.equal(result.ok, true); + assert.equal(result.inFlight, false, "Completed message should end in-flight"); + } finally { + await mock.close(); + } +}); + test("getOpenCodeSessionActivity handles empty messages array", async () => { const mock = await createMockServer([]); try { @@ -389,3 +420,11 @@ test("getOpenCodeSessionActivity handles completed message with completed tools" await mock.close(); } }); + +process.on("exit", () => { + if (originalStale === undefined) { + delete process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; + } else { + process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = originalStale; + } +}); diff --git a/tests/integration/opencodeSessionActivity.test.ts b/tests/integration/opencodeSessionActivity.test.ts index 02e3112..e093738 100644 --- a/tests/integration/opencodeSessionActivity.test.ts +++ b/tests/integration/opencodeSessionActivity.test.ts @@ -3,6 +3,9 @@ import assert from "node:assert/strict"; import http from "node:http"; import { getOpenCodeSessionActivity } from "../../src/opencodeApi.ts"; +const originalStale = process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; +process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = "0"; + type ServerHandle = { server: http.Server; port: number }; async function startServer( @@ -50,6 +53,14 @@ test("getOpenCodeSessionActivity detects in-flight assistant message", async () } }); +process.on("exit", () => { + if (originalStale === undefined) { + delete process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; + } else { + process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = originalStale; + } +}); + test("getOpenCodeSessionActivity treats pending tool as in-flight", async () => { const { server, port } = await startServer((req, res) => { if (req.url === "/session/s2/message") { diff --git a/tests/unit/opencodeSessionActivity.test.ts b/tests/unit/opencodeSessionActivity.test.ts index edb7e9a..9f014cf 100644 --- a/tests/unit/opencodeSessionActivity.test.ts +++ b/tests/unit/opencodeSessionActivity.test.ts @@ -91,9 +91,9 @@ function parseMessageActivity(messages: OpenCodeMessage[]): ActivityResult { // Session is in flight if: // 1. Assistant message has no completed timestamp, OR - // 2. There's a pending/running tool call, OR - // 3. There's a part still in progress - const inFlight = !hasCompleted || hasPendingTool || hasIncompletePart; + // 2. There's a pending/running tool call + // Incomplete parts only matter when the message is not completed. + const inFlight = hasPendingTool || !hasCompleted; return { inFlight, lastActivityAt: latestActivityAt }; } @@ -259,6 +259,19 @@ test("returns inFlight=true when part has start but no end time", () => { assert.equal(result.inFlight, true, "Should be in flight due to incomplete part"); }); +test("returns inFlight=false when message completed but parts lack end", () => { + const messages: OpenCodeMessage[] = [ + { + info: { id: "msg_1", role: "assistant", time: { created: 2000, completed: 3000 } }, + parts: [ + { id: "prt_1", type: "reasoning", time: { start: 2001 } }, + ], + }, + ]; + const result = parseMessageActivity(messages); + assert.equal(result.inFlight, false, "Completed message should end in-flight"); +}); + test("returns inFlight=false when all parts have end times", () => { const messages: OpenCodeMessage[] = [ { @@ -273,7 +286,7 @@ test("returns inFlight=false when all parts have end times", () => { assert.equal(result.inFlight, false, "Should be idle when all parts completed"); }); -test("returns inFlight=true when message completed but part still in progress", () => { +test("returns inFlight=false when message completed but part still in progress", () => { const messages: OpenCodeMessage[] = [ { info: { id: "msg_1", role: "assistant", time: { created: 2000, completed: 3000 } }, @@ -284,7 +297,7 @@ test("returns inFlight=true when message completed but part still in progress", }, ]; const result = parseMessageActivity(messages); - assert.equal(result.inFlight, true, "Incomplete part should override completed timestamp"); + assert.equal(result.inFlight, false, "Completed message should end in-flight"); }); // ============================================================================= From 922e9b96a3eaf40ed4d6bf244575b483dd6a7b7f Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:58:47 -0500 Subject: [PATCH 05/15] fix(dev): increment server port and refresh opencode status Start the server on the next available port during dev and ignore stale OpenCode status events when determining in-flight state. --- dev/dev.mjs | 74 +++++++++++++++++++++++++++++++++++++++++-- src/opencodeEvents.ts | 8 ++++- src/scan.ts | 17 ++++++++-- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/dev/dev.mjs b/dev/dev.mjs index a5b6f40..578ce1a 100644 --- a/dev/dev.mjs +++ b/dev/dev.mjs @@ -52,7 +52,6 @@ const tsc = spawnChild("tsc", [ "--pretty", "false", ]); -const server = spawnChild("server", [tsxPath, "watch", "src/server.ts"]); const parsePort = (value) => { const num = Number(value); @@ -66,6 +65,77 @@ const basePort = parsePort(process.env.VITE_PORT) ?? 5173; +const baseServerPort = parsePort(process.env.CONSENSUS_PORT) ?? 8787; + +const startServer = async () => { + const maxAttempts = 6; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const port = baseServerPort + attempt; + process.stderr.write(`[dev] starting server on port ${port}\n`); + + const child = spawn( + process.execPath, + [tsxPath, "watch", "src/server.ts"], + { + env: { ...process.env, CONSENSUS_PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + } + ); + + let output = ""; + let running = false; + const onData = (chunk) => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + + child.on("exit", (code) => { + if (running) { + process.stderr.write(`[dev] server exited with ${code ?? 0}\n`); + shutdown(code ?? 1); + } + }); + + const result = await new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + running = true; + children.push(child); + resolve({ status: "running" }); + }, 1500); + + child.on("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const conflict = /EADDRINUSE|address already in use|port .* in use/i.test( + output + ); + resolve({ status: conflict ? "conflict" : "exit", code }); + }); + }); + + if (result.status === "running") return; + if (result.status === "conflict") { + process.stderr.write(`[dev] port ${port} in use, trying next\n`); + continue; + } + + process.stderr.write("[dev] server exited unexpectedly\n"); + shutdown(1); + return; + } + + process.stderr.write("[dev] failed to find an open port for server\n"); + shutdown(1); +}; + const startVite = async () => { const maxAttempts = 6; @@ -127,6 +197,7 @@ const startVite = async () => { shutdown(1); }; +void startServer(); void startVite(); const shutdown = (code = 0) => { @@ -138,7 +209,6 @@ const shutdown = (code = 0) => { }; tsc.on("exit", (code) => shutdown(code ?? 0)); -server.on("exit", (code) => shutdown(code ?? 0)); process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index a9da1e5..1060d0f 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -89,7 +89,13 @@ function logDebug(message: string): void { function expireInFlight(state: ActivityState, now: number): void { if (!state.inFlight) return; - if (typeof state.lastStatusAt === "number") return; + if (typeof state.lastStatusAt === "number") { + if (now - state.lastStatusAt <= INFLIGHT_TIMEOUT_MS) { + return; + } + state.lastStatusAt = undefined; + state.lastStatus = undefined; + } const lastSignal = state.lastInFlightSignalAt ?? state.lastActivityAt ?? state.lastEventAt; if (typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS) { state.inFlight = false; diff --git a/src/scan.ts b/src/scan.ts index 42271f3..b193090 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1000,6 +1000,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise> >(); + const opencodeStatusFreshMs = Math.max( + 2500, + resolveMs(process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS, 2500) + ); const getCachedOpenCodeSessionActivity = async (sessionId: string) => { const cached = opencodeMessageActivityCache.get(sessionId); if (cached) return cached; @@ -1032,7 +1036,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise Date: Fri, 30 Jan 2026 20:31:21 -0500 Subject: [PATCH 06/15] fix(activity): smooth opencode transitions and avoid wheel warnings --- README.md | 2 +- docs/configuration.md | 2 +- public/src/App.tsx | 2 +- public/src/components/CanvasScene.tsx | 8 ++++---- public/src/hooks/useViewState.ts | 6 +++--- src/opencodeApi.ts | 2 +- src/opencodeEvents.ts | 2 +- src/opencodeState.ts | 2 +- src/scan.ts | 18 ++++++++++++------ 9 files changed, 25 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 475b327..9f6256a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ consensus dev server running on http://127.0.0.1:8787 - `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream. - `CONSENSUS_OPENCODE_HOME`: override OpenCode storage (default `~/.local/share/opencode`). - `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS`: OpenCode active window after last event in ms (default `0`). -- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `0`). +- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `3000`). - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS`: OpenCode in-flight idle timeout in ms (defaults to `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`). - `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `2500`). - `CONSENSUS_OPENCODE_INFLIGHT_STALE_MS`: OpenCode stale in-flight cutoff in ms (default `0`). diff --git a/docs/configuration.md b/docs/configuration.md index 1ade105..1f12222 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,7 +87,7 @@ All configuration is via environment variables. - Default: `0` - OpenCode event window before dropping to idle. - `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS` - - Default: `0` + - Default: `3000` - OpenCode hold window after activity. - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS` - Default: `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` diff --git a/public/src/App.tsx b/public/src/App.tsx index a1117f9..ca15ab3 100644 --- a/public/src/App.tsx +++ b/public/src/App.tsx @@ -73,7 +73,7 @@ function App() { onSelect={select} onMouseDown={viewHandlers.onMouseDown} onKeyDown={viewHandlers.onKeyDown} - onWheel={viewHandlers.onWheel} + onCanvasWheel={viewHandlers.onCanvasWheel} /> void; onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: WheelEvent) => void; + onCanvasWheel: (e: WheelEvent) => void; } export function CanvasScene({ @@ -24,7 +24,7 @@ export function CanvasScene({ onSelect, onMouseDown, onKeyDown, - onWheel, + onCanvasWheel, }: CanvasSceneProps) { const layoutRef = useRef(createLayoutState()); const spawnTimesRef = useRef>(new Map()); @@ -95,12 +95,12 @@ export function CanvasScene({ useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - const handleWheel = (e: WheelEvent) => onWheel(e); + const handleWheel = (e: WheelEvent) => onCanvasWheel(e); canvas.addEventListener('wheel', handleWheel, { passive: false }); return () => { canvas.removeEventListener('wheel', handleWheel); }; - }, [canvasRef, onWheel]); + }, [canvasRef, onCanvasWheel]); const handleClick = useCallback((e: React.MouseEvent) => { // First try hovered state, otherwise calculate from click position diff --git a/public/src/hooks/useViewState.ts b/public/src/hooks/useViewState.ts index 869c568..22deb66 100644 --- a/public/src/hooks/useViewState.ts +++ b/public/src/hooks/useViewState.ts @@ -26,7 +26,7 @@ export function useViewState( ): [ViewState, ViewActions, DragState, { onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: WheelEvent) => void; + onCanvasWheel: (e: WheelEvent) => void; }] { const [view, setView] = useState({ x: initialView.x ?? window.innerWidth / 2, @@ -149,7 +149,7 @@ export function useViewState( } }, [pan, zoom]); - const onWheel = useCallback((e: WheelEvent) => { + const onCanvasWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const delta = Math.sign(e.deltaY) * -WHEEL_SENSITIVITY; zoom(delta); @@ -161,7 +161,7 @@ export function useViewState( startX: dragStartRef.current.x, startY: dragStartRef.current.y, }; - const handlers = { onMouseDown, onKeyDown, onWheel }; + const handlers = { onMouseDown, onKeyDown, onCanvasWheel }; return [view, actions, dragState, handlers]; } diff --git a/src/opencodeApi.ts b/src/opencodeApi.ts index fe5537e..1404d63 100644 --- a/src/opencodeApi.ts +++ b/src/opencodeApi.ts @@ -36,7 +36,7 @@ function shouldWarn(options?: OpenCodeApiOptions): boolean { return options?.silent ? false : true; } -const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 15000; +const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 2500; export async function getOpenCodeSessions( host: string = "localhost", diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index 1060d0f..5db910b 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -7,7 +7,7 @@ const STALE_TTL_MS = 30 * 60 * 1000; const RECONNECT_MIN_MS = 10_000; const isDebugActivity = () => process.env.CONSENSUS_DEBUG_ACTIVITY === "1"; const INFLIGHT_TIMEOUT_MS = Number( - process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS || 15000 + process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS || 2500 ); const ACTIVITY_KINDS = new Set(["command", "edit", "message", "prompt", "tool"]); // Meta events that don't indicate real activity diff --git a/src/opencodeState.ts b/src/opencodeState.ts index a28fad6..dbd7626 100644 --- a/src/opencodeState.ts +++ b/src/opencodeState.ts @@ -33,7 +33,7 @@ export function deriveOpenCodeState(input: OpenCodeStateInput): ActivityHoldResu return { state, lastActiveAt: input.inFlight ? now : undefined, reason }; } const holdMs = - input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 0); + input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 3000); const envInFlightIdle = process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS; const envInFlightTimeout = process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS; let inFlightIdleMs: number | undefined = diff --git a/src/scan.ts b/src/scan.ts index b193090..5c1a472 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1517,7 +1517,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise Date: Fri, 30 Jan 2026 21:36:14 -0500 Subject: [PATCH 07/15] fix(opencode): treat paused statuses as idle --- src/opencodeEvents.ts | 5 +++-- src/scan.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index 5db910b..d83c377 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -24,6 +24,7 @@ const DELTA_EVENT_RE = /(response\.((output_text|function_call_arguments|content_part|text)\.delta)|message\.part\.updated)/i; // OpenCode session status values that indicate busy const BUSY_STATUS_RE = /^(busy|running|generating|processing)$/i; +const IDLE_STATUS_RE = /^(idle|paused|stopped)$/i; interface ActivityState { events: EventSummary[]; @@ -496,7 +497,7 @@ function handleRawEvent(raw: any): void { if (typeof status === "string" && status.trim()) { state.lastStatus = status; state.lastStatusAt = now; - if (status.toLowerCase() === "idle") { + if (IDLE_STATUS_RE.test(status)) { state.inFlight = false; state.lastInFlightSignalAt = undefined; } else { @@ -544,7 +545,7 @@ function handleRawEvent(raw: any): void { if (typeof status === "string" && status.trim()) { state.lastStatus = status; state.lastStatusAt = now; - if (status.toLowerCase() === "idle") { + if (IDLE_STATUS_RE.test(status)) { state.inFlight = false; state.lastInFlightSignalAt = undefined; } else { diff --git a/src/scan.ts b/src/scan.ts index 5c1a472..2792de8 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1642,9 +1642,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise Date: Sat, 31 Jan 2026 19:37:29 -0500 Subject: [PATCH 08/15] chore(dev): improve tmux worktree setup --- dev/tmux-start.sh | 11 +- dev/tmux.conf | 9 +- dev/worktrees.sh | 201 ++++++++++++++++++++++++++++++---- docs/worktrees-tmux-agents.md | 3 +- 4 files changed, 195 insertions(+), 29 deletions(-) diff --git a/dev/tmux-start.sh b/dev/tmux-start.sh index e2911d8..b3c15cb 100755 --- a/dev/tmux-start.sh +++ b/dev/tmux-start.sh @@ -2,4 +2,13 @@ set -euo pipefail SCOUT_DIR="$(pwd -P)" -exec env SCOUT_DIR="$SCOUT_DIR" tmux -f ./dev/tmux.conf new -A -s scout + +if tmux has-session -t scout 2>/dev/null; then + tmux set-environment -g SCOUT_DIR "$SCOUT_DIR" + tmux set-environment -g PATH "$PATH" + tmux set-environment -t scout SCOUT_DIR "$SCOUT_DIR" + tmux set-environment -t scout PATH "$PATH" + exec tmux -f ./dev/tmux.conf attach -t scout +fi + +exec env SCOUT_DIR="$SCOUT_DIR" PATH="$PATH" tmux -f ./dev/tmux.conf new -s scout diff --git a/dev/tmux.conf b/dev/tmux.conf index 066d134..e738d94 100644 --- a/dev/tmux.conf +++ b/dev/tmux.conf @@ -1,8 +1,11 @@ +# Keep PATH in sync between shell and tmux. +set -ga update-environment "PATH" + # Show useful window names automatically: ":" # This makes Ctrl+b w show both the agent (command) and the worktree (dir). setw -g automatic-rename on setw -g automatic-rename-format '#{pane_current_command}:#{b:pane_current_path}' -# New windows should always start in the default worktree ("scout directory" in the demo). -# We set SCOUT_DIR in the session start command (see next section). -bind c new-window -c "#{env:SCOUT_DIR}" +# New windows should start in the default worktree when SCOUT_DIR is set. +# Fallback to the current pane path if SCOUT_DIR is missing. +bind c new-window -c "#{?env:SCOUT_DIR,#{env:SCOUT_DIR},#{pane_current_path}}" diff --git a/dev/worktrees.sh b/dev/worktrees.sh index 80718d6..a174860 100644 --- a/dev/worktrees.sh +++ b/dev/worktrees.sh @@ -1,14 +1,162 @@ # gwt: create a new branch off the current branch + create a new worktree + cd into it + +_gwt_git_bin() { + local bin + for bin in /opt/homebrew/bin/git /usr/bin/git /usr/local/bin/git; do + if [ -x "$bin" ]; then + printf '%s\n' "$bin" + return 0 + fi + done + bin="$(type -P git 2>/dev/null)" + if [ -n "$bin" ] && [ -x "$bin" ]; then + printf '%s\n' "$bin" + return 0 + fi + bin="$(command -v git 2>/dev/null)" + if [ -n "$bin" ] && [ -x "$bin" ]; then + printf '%s\n' "$bin" + return 0 + fi + return 1 +} + +_gwt_env_bin() { + if [ -x /usr/bin/env ]; then + printf '%s\n' "/usr/bin/env" + return 0 + fi + if [ -x /bin/env ]; then + printf '%s\n' "/bin/env" + return 0 + fi + if command -v env >/dev/null 2>&1; then + printf '%s\n' "env" + return 0 + fi + return 1 +} + +_gwt_mkdir_bin() { + if [ -x /bin/mkdir ]; then + printf '%s\n' "/bin/mkdir" + return 0 + fi + if command -v mkdir >/dev/null 2>&1; then + printf '%s\n' "mkdir" + return 0 + fi + return 1 +} + +_gwt_sed_bin() { + if [ -x /usr/bin/sed ]; then + printf '%s\n' "/usr/bin/sed" + return 0 + fi + if [ -x /bin/sed ]; then + printf '%s\n' "/bin/sed" + return 0 + fi + if command -v sed >/dev/null 2>&1; then + printf '%s\n' "sed" + return 0 + fi + return 1 +} + +_gwt_cat_bin() { + if [ -x /bin/cat ]; then + printf '%s\n' "/bin/cat" + return 0 + fi + if [ -x /usr/bin/cat ]; then + printf '%s\n' "/usr/bin/cat" + return 0 + fi + if command -v cat >/dev/null 2>&1; then + printf '%s\n' "cat" + return 0 + fi + return 1 +} + +_gwt_pwd_bin() { + if [ -x /bin/pwd ]; then + printf '%s\n' "/bin/pwd" + return 0 + fi + if [ -x /usr/bin/pwd ]; then + printf '%s\n' "/usr/bin/pwd" + return 0 + fi + printf '%s\n' "pwd" + return 0 +} + +_gwt_git_common_dir() { + local git_bin env_bin sed_bin cat_bin pwd_bin common gitdir commondir cwd + git_bin="$(_gwt_git_bin)" || return 1 + env_bin="$(_gwt_env_bin)" || return 1 + sed_bin="$(_gwt_sed_bin)" || return 1 + cat_bin="$(_gwt_cat_bin)" || return 1 + pwd_bin="$(_gwt_pwd_bin)" + cwd="$("$pwd_bin" -P)" + + common="$("$env_bin" -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \ + "$git_bin" -C "$cwd" rev-parse --git-common-dir 2>/dev/null)" + if [ -n "$common" ]; then + case "$common" in + /*) ;; + *) common="$cwd/$common" ;; + esac + printf '%s\n' "$common" + return 0 + fi + + if [ -e "$cwd/.git" ]; then + if [ -d "$cwd/.git" ]; then + printf '%s\n' "$cwd/.git" + return 0 + fi + gitdir="$("$sed_bin" -n 's/^gitdir: //p' "$cwd/.git" 2>/dev/null)" + if [ -n "$gitdir" ]; then + case "$gitdir" in + /*) ;; + *) gitdir="$cwd/$gitdir" ;; + esac + if [ -f "$gitdir/commondir" ]; then + commondir="$("$cat_bin" "$gitdir/commondir" 2>/dev/null)" + if [ -n "$commondir" ]; then + printf '%s\n' "$(cd "$gitdir/$commondir" && "$pwd_bin" -P)" + return 0 + fi + fi + printf '%s\n' "$gitdir" + return 0 + fi + fi + + return 1 +} + gwt() { - # Must be inside a git repo - local common main wt_root branch dir path - common="$(git rev-parse --git-common-dir 2>/dev/null)" || { + local common main wt_root branch dir path git_bin mkdir_bin pwd_bin + common="$(_gwt_git_common_dir)" || { echo "gwt: not inside a git repository" >&2 return 1 } + git_bin="$(_gwt_git_bin)" || { + echo "gwt: git not found in PATH" >&2 + return 1 + } + mkdir_bin="$(_gwt_mkdir_bin)" || { + echo "gwt: mkdir not found in PATH" >&2 + return 1 + } + pwd_bin="$(_gwt_pwd_bin)" - # The common dir is
/.git for all worktrees; main worktree is its parent - main="$(cd "$common/.." && pwd -P)" + main="$(cd "$common/.." && "$pwd_bin" -P)" wt_root="${main}.worktrees" branch="$1" @@ -21,47 +169,52 @@ gwt() { return 1 fi - # Directory name should be filesystem-friendly AND show up clearly in tmux - # Replace slashes so "task/123-foo" becomes "task-123-foo" dir="${branch//\//-}" path="${wt_root}/${dir}" - mkdir -p "$wt_root" || return 1 + "$mkdir_bin" -p "$wt_root" || return 1 - # Create branch from current HEAD and add worktree - git worktree add -b "$branch" "$path" HEAD || return 1 + "$git_bin" worktree add -b "$branch" "$path" HEAD || return 1 cd "$path" || return 1 } -# gwe: delete the current worktree (and cd back to the default worktree first) gwe() { - local common main cur branch - common="$(git rev-parse --git-common-dir 2>/dev/null)" || { + local common main cur branch git_bin env_bin pwd_bin + common="$(_gwt_git_common_dir)" || { echo "gwe: not inside a git repository" >&2 return 1 } - main="$(cd "$common/.." && pwd -P)" - cur="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 + git_bin="$(_gwt_git_bin)" || { + echo "gwe: git not found in PATH" >&2 + return 1 + } + env_bin="$(_gwt_env_bin)" || { + echo "gwe: env not found in PATH" >&2 + return 1 + } + pwd_bin="$(_gwt_pwd_bin)" + + main="$(cd "$common/.." && "$pwd_bin" -P)" + cur="$("$env_bin" -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \ + "$git_bin" -C "$main" rev-parse --show-toplevel 2>/dev/null)" + if [ -z "$cur" ]; then + return 1 + fi - # Prevent nuking the default worktree if [ "$cur" = "$main" ]; then echo "gwe: refusing to remove the default worktree: $main" >&2 return 1 fi - branch="$(git -C "$cur" rev-parse --abbrev-ref HEAD 2>/dev/null)" + branch="$("$env_bin" -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \ + "$git_bin" -C "$cur" rev-parse --abbrev-ref HEAD 2>/dev/null)" - # Move out of the worktree before removing it cd "$main" || return 1 - # Remove the worktree directory - git worktree remove --force "$cur" || return 1 + "$git_bin" worktree remove --force "$cur" || return 1 - # In the demo the intent is "task is done -> delete the worktree". - # Most teams also want to delete the local branch created for that worktree. - # This will fail safely if the branch is checked out elsewhere. if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then - git branch -D "$branch" >/dev/null 2>&1 || true + "$git_bin" branch -D "$branch" >/dev/null 2>&1 || true fi } diff --git a/docs/worktrees-tmux-agents.md b/docs/worktrees-tmux-agents.md index e93ea9a..a511e26 100644 --- a/docs/worktrees-tmux-agents.md +++ b/docs/worktrees-tmux-agents.md @@ -36,9 +36,10 @@ These must be shell functions (not standalone executables) because they `cd` int `dev/tmux.conf` enables automatic window renaming so Ctrl+B W shows `command:path`: ``` +set -ga update-environment "PATH" setw -g automatic-rename on setw -g automatic-rename-format '#{pane_current_command}:#{b:pane_current_path}' -bind c new-window -c "#{env:SCOUT_DIR}" +bind c new-window -c "#{?env:SCOUT_DIR,#{env:SCOUT_DIR},#{pane_current_path}}" ``` ## Start tmux the same way every time From 87ede28be9a9eade3eda01147d62828f339f642d Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:37:43 -0500 Subject: [PATCH 09/15] fix(opencode): improve subagent visibility --- package.json | 4 +- public/src/components/AgentPanel.tsx | 8 +- public/src/types.ts | 6 +- src/opencodeApi.ts | 131 +++++-- src/opencodeFilter.ts | 7 +- src/opencodeSessionAssign.ts | 41 ++- src/scan.ts | 526 ++++++++++++++++++++++++--- src/server.ts | 12 +- src/types.ts | 5 +- 9 files changed, 656 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index 03cb885..cd2e35c 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,6 @@ "dependencies": { "@effect/opentelemetry": "^0.61.0", "@effect/platform": "^0.94.2", - "react": "^18.3.0", - "react-dom": "^18.3.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.211.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", @@ -62,6 +60,8 @@ "express": "^4.19.2", "pidusage": "^3.0.2", "ps-list": "^8.1.1", + "react": "^18.3.0", + "react-dom": "^18.3.0", "ws": "^8.17.0" }, "devDependencies": { diff --git a/public/src/components/AgentPanel.tsx b/public/src/components/AgentPanel.tsx index a5ee475..11c16a7 100644 --- a/public/src/components/AgentPanel.tsx +++ b/public/src/components/AgentPanel.tsx @@ -58,8 +58,14 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
pid - {agent.pid} + {typeof agent.pid === 'number' ? agent.pid : '—'}
+ {!agent.pid && agent.sessionPath && ( +
+ session + {agent.sessionPath.replace(/^opencode:/, '')} +
+ )}
kind {agent.kind} diff --git a/public/src/types.ts b/public/src/types.ts index 8e6ddf7..68c2374 100644 --- a/public/src/types.ts +++ b/public/src/types.ts @@ -16,9 +16,11 @@ export interface AgentEvent { } export interface AgentSnapshot { - identity: string; + identity?: string; id: string; - pid: number; + pid?: number; + sessionId?: string; + parentSessionId?: string; cmd?: string; cmdShort?: string; kind: string; diff --git a/src/opencodeApi.ts b/src/opencodeApi.ts index 1404d63..47324d4 100644 --- a/src/opencodeApi.ts +++ b/src/opencodeApi.ts @@ -30,12 +30,89 @@ export interface OpenCodeSessionResult { export interface OpenCodeApiOptions { timeoutMs?: number; silent?: boolean; + signal?: AbortSignal; } function shouldWarn(options?: OpenCodeApiOptions): boolean { return options?.silent ? false : true; } +interface AbortContext { + signal: AbortSignal; + cleanup: () => void; + isTimedOut: () => boolean; + isParentAborted: () => boolean; +} + +function createAbortContext( + parentSignal: AbortSignal | undefined, + timeoutMs: number +): AbortContext { + const controller = new AbortController(); + let timedOut = false; + let parentAborted = false; + let timeoutId: ReturnType | undefined; + let onParentAbort: (() => void) | undefined; + + const abort = (reason?: unknown) => { + if (controller.signal.aborted) return; + try { + controller.abort(reason); + } catch { + controller.abort(); + } + }; + + if (parentSignal) { + if (parentSignal.aborted) { + parentAborted = true; + abort(parentSignal.reason); + } else { + onParentAbort = () => { + parentAborted = true; + abort(parentSignal.reason); + }; + parentSignal.addEventListener("abort", onParentAbort, { once: true }); + } + } + + if (controller.signal.aborted) { + return { + signal: controller.signal, + cleanup: () => { + if (parentSignal && onParentAbort) { + parentSignal.removeEventListener("abort", onParentAbort); + } + }, + isTimedOut: () => timedOut, + isParentAborted: () => parentAborted, + }; + } + + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + timeoutId = setTimeout(() => { + timedOut = true; + abort(); + }, timeoutMs); + } + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + if (parentSignal && onParentAbort) { + parentSignal.removeEventListener("abort", onParentAbort); + } + }; + + controller.signal.addEventListener("abort", cleanup, { once: true }); + + return { + signal: controller.signal, + cleanup, + isTimedOut: () => timedOut, + isParentAborted: () => parentAborted, + }; +} + const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 2500; export async function getOpenCodeSessions( @@ -43,9 +120,8 @@ export async function getOpenCodeSessions( port: number = 4096, options?: OpenCodeApiOptions ): Promise { - const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 5000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const abortContext = createAbortContext(options?.signal, timeoutMs); const warn = shouldWarn(options); try { @@ -54,11 +130,9 @@ export async function getOpenCodeSessions( Accept: "application/json", "User-Agent": "consensus-scanner", }, - signal: controller.signal, + signal: abortContext.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { if (warn) { console.warn(`OpenCode API error: ${response.status} ${response.statusText}`); @@ -84,10 +158,17 @@ export async function getOpenCodeSessions( } return { ok: true, sessions: [], reachable: true }; } catch (error) { - clearTimeout(timeoutId); - if (warn) { + const timedOut = abortContext.isTimedOut(); + const parentAborted = abortContext.isParentAborted(); + if (warn && !timedOut && !parentAborted) { console.warn("Failed to fetch OpenCode sessions:", error); } + if (timedOut) { + return { ok: false, sessions: [], error: "timeout", reachable: false }; + } + if (parentAborted) { + return { ok: false, sessions: [], error: "aborted", reachable: false }; + } const errorCode = typeof (error as any)?.cause?.code === "string" ? (error as any).cause.code @@ -95,6 +176,8 @@ export async function getOpenCodeSessions( ? (error as any).code : undefined; return { ok: false, sessions: [], error: errorCode, reachable: false }; + } finally { + abortContext.cleanup(); } } @@ -104,9 +187,8 @@ export async function getOpenCodeSession( port: number = 4096, options?: OpenCodeApiOptions ): Promise { - const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 5000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const abortContext = createAbortContext(options?.signal, timeoutMs); const warn = shouldWarn(options); try { @@ -115,11 +197,9 @@ export async function getOpenCodeSession( Accept: "application/json", "User-Agent": "consensus-scanner", }, - signal: controller.signal, + signal: abortContext.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { if (warn) { console.warn( @@ -131,11 +211,14 @@ export async function getOpenCodeSession( return await response.json(); } catch (error) { - clearTimeout(timeoutId); - if (warn) { + const timedOut = abortContext.isTimedOut(); + const parentAborted = abortContext.isParentAborted(); + if (warn && !timedOut && !parentAborted) { console.warn(`Failed to fetch OpenCode session ${sessionId}:`, error); } return null; + } finally { + abortContext.cleanup(); } } @@ -194,9 +277,8 @@ export async function getOpenCodeSessionActivity( staleMsRaw !== undefined && staleMsRaw !== "" ? Number(staleMsRaw) : 0; - const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 3000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const abortContext = createAbortContext(options?.signal, timeoutMs); const warn = shouldWarn(options); try { @@ -205,11 +287,9 @@ export async function getOpenCodeSessionActivity( Accept: "application/json", "User-Agent": "consensus-scanner", }, - signal: controller.signal, + signal: abortContext.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { return { ok: false, inFlight: false, error: `status_${response.status}` }; } @@ -344,10 +424,19 @@ export async function getOpenCodeSessionActivity( lastActivityAt: latestActivityAt, }; } catch (error) { - clearTimeout(timeoutId); - if (warn) { + const timedOut = abortContext.isTimedOut(); + const parentAborted = abortContext.isParentAborted(); + if (warn && !timedOut && !parentAborted) { console.warn(`Failed to fetch OpenCode session activity ${sessionId}:`, error); } + if (timedOut) { + return { ok: false, inFlight: false, error: "timeout" }; + } + if (parentAborted) { + return { ok: false, inFlight: false, error: "aborted" }; + } return { ok: false, inFlight: false, error: "fetch_error" }; + } finally { + abortContext.cleanup(); } } diff --git a/src/opencodeFilter.ts b/src/opencodeFilter.ts index be33ea2..7566a37 100644 --- a/src/opencodeFilter.ts +++ b/src/opencodeFilter.ts @@ -7,7 +7,12 @@ export interface OpenCodeIncludeInput { export function shouldIncludeOpenCodeProcess(input: OpenCodeIncludeInput): boolean { if (input.kind === "opencode-server") return true; - if (input.kind === "opencode-tui" || input.kind === "opencode-cli") return true; + if (input.kind === "opencode-session") { + return input.hasEventActivity; + } + if (input.kind === "opencode-tui" || input.kind === "opencode-cli") { + return true; + } if (input.opencodeApiAvailable) { return input.hasSession || input.hasEventActivity; } diff --git a/src/opencodeSessionAssign.ts b/src/opencodeSessionAssign.ts index 3ad969a..7a9916f 100644 --- a/src/opencodeSessionAssign.ts +++ b/src/opencodeSessionAssign.ts @@ -61,6 +61,19 @@ export const getOpenCodeSessionPid = (session: unknown): number | undefined => { ); }; +export const getOpenCodeSessionParentId = (session: unknown): string | undefined => { + if (!session || typeof session !== "object") return undefined; + const target = session as { parentID?: unknown; parentId?: unknown; parent_id?: unknown }; + const raw = target.parentID ?? target.parentId ?? target.parent_id; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed ? trimmed : undefined; +}; + +export const isOpenCodeChildSession = (session: unknown): boolean => { + return typeof getOpenCodeSessionParentId(session) === "string"; +}; + export const markOpenCodeSessionUsed = ( used: UsedSessionMap, sessionId: string, @@ -93,14 +106,16 @@ export const pickOpenCodeSessionByDir = ({ const activeIds = activeSessionIdsByDir.get(dir); if (activeIds) { for (const id of activeIds) { - if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; const activeSession = sessionsById.get(id); + if (activeSession && isOpenCodeChildSession(activeSession)) continue; + if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; if (activeSession) return activeSession; // If API no longer lists this session, keep id reserved to avoid collisions. return undefined; } } for (const session of sessions) { + if (isOpenCodeChildSession(session)) continue; const id = getOpenCodeSessionId(session); if (!id) continue; if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; @@ -129,16 +144,26 @@ export const selectOpenCodeSessionForTui = ({ usedSessionIds: UsedSessionMap; }): { session?: T; sessionId?: string; source: "pid" | "cache" | "dir" | "none" } => { const sessionByPidId = getOpenCodeSessionId(sessionByPid); - if (sessionByPidId && markOpenCodeSessionUsed(usedSessionIds, sessionByPidId, pid)) { + if ( + sessionByPidId && + !isOpenCodeChildSession(sessionByPid) && + markOpenCodeSessionUsed(usedSessionIds, sessionByPidId, pid) + ) { return { session: sessionByPid, sessionId: sessionByPidId, source: "pid" }; } - if (cachedSessionId && markOpenCodeSessionUsed(usedSessionIds, cachedSessionId, pid)) { - return { - session: sessionsById.get(cachedSessionId), - sessionId: cachedSessionId, - source: "cache", - }; + if (cachedSessionId) { + const cachedSession = sessionsById.get(cachedSessionId); + if ( + !isOpenCodeChildSession(cachedSession) && + markOpenCodeSessionUsed(usedSessionIds, cachedSessionId, pid) + ) { + return { + session: cachedSession, + sessionId: cachedSessionId, + source: "cache", + }; + } } const byDir = pickOpenCodeSessionByDir({ diff --git a/src/scan.ts b/src/scan.ts index 2792de8..b490e9e 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -80,6 +80,55 @@ const pidSessionCache = new Map< number, { path: string; lastSeenAt: number; startMs?: number } >(); + +const opencodePrefetchFailures = new Map< + string, + { count: number; firstAt: number; cooldownUntil?: number } +>(); +const OPENCODE_PREFETCH_FAILURE_WINDOW_MS = 10_000; +const OPENCODE_PREFETCH_FAILURE_THRESHOLD = 3; +const OPENCODE_PREFETCH_COOLDOWN_MS = 30_000; + +// Track subagent observation times for hold mechanism +const subagentSeen = new Map< + string, + { lastAt?: number; observedAt?: number } +>(); + +function getOpencodePrefetchKey(host: string, port: number): string { + return `${host}:${port}`; +} + +function shouldSkipOpenCodePrefetch(key: string, now: number): boolean { + const entry = opencodePrefetchFailures.get(key); + if (!entry) return false; + if (typeof entry.cooldownUntil === "number" && now < entry.cooldownUntil) { + return true; + } + if (typeof entry.cooldownUntil === "number" && now >= entry.cooldownUntil) { + opencodePrefetchFailures.delete(key); + } + return false; +} + +function recordOpenCodePrefetchFailure(key: string, now: number): void { + const entry = opencodePrefetchFailures.get(key); + if (!entry || now - entry.firstAt > OPENCODE_PREFETCH_FAILURE_WINDOW_MS) { + opencodePrefetchFailures.set(key, { count: 1, firstAt: now }); + return; + } + entry.count += 1; + if (entry.count >= OPENCODE_PREFETCH_FAILURE_THRESHOLD) { + entry.cooldownUntil = now + OPENCODE_PREFETCH_COOLDOWN_MS; + } + opencodePrefetchFailures.set(key, entry); +} + +function recordOpenCodePrefetchSuccess(key: string): void { + if (opencodePrefetchFailures.has(key)) { + opencodePrefetchFailures.delete(key); + } +} const opencodeServerLogged = new Set(); const opencodeSessionByPidCache = new Map< number, @@ -146,10 +195,18 @@ function consumeDirtySessions(): Set { return dirty; } +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + const error = new Error("Scan cancelled"); + error.name = "AbortError"; + throw error; +} + type ScanMode = "fast" | "full"; export interface ScanOptions { mode?: ScanMode; includeActivity?: boolean; + signal?: AbortSignal; } type PsProcess = Awaited>[number]; @@ -411,6 +468,22 @@ function parseTimestamp(value?: string | number): number | undefined { return undefined; } +function toMs(value: unknown): number | undefined { + if (typeof value === "number" || typeof value === "string") { + return parseTimestamp(value); + } + return undefined; +} + +function maxMs(...values: Array): number | undefined { + let max: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + if (max === undefined || value > max) max = value; + } + return max; +} + function deriveTitle( doing: string | undefined, repo: string | undefined, @@ -683,6 +756,8 @@ function findRepoRoot(cwd: string): string | null { export async function scanCodexProcesses(options: ScanOptions = {}): Promise { const now = Date.now(); const mode: ScanMode = options.mode ?? "full"; + const signal = options.signal; + throwIfAborted(signal); const includeActivity = options.includeActivity !== false; const scanTimer = startProfile("total", mode); const dirtySessions = includeActivity && mode === "fast" ? consumeDirtySessions() : null; @@ -914,6 +989,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { opencodeSessionCache = result; opencodeSessionCacheAt = now; @@ -922,12 +998,14 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0); if (opencodeProcs.length > 0 || opencodeResult.ok) { ensureOpenCodeEventStream(opencodeHost, opencodePort); @@ -1004,63 +1082,141 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { + const opencodePrefetchKey = getOpencodePrefetchKey(opencodeHost, opencodePort); + const getCachedOpenCodeSessionActivity = async ( + sessionId: string, + options: { signal?: AbortSignal; timeoutMs?: number; cache?: boolean } = {} + ) => { const cached = opencodeMessageActivityCache.get(sessionId); if (cached) return cached; const result = await getOpenCodeSessionActivity( sessionId, opencodeHost, opencodePort, - { silent: true, timeoutMs: 2000 } + { silent: true, timeoutMs: options.timeoutMs ?? 2000, signal: options.signal } ); - opencodeMessageActivityCache.set(sessionId, result); + if (options.cache !== false && result.error !== "aborted" && result.error !== "timeout") { + opencodeMessageActivityCache.set(sessionId, result); + } return result; }; const opencodeActiveSessionIdsByDir = new Map(); - const prefetchActiveSessionsByDir = async () => { - const tasks: Promise[] = []; - for (const [dir, sessions] of opencodeAllSessionsByDir.entries()) { - const tuiCount = opencodeTuiByDir.get(dir)?.length ?? 0; - if (tuiCount === 0) continue; - const minCandidates = Math.max(tuiCount, 2); - tasks.push( - (async () => { - const activeIds: string[] = []; - let checked = 0; - for (const session of sessions) { - if (checked >= minCandidates && activeIds.length >= tuiCount) break; - const id = getOpenCodeSessionId(session); - if (!id) { - checked += 1; - continue; - } - const statusActivity = getOpenCodeActivityBySession(id); - const statusValue = statusActivity?.lastStatus?.toLowerCase(); - const statusAt = statusActivity?.lastStatusAt; - const statusFresh = - typeof statusAt === "number" && now - statusAt <= opencodeStatusFreshMs; - if (statusValue && statusFresh) { - if (statusValue !== "idle") { - activeIds.push(id); + const prefetchActiveSessionsByDir = async (signal?: AbortSignal) => { + if (mode === "fast") return; + if (!opencodeApiAvailable) return; + if (signal?.aborted) return; + if (shouldSkipOpenCodePrefetch(opencodePrefetchKey, now)) return; + + const prefetchBudgetMs = 2500; + if (!Number.isFinite(prefetchBudgetMs) || prefetchBudgetMs <= 0) return; + const prefetchCallTimeoutMs = Math.min(750, prefetchBudgetMs); + const prefetchDeadline = Date.now() + prefetchBudgetMs; + + const entries = Array.from(opencodeAllSessionsByDir.entries()); + if (entries.length === 0) return; + + const prefetchController = new AbortController(); + let prefetchTimeoutId: ReturnType | undefined; + let onScanAbort: (() => void) | undefined; + + const abortPrefetch = (reason?: unknown) => { + if (prefetchController.signal.aborted) return; + try { + prefetchController.abort(reason); + } catch { + prefetchController.abort(); + } + }; + + if (signal) { + if (signal.aborted) { + abortPrefetch(signal.reason); + } else { + onScanAbort = () => abortPrefetch(signal.reason); + signal.addEventListener("abort", onScanAbort, { once: true }); + } + } + prefetchTimeoutId = setTimeout(() => abortPrefetch(), prefetchBudgetMs); + + const prefetchSignal = prefetchController.signal; + const hasBudget = () => !prefetchSignal.aborted && Date.now() < prefetchDeadline; + + const prefetchEffect = Effect.forEach( + entries, + ([dir, sessions]) => + Effect.tryPromise({ + try: async () => { + if (!hasBudget()) return; + const tuiCount = opencodeTuiByDir.get(dir)?.length ?? 0; + if (tuiCount === 0) return; + + const minCandidates = Math.max(tuiCount, 2); + const hardCap = 10; + const absoluteCap = 20; + const cappedLimit = minCandidates > hardCap ? absoluteCap : hardCap; + const maxCandidates = Math.min(sessions.length, cappedLimit); + if (maxCandidates <= 0) return; + + const activeIds: string[] = []; + let checked = 0; + const candidates = sessions.slice(0, maxCandidates); + + for (const session of candidates) { + if (!hasBudget()) return; + if (checked >= minCandidates && activeIds.length >= tuiCount) break; + const id = getOpenCodeSessionId(session); + if (!id) { + checked += 1; + continue; + } + const statusActivity = getOpenCodeActivityBySession(id); + const statusValue = statusActivity?.lastStatus?.toLowerCase(); + const statusAt = statusActivity?.lastStatusAt; + const statusFresh = + typeof statusAt === "number" && now - statusAt <= opencodeStatusFreshMs; + if (statusValue && statusFresh) { + if (!/idle|stopped|paused/.test(statusValue)) { + activeIds.push(id); + } + checked += 1; + continue; + } + const activity = await getCachedOpenCodeSessionActivity(id, { + signal: prefetchSignal, + timeoutMs: prefetchCallTimeoutMs, + cache: false, + }); + if (activity.ok) { + recordOpenCodePrefetchSuccess(opencodePrefetchKey); + if (activity.inFlight) { + activeIds.push(id); + } + } else if (activity.error !== "aborted" && activity.error !== "timeout") { + recordOpenCodePrefetchFailure(opencodePrefetchKey, Date.now()); } checked += 1; - continue; } - const activity = await getCachedOpenCodeSessionActivity(id); - if (activity.ok && activity.inFlight) { - activeIds.push(id); + + if (activeIds.length > 0) { + opencodeActiveSessionIdsByDir.set(dir, activeIds); } - checked += 1; - } - if (activeIds.length > 0) { - opencodeActiveSessionIdsByDir.set(dir, activeIds); - } - })() - ); + }, + catch: () => undefined, + }).pipe(Effect.catchAll(() => Effect.void)), + { concurrency: 5 } + ).pipe(Effect.asVoid); + + try { + await runPromise(prefetchEffect); + } finally { + if (prefetchTimeoutId) clearTimeout(prefetchTimeoutId); + if (signal && onScanAbort) { + signal.removeEventListener("abort", onScanAbort); + } } - await Promise.all(tasks); }; - await prefetchActiveSessionsByDir(); + await prefetchActiveSessionsByDir(signal); + throwIfAborted(signal); const cacheMatchesHome = sessionCache.home === codexHome && sessionCache.sessions.length > 0; let sessions: SessionFile[] = []; @@ -1529,7 +1685,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); + const assignedOpenCodeSessionIds = new Set(); for (const proc of opencodeProcs) { + if (signal?.aborted) break; const stats = usage[proc.pid] || ({} as pidusage.Status); const cpu = typeof stats.cpu === "number" ? stats.cpu : 0; const mem = typeof stats.memory === "number" ? stats.memory : 0; @@ -1601,6 +1759,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { + const subagentCandidates: Array<{ + session: OpenCodeSession; + sessionId: string; + parentId: string; + eventActivity: ReturnType | null; + messageActivity: Awaited> | null; + activityAt: number; + inFlight: boolean; + }> = []; + for (const session of opencodeSessions) { + const sessionId = getOpenCodeSessionId(session); + if (!sessionId) continue; + const parentId = typeof (session as { parentID?: string }).parentID === "string" + ? (session as { parentID?: string }).parentID + : undefined; + if (!parentId) continue; + if (!assignedOpenCodeSessionIds.has(parentId)) continue; + if (assignedOpenCodeSessionIds.has(sessionId)) continue; + + const eventActivity = getOpenCodeActivityBySession(sessionId); + const messageActivity = + opencodeMessageActivityCache.get(sessionId) || + (await getCachedOpenCodeSessionActivity(sessionId, { + signal, + timeoutMs: 750, + cache: true, + })); + const sessionUpdatedAt = opencodeSessionTimestamp(session); + const activityAt = maxMs( + toMs(eventActivity?.lastActivityAt), + toMs(messageActivity?.lastActivityAt), + sessionUpdatedAt + ); + const inFlight = + eventActivity?.inFlight === true || messageActivity?.inFlight === true; + const hasEventActivity = + typeof activityAt === "number" && + Number.isFinite(opencodeSubagentWindowMs) && + opencodeSubagentWindowMs > 0 && + now - activityAt <= opencodeSubagentWindowMs; + + // Observation hold: track when we see activity timestamps increase + const entry = subagentSeen.get(sessionId); + if (!entry) { + // First sight: store lastAt, and set observedAt only if within remote window + const newEntry: { lastAt?: number; observedAt?: number } = { + lastAt: activityAt, + }; + if ( + typeof activityAt === "number" && + now - activityAt <= opencodeSubagentWindowMs + ) { + newEntry.observedAt = now; + } + subagentSeen.set(sessionId, newEntry); + } else if ( + typeof activityAt === "number" && + (typeof entry.lastAt !== "number" || activityAt > entry.lastAt) + ) { + // Timestamp increased: update and refresh observedAt + entry.lastAt = activityAt; + entry.observedAt = now; + } else if (inFlight) { + // Currently in flight: refresh observedAt + entry.observedAt = now; + } + + const observed = subagentSeen.get(sessionId); + const hasRecentObserved = + typeof observed?.observedAt === "number" && + now - observed.observedAt <= opencodeSubagentHoldMs; + + if (!inFlight && !hasEventActivity && !hasRecentObserved) { + if (debugSubagents) { + const ageMs = + typeof activityAt === "number" ? Math.max(0, now - activityAt) : undefined; + const obsAgeMs = + typeof observed?.observedAt === "number" + ? now - observed.observedAt + : undefined; + const remoteSkewMs = ageMs; + console.log( + `[consensus][subagent-drop] ${sessionId} ` + + `inFlight=${inFlight} ` + + `hasEventActivity=${hasEventActivity} ` + + `hasRecentObserved=${hasRecentObserved} ` + + `sseAt=${String(eventActivity?.lastActivityAt)} ` + + `msgAt=${String(messageActivity?.lastActivityAt)} ` + + `updatedAt=${String(sessionUpdatedAt)} ` + + `effectiveAt=${String(activityAt)} ` + + `ageMs=${String(ageMs)} ` + + `remoteSkewMs=${String(remoteSkewMs)} ` + + `observedAt=${String(observed?.observedAt)} ` + + `obsAgeMs=${String(obsAgeMs)} ` + + `windowMs=${opencodeSubagentWindowMs} ` + + `holdMs=${opencodeSubagentHoldMs}` + ); + } + continue; + } + if (debugSubagents) { + console.log( + `[consensus][subagent-keep] ${sessionId} ` + + `inFlight=${inFlight} ` + + `hasEventActivity=${hasEventActivity} ` + + `hasRecentObserved=${hasRecentObserved} ` + + `effectiveAt=${String(activityAt)} ` + + `windowMs=${opencodeSubagentWindowMs} ` + + `holdMs=${opencodeSubagentHoldMs}` + ); + } + + subagentCandidates.push({ + session, + sessionId, + parentId, + eventActivity, + messageActivity, + activityAt: activityAt ?? (inFlight ? now : 0), + inFlight, + }); + } + + if (subagentCandidates.length) { + subagentCandidates.sort((a, b) => b.activityAt - a.activityAt); + const capped = subagentCandidates.slice(0, 20); + for (const candidate of capped) { + const { session, sessionId, parentId, eventActivity, messageActivity, inFlight } = candidate; + + const sessionIdentity = `opencode:${sessionId}`; + const id = `session:${sessionId}`; + const cached = activityCache.get(id); + const statusRaw = typeof (session as { status?: string }).status === "string" + ? (session as { status?: string }).status + : undefined; + const status = eventActivity?.lastStatus ?? statusRaw?.toLowerCase(); + const statusIsError = !!status && /error|failed|failure/.test(status); + const statusIsIdle = !!status && /idle|stopped|paused/.test(status); + const statusIsActive = !!status && /running|active|processing|busy|retry/.test(status); + const sessionUpdatedAt = opencodeSessionTimestamp(session); + const lastActivityAt = maxMs( + toMs(eventActivity?.lastActivityAt), + toMs(messageActivity?.lastActivityAt), + sessionUpdatedAt + ); + const lastEventAt = eventActivity?.lastEventAt; + const hasError = !!eventActivity?.hasError || statusIsError; + + const activity = deriveOpenCodeState({ + hasError, + lastEventAt, + lastActivityAt, + inFlight, + status, + now, + previousActiveAt: cached?.lastActiveAt, + eventWindowMs: opencodeEventWindowMs, + holdMs: opencodeHoldMs, + inFlightIdleMs: opencodeInFlightIdleMs, + strictInFlight: opencodeStrictInFlight, + }); + let state = activity.state; + let reason = activity.reason || "session"; + const activityLastActiveAt = activity.lastActiveAt ?? cached?.lastActiveAt; + if ( + activity.state === "active" && + activity.reason === "hold" && + typeof activity.lastActiveAt === "number" && + opencodeHoldMs > 0 + ) { + bumpNextTickAt(activity.lastActiveAt + opencodeHoldMs); + } + + const hasSignal = + statusIsIdle || + statusIsActive || + statusIsError || + typeof lastActivityAt === "number" || + typeof inFlight === "boolean"; + if (!hasSignal && !activityLastActiveAt) { + state = "idle"; + reason = "no_signal"; + } + + if (cached?.lastState && cached.lastState !== state) { + metricEffects.push(recordActivityTransition("opencode", cached.lastState, state, reason)); + trackTransition("opencode", cached.lastState, state, reason); + } + + activityCache.set(id, { + lastActiveAt: activityLastActiveAt, + lastSeenAt: now, + lastState: state, + lastReason: reason, + startMs: sessionUpdatedAt || undefined, + }); + seenIds.add(id); + + const sessionTitle = normalizeTitle( + (session as { title?: string; name?: string }).title || + (session as { title?: string; name?: string }).name + ); + const sessionCwd = + (session as { directory?: string; cwd?: string }).directory || + (session as { directory?: string; cwd?: string }).cwd; + const cwd = redactText(sessionCwd) || sessionCwd; + const repoRoot = sessionCwd ? findRepoRoot(sessionCwd) : null; + const repoName = repoRoot ? path.basename(repoRoot) : undefined; + const summary = eventActivity?.summary; + const events = eventActivity?.events; + const cmd = `opencode session ${sessionId}`; + const cmdShort = shortenCmd(cmd); + const doing = summary?.current || sessionTitle; + + agents.push({ + identity: sessionIdentity, + id, + pid: undefined, + sessionId, + parentSessionId: parentId, + startedAt: sessionUpdatedAt ? Math.floor(sessionUpdatedAt / 1000) : undefined, + lastEventAt, + lastActivityAt, + activityReason: reason, + title: sessionTitle, + cmd, + cmdShort, + kind: "opencode-session", + cpu: 0, + mem: 0, + state, + doing: redactText(doing) || doing, + sessionPath: `opencode:${sessionId}`, + repo: repoName, + cwd, + model: + typeof (session as { model?: string }).model === "string" + ? (session as { model?: string }).model + : undefined, + summary: summary ? sanitizeSummary(summary) : undefined, + events, + }); + } + } + + // Prune old subagent seen entries (1 hour retention) + for (const [id, entry] of subagentSeen) { + if (entry.observedAt && now - entry.observedAt > 3_600_000) { + subagentSeen.delete(id); + continue; + } + if (!entry.observedAt) { + if (typeof entry.lastAt === "number" && now - entry.lastAt > 3_600_000) { + subagentSeen.delete(id); + continue; + } + if (entry.lastAt === undefined) { + subagentSeen.delete(id); + } + } + } + } + + throwIfAborted(signal); for (const proc of claudeProcs) { const stats = usage[proc.pid] || ({} as pidusage.Status); const cpu = typeof stats.cpu === "number" ? stats.cpu : 0; diff --git a/src/server.ts b/src/server.ts index 3beafdc..42011a1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,7 +7,7 @@ import chokidar from "chokidar"; import type { FSWatcher } from "chokidar"; import { WebSocketServer, WebSocket } from "ws"; import { fileURLToPath } from "url"; -import { Effect, Fiber } from "effect"; +import { Effect, Exit, Fiber } from "effect"; import { scanCodexProcesses, markSessionDirty } from "./scan.js"; import { resolveCodexHome } from "./codexLogs.js"; import { onOpenCodeEvent, stopOpenCodeEventStream } from "./opencodeEvents.js"; @@ -617,7 +617,7 @@ async function tick(): Promise { .pipe( Effect.andThen( Effect.tryPromise({ - try: () => scanCodexProcesses({ mode, includeActivity }), + try: (signal) => scanCodexProcesses({ mode, includeActivity, signal }), catch: (err) => err as Error, }).pipe(Effect.timeout(`${scanTimeoutMs} millis`)) ) @@ -655,8 +655,12 @@ async function tick(): Promise { ) ); - const snapshot = await runPromise(scanEffect); - if (snapshot) emitSnapshot(snapshot); + const fiber = runFork(scanEffect); + const exit = await runPromise(fiber.await); + if (Exit.isSuccess(exit)) { + const snapshot = exit.value; + if (snapshot) emitSnapshot(snapshot); + } } catch (err) { // Keep server alive on scan errors. logRuntimeError("scan crashed", err); diff --git a/src/types.ts b/src/types.ts index faa8ffc..68a0999 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export type AgentKind = | "app-server" | "opencode-tui" | "opencode-cli" + | "opencode-session" | "opencode-server" | "claude-tui" | "claude-cli" @@ -29,7 +30,9 @@ export interface WorkSummary { export interface AgentSnapshot { identity?: string; id: string; - pid: number; + pid?: number; + sessionId?: string; + parentSessionId?: string; startedAt?: number; lastEventAt?: number; lastActivityAt?: number; From 14b447c562de90816656cd95cfbd3afe5fe8e35a Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:32:33 -0500 Subject: [PATCH 10/15] fix(opencode): prevent stale subagents and child session binding --- src/opencodeSessionAssign.ts | 11 ++- src/scan.ts | 133 +++++++++++++++++++---------------- 2 files changed, 83 insertions(+), 61 deletions(-) diff --git a/src/opencodeSessionAssign.ts b/src/opencodeSessionAssign.ts index 7a9916f..c7bd39e 100644 --- a/src/opencodeSessionAssign.ts +++ b/src/opencodeSessionAssign.ts @@ -92,6 +92,7 @@ export const pickOpenCodeSessionByDir = ({ sessionsById, usedSessionIds, pid, + childSessionIds, }: { dir?: string; sessionsByDir: Map; @@ -99,13 +100,16 @@ export const pickOpenCodeSessionByDir = ({ sessionsById: Map; usedSessionIds: UsedSessionMap; pid: number; + childSessionIds?: Set; }): T | undefined => { if (!dir) return undefined; const sessions = sessionsByDir.get(dir); if (!sessions || sessions.length === 0) return undefined; const activeIds = activeSessionIdsByDir.get(dir); + const isChildId = (id: string): boolean => !!childSessionIds?.has(id); if (activeIds) { for (const id of activeIds) { + if (isChildId(id)) continue; const activeSession = sessionsById.get(id); if (activeSession && isOpenCodeChildSession(activeSession)) continue; if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; @@ -118,6 +122,7 @@ export const pickOpenCodeSessionByDir = ({ if (isOpenCodeChildSession(session)) continue; const id = getOpenCodeSessionId(session); if (!id) continue; + if (isChildId(id)) continue; if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; return session; } @@ -133,6 +138,7 @@ export const selectOpenCodeSessionForTui = ({ sessionsByDir, activeSessionIdsByDir, usedSessionIds, + childSessionIds, }: { pid: number; dir?: string; @@ -142,17 +148,19 @@ export const selectOpenCodeSessionForTui = ({ sessionsByDir: Map; activeSessionIdsByDir: Map; usedSessionIds: UsedSessionMap; + childSessionIds?: Set; }): { session?: T; sessionId?: string; source: "pid" | "cache" | "dir" | "none" } => { const sessionByPidId = getOpenCodeSessionId(sessionByPid); if ( sessionByPidId && !isOpenCodeChildSession(sessionByPid) && + !childSessionIds?.has(sessionByPidId) && markOpenCodeSessionUsed(usedSessionIds, sessionByPidId, pid) ) { return { session: sessionByPid, sessionId: sessionByPidId, source: "pid" }; } - if (cachedSessionId) { + if (cachedSessionId && !childSessionIds?.has(cachedSessionId)) { const cachedSession = sessionsById.get(cachedSessionId); if ( !isOpenCodeChildSession(cachedSession) && @@ -173,6 +181,7 @@ export const selectOpenCodeSessionForTui = ({ sessionsById, usedSessionIds, pid, + childSessionIds, }); if (byDir) { return { session: byDir, sessionId: getOpenCodeSessionId(byDir), source: "dir" }; diff --git a/src/scan.ts b/src/scan.ts index b490e9e..97a74ef 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -38,6 +38,7 @@ import { } from "./opencodeEvents.js"; import { getOpenCodeSessionId, + getOpenCodeSessionParentId, getOpenCodeSessionPid, markOpenCodeSessionUsed, selectOpenCodeSessionForTui, @@ -92,7 +93,7 @@ const OPENCODE_PREFETCH_COOLDOWN_MS = 30_000; // Track subagent observation times for hold mechanism const subagentSeen = new Map< string, - { lastAt?: number; observedAt?: number } + { lastRemoteAt?: number; observedAt?: number; lastSeenAt: number } >(); function getOpencodePrefetchKey(host: string, port: number): string { @@ -1015,6 +1016,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const opencodeSessionsById = new Map(); + const opencodeChildSessionIds = new Set(); // Track ALL recent sessions per directory for activity polling (not just the latest) const opencodeAllSessionsByDir = new Map(); const opencodeSessionTimestamp = (session: OpenCodeSession): number => { @@ -1049,6 +1051,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { + for (const sessionId of subagentSeen.keys()) { + const session = opencodeSessionsById.get(sessionId); + if (!session) { + subagentSeen.delete(sessionId); + continue; + } + const parentId = getOpenCodeSessionParentId(session); + if (!parentId || !opencodeSessionsById.has(parentId)) { + subagentSeen.delete(sessionId); + } + } + } for (const sessions of opencodeAllSessionsByDir.values()) { sessions.sort((a, b) => opencodeSessionTimestamp(b) - opencodeSessionTimestamp(a)); } @@ -1707,6 +1725,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0 && - now - activityAt <= opencodeSubagentWindowMs; + now - remoteAt <= opencodeSubagentWindowMs; + const hasFallbackFresh = + typeof fallbackAt === "number" && + Number.isFinite(opencodeSubagentWindowMs) && + opencodeSubagentWindowMs > 0 && + now - fallbackAt <= opencodeSubagentWindowMs; + const hasEventActivity = hasRemoteFresh || hasFallbackFresh; + const effectiveAt = typeof remoteAt === "number" ? remoteAt : fallbackAt; + const activityAt = + typeof effectiveAt === "number" ? effectiveAt : inFlight ? now : 0; // Observation hold: track when we see activity timestamps increase - const entry = subagentSeen.get(sessionId); - if (!entry) { - // First sight: store lastAt, and set observedAt only if within remote window - const newEntry: { lastAt?: number; observedAt?: number } = { - lastAt: activityAt, - }; - if ( - typeof activityAt === "number" && - now - activityAt <= opencodeSubagentWindowMs - ) { - newEntry.observedAt = now; - } - subagentSeen.set(sessionId, newEntry); - } else if ( - typeof activityAt === "number" && - (typeof entry.lastAt !== "number" || activityAt > entry.lastAt) - ) { - // Timestamp increased: update and refresh observedAt - entry.lastAt = activityAt; - entry.observedAt = now; - } else if (inFlight) { - // Currently in flight: refresh observedAt - entry.observedAt = now; + const prev = subagentSeen.get(sessionId); + let observedAt = prev?.observedAt; + const remoteIncreased = + typeof remoteAt === "number" && remoteAt > (prev?.lastRemoteAt ?? -Infinity); + if (!prev) { + if (inFlight || hasEventActivity) observedAt = now; + } else { + if (inFlight || hasEventActivity || remoteIncreased) observedAt = now; } + subagentSeen.set(sessionId, { + lastRemoteAt: typeof remoteAt === "number" ? remoteAt : prev?.lastRemoteAt, + observedAt, + lastSeenAt: now, + }); - const observed = subagentSeen.get(sessionId); const hasRecentObserved = - typeof observed?.observedAt === "number" && - now - observed.observedAt <= opencodeSubagentHoldMs; + typeof observedAt === "number" && now - observedAt <= opencodeSubagentHoldMs; if (!inFlight && !hasEventActivity && !hasRecentObserved) { if (debugSubagents) { const ageMs = - typeof activityAt === "number" ? Math.max(0, now - activityAt) : undefined; + typeof effectiveAt === "number" ? Math.max(0, now - effectiveAt) : undefined; const obsAgeMs = - typeof observed?.observedAt === "number" - ? now - observed.observedAt - : undefined; + typeof observedAt === "number" ? now - observedAt : undefined; const remoteSkewMs = ageMs; console.log( `[consensus][subagent-drop] ${sessionId} ` + @@ -2110,10 +2131,11 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 3_600_000) { - subagentSeen.delete(id); - continue; - } - if (!entry.observedAt) { - if (typeof entry.lastAt === "number" && now - entry.lastAt > 3_600_000) { - subagentSeen.delete(id); - continue; - } - if (entry.lastAt === undefined) { - subagentSeen.delete(id); - } - } + } + + // Prune old subagent seen entries (1 hour retention) + for (const [id, entry] of subagentSeen) { + if (typeof entry.lastSeenAt !== "number" || now - entry.lastSeenAt > 3_600_000) { + subagentSeen.delete(id); } } From 9b383a95aa565a05fcbb12ccb7bb13cf00465aac Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:25:17 -0500 Subject: [PATCH 11/15] fix(codex): add default hold window to reduce flicker --- README.md | 2 +- docs/configuration.md | 4 ++-- src/scan.ts | 2 +- tests/unit/codexEventState.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9f6256a..800b179 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ consensus dev server running on http://127.0.0.1:8787 - `CONSENSUS_UI_PORT`: dev UI port for Vite when running `npm run dev` (default `5173`). - `CONSENSUS_DEBUG_OPENCODE`: set to `1` to log OpenCode server discovery. - `CONSENSUS_CODEX_EVENT_ACTIVE_MS`: Codex active window after last event in ms (default `30000`). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `0`). +- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `2000`, set to `0` to disable). - `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`: Codex in-flight idle timeout in ms (default `30000`, set to `0` to disable). - `CONSENSUS_CODEX_CPU_SUSTAIN_MS`: sustained CPU window before Codex becomes active without logs (default `500`). - `CONSENSUS_CODEX_CPU_SPIKE`: Codex CPU spike threshold for immediate activation (default derived). diff --git a/docs/configuration.md b/docs/configuration.md index 1f12222..b46223d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,8 +117,8 @@ All configuration is via environment variables. - Default: `750` - Treat recent Codex JSONL file mtime as activity within this window (bridges log write lag). - `CONSENSUS_CODEX_ACTIVE_HOLD_MS` - - Default: `0` - - Codex hold window after activity. + - Default: `2000` + - Codex hold window after activity. Set to `0` to disable (immediate transitions may flicker). - `CONSENSUS_CODEX_INFLIGHT_GRACE_MS` - Default: `750` - Codex in-flight grace window after the last in-flight signal (prevents brief idle flicker). diff --git a/src/scan.ts b/src/scan.ts index 97a74ef..22dce5e 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1271,7 +1271,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const codexHoldMs = resolveMs( process.env.CONSENSUS_CODEX_ACTIVE_HOLD_MS, - 0 + 2000 ); const codexEventIdleMs = resolveMs( process.env.CONSENSUS_CODEX_EVENT_IDLE_MS, diff --git a/tests/unit/codexEventState.test.ts b/tests/unit/codexEventState.test.ts index a9a3d42..e26533d 100644 --- a/tests/unit/codexEventState.test.ts +++ b/tests/unit/codexEventState.test.ts @@ -30,6 +30,32 @@ test("codex event state: hold after recent activity", () => { assert.equal(result.reason, "event_hold"); }); +test("codex event state: hold expires after in-flight ends", () => { + const now = Date.now(); + const holdMs = 2000; + const lastActivityAt = now; + const active = deriveCodexEventState({ + inFlight: false, + lastActivityAt, + hasError: false, + now: now + holdMs - 1, + holdMs, + idleMs: 20000, + }); + const idle = deriveCodexEventState({ + inFlight: false, + lastActivityAt, + hasError: false, + now: now + holdMs + 1, + holdMs, + idleMs: 20000, + }); + assert.equal(active.state, "active"); + assert.equal(active.reason, "event_hold"); + assert.equal(idle.state, "idle"); + assert.equal(idle.reason, "event_idle"); +}); + test("codex event state: idle after hold window", () => { const now = Date.now(); const result = deriveCodexEventState({ From 05107e6aa1937b1562a832c8dbbfd5895a8a43b8 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:05:42 -0500 Subject: [PATCH 12/15] fix(codex): detect codex-cli processes --- src/codexCmd.ts | 30 ++++++++++++++++++++++++++++++ src/scan.ts | 28 +++------------------------- tests/unit/codexCmd.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 src/codexCmd.ts create mode 100644 tests/unit/codexCmd.test.ts diff --git a/src/codexCmd.ts b/src/codexCmd.ts new file mode 100644 index 0000000..d90f21a --- /dev/null +++ b/src/codexCmd.ts @@ -0,0 +1,30 @@ +import path from "node:path"; + +const CODEX_BINARIES = new Set([ + "codex", + "codex.exe", + "codex-cli", + "codex-cli.exe", +]); + +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, ""); +} + +export function isCodexBinary(value: string | undefined): boolean { + if (!value) return false; + const cleaned = stripQuotes(value); + const base = path.basename(cleaned).toLowerCase(); + return CODEX_BINARIES.has(base); +} + +export function hasCodexVendorPath(cmdLine: string): boolean { + return /[\\/]+codex[\\/]+vendor[\\/]+/i.test(cmdLine); +} + +export function hasCodexToken(cmdLine: string): boolean { + return ( + /(?:^|\s|[\\/])codex(?:-cli)?(\.exe)?(?:\s|$)/i.test(cmdLine) || + /[\\/]+codex(?:-cli)?(\.exe)?/i.test(cmdLine) + ); +} diff --git a/src/scan.ts b/src/scan.ts index 22dce5e..15a0c45 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -48,6 +48,7 @@ import { deriveOpenCodeState } from "./opencodeState.js"; import { shouldUseOpenCodeApiActivityAt } from "./opencodeApiActivity.js"; import { shouldIncludeOpenCodeProcess } from "./opencodeFilter.js"; import { summarizeClaudeCommand } from "./claudeCli.js"; +import { hasCodexToken, hasCodexVendorPath, isCodexBinary } from "./codexCmd.js"; import { deriveStateWithHold } from "./activity.js"; import { getClaudeActivityByCwd, getClaudeActivityBySession } from "./services/claudeEvents.js"; import { parseOpenCodeCommand, summarizeOpenCodeCommand } from "./opencodeCmd.js"; @@ -241,24 +242,6 @@ function stripQuotes(value: string): string { return value.replace(/^["']|["']$/g, ""); } -function isCodexBinary(value: string | undefined): boolean { - if (!value) return false; - const cleaned = stripQuotes(value); - const base = path.basename(cleaned).toLowerCase(); - return base === "codex" || base === "codex.exe"; -} - -function hasCodexVendorPath(cmdLine: string): boolean { - return /[\\/]+codex[\\/]+vendor[\\/]+/i.test(cmdLine); -} - -function hasCodexToken(cmdLine: string): boolean { - return ( - /(?:^|\s|[\\/])codex(\.exe)?(?:\s|$)/i.test(cmdLine) || - /[\\/]+codex(\.exe)?/i.test(cmdLine) - ); -} - function isCodexProcess(cmd: string | undefined, name: string | undefined, matchRe?: RegExp): boolean { if (!cmd && !name) return false; if (isOpenCodeProcess(cmd, name)) return false; @@ -310,12 +293,7 @@ function inferKind(cmd: string): AgentKind { const claudeInfo = summarizeClaudeCommand(cmd); if (claudeInfo) return claudeInfo.kind; if (cmd.includes(" exec")) return "exec"; - if ( - cmd.includes(" codex") || - cmd.startsWith("codex") || - cmd.startsWith("codex.exe") || - /[\\/]+codex(\.exe)?/i.test(cmd) - ) { + if (hasCodexToken(cmd)) { return "tui"; } return "unknown"; @@ -373,7 +351,7 @@ function parseDoingFromCmd(cmd: string): string | undefined { return "monitor"; } if (cmd.includes("app-server")) return "app-server"; - if (cmd.startsWith("codex") || cmd.startsWith("codex.exe")) return "codex"; + if (hasCodexToken(cmd)) return "codex"; return undefined; } diff --git a/tests/unit/codexCmd.test.ts b/tests/unit/codexCmd.test.ts new file mode 100644 index 0000000..ec0d1c8 --- /dev/null +++ b/tests/unit/codexCmd.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { hasCodexToken, hasCodexVendorPath, isCodexBinary } from "../../src/codexCmd.ts"; + +test("codex command matching recognizes codex-cli", () => { + assert.equal(isCodexBinary("codex"), true); + assert.equal(isCodexBinary("codex.exe"), true); + assert.equal(isCodexBinary("codex-cli"), true); + assert.equal(isCodexBinary("codex-cli.exe"), true); + assert.equal(isCodexBinary("/usr/local/bin/codex-cli"), true); + assert.equal(isCodexBinary('"/usr/local/bin/codex-cli"'), true); + assert.equal(isCodexBinary("codexer"), false); +}); + +test("codex token matching recognizes cli invocation", () => { + assert.equal(hasCodexToken("codex-cli exec --foo"), true); + assert.equal(hasCodexToken("node /opt/bin/codex-cli exec"), true); + assert.equal(hasCodexToken("/opt/bin/codex-cli.exe"), true); + assert.equal(hasCodexToken("/opt/bin/codex.exe"), true); + assert.equal(hasCodexToken("mycodex"), false); +}); + +test("codex vendor path matching", () => { + assert.equal(hasCodexVendorPath("/Users/me/codex/vendor/bin"), true); + assert.equal(hasCodexVendorPath("/Users/me/.codex/bin"), false); +}); From 115124a798517a8bf8704f7073efc5539795b7a5 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:02:45 -0500 Subject: [PATCH 13/15] fix(activity): prevent false idle and dev proxy misroutes --- dev/dev.mjs | 20 ++++++++++---- public/vite.config.ts | 13 +++++++-- src/scan.ts | 29 +++++++++++++++----- tests/unit/codexScan.test.ts | 53 ++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 tests/unit/codexScan.test.ts diff --git a/dev/dev.mjs b/dev/dev.mjs index 578ce1a..32aa873 100644 --- a/dev/dev.mjs +++ b/dev/dev.mjs @@ -121,7 +121,7 @@ const startServer = async () => { }); }); - if (result.status === "running") return; + if (result.status === "running") return port; if (result.status === "conflict") { process.stderr.write(`[dev] port ${port} in use, trying next\n`); continue; @@ -134,9 +134,10 @@ const startServer = async () => { process.stderr.write("[dev] failed to find an open port for server\n"); shutdown(1); + return null; }; -const startVite = async () => { +const startVite = async (serverPort) => { const maxAttempts = 6; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { @@ -148,7 +149,11 @@ const startVite = async () => { [vitePath, "--port", String(port), "--host", "127.0.0.1", "--strictPort"], { cwd: path.join(root, "public"), - env: { ...process.env, CONSENSUS_UI_PORT: String(port) }, + env: { + ...process.env, + CONSENSUS_UI_PORT: String(port), + CONSENSUS_PORT: String(serverPort), + }, stdio: ["ignore", "pipe", "pipe"], } ); @@ -197,8 +202,13 @@ const startVite = async () => { shutdown(1); }; -void startServer(); -void startVite(); +const main = async () => { + const serverPort = await startServer(); + if (!serverPort) return; + await startVite(serverPort); +}; + +void main(); const shutdown = (code = 0) => { for (const child of children) { diff --git a/public/vite.config.ts b/public/vite.config.ts index df5de40..f27bf58 100644 --- a/public/vite.config.ts +++ b/public/vite.config.ts @@ -2,6 +2,15 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +const parsePort = (value?: string) => { + const num = Number(value); + if (!Number.isFinite(num)) return null; + if (num <= 0 || num > 65535) return null; + return Math.floor(num); +}; + +const consensusPort = parsePort(process.env.CONSENSUS_PORT) ?? 8787; + export default defineConfig({ plugins: [react()], root: path.resolve(__dirname), @@ -19,10 +28,10 @@ export default defineConfig({ port: 5173, proxy: { '/ws': { - target: 'ws://localhost:8787', + target: `ws://localhost:${consensusPort}`, ws: true, }, - '/api': 'http://localhost:8787', + '/api': `http://localhost:${consensusPort}`, }, }, }); diff --git a/src/scan.ts b/src/scan.ts index 15a0c45..4156ca4 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -191,6 +191,22 @@ export function markSessionDirty(sessionPath: string): void { dirtySessionPaths.add(sessionPath); } +export interface CodexNotifyEndGateInput { + tailAllowsNotifyEnd: boolean; + notifyEndIsFresh: boolean; + tailEndAt?: number; + tailInFlight?: boolean; +} + +export function shouldApplyCodexNotifyEnd(input: CodexNotifyEndGateInput): boolean { + return ( + input.tailAllowsNotifyEnd && + input.notifyEndIsFresh && + !input.tailEndAt && + !input.tailInFlight + ); +} + function consumeDirtySessions(): Set { const dirty = new Set(dirtySessionPaths); dirtySessionPaths.clear(); @@ -1521,12 +1537,6 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise= tailActivityAtCandidate); - const notifyShouldEnd = tailAllowsNotifyEnd && notifyEndIsFresh && !tailEndAt; + const notifyShouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd, + notifyEndIsFresh, + tailEndAt, + tailInFlight, + }); if (notifyShouldEnd) { inFlight = false; } diff --git a/tests/unit/codexScan.test.ts b/tests/unit/codexScan.test.ts new file mode 100644 index 0000000..2b4178c --- /dev/null +++ b/tests/unit/codexScan.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { shouldApplyCodexNotifyEnd } from "../../src/scan.ts"; + +test("notify end does not override tail in-flight", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: true, + tailEndAt: undefined, + tailInFlight: true, + }); + assert.equal(shouldEnd, false); +}); + +test("notify end applies when tail is idle", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: true, + tailEndAt: undefined, + tailInFlight: false, + }); + assert.equal(shouldEnd, true); +}); + +test("notify end does not apply when tail has explicit end", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: true, + tailEndAt: Date.now(), + tailInFlight: false, + }); + assert.equal(shouldEnd, false); +}); + +test("notify end does not apply when tail disallows notify end", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: false, + notifyEndIsFresh: true, + tailEndAt: undefined, + tailInFlight: false, + }); + assert.equal(shouldEnd, false); +}); + +test("notify end does not apply when notify end is not fresh", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: false, + tailEndAt: undefined, + tailInFlight: false, + }); + assert.equal(shouldEnd, false); +}); From 0e3b5bb6c3a61048699189d8cdb8e33673704b63 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:42:13 -0500 Subject: [PATCH 14/15] test(codex): add live hooks and readiness diagnostics --- src/codexLogs.ts | 32 + src/scan.ts | 8 + tests/integration/codexLiveDebugger.test.ts | 246 ++++++++ .../integration/codexLiveToolTimeout.test.ts | 305 ++++++++++ tests/integration/codexLogs.test.ts | 121 ++++ tests/integration/liveCdpClient.ts | 131 ++++ tests/integration/liveCodexHarness.ts | 564 ++++++++++++++++++ 7 files changed, 1407 insertions(+) create mode 100644 tests/integration/codexLiveDebugger.test.ts create mode 100644 tests/integration/codexLiveToolTimeout.test.ts create mode 100644 tests/integration/liveCdpClient.ts create mode 100644 tests/integration/liveCodexHarness.ts diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 99e353d..17c1ac7 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -735,6 +735,10 @@ export async function updateTail( state.lastToolSignalAt = Math.max(state.lastToolSignalAt || 0, ts); }; const finalizeEnd = (ts: number, { clearReview = false }: { clearReview?: boolean } = {}) => { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_FINALIZE_END"; + debugger; + } if (clearReview) state.reviewMode = false; state.turnOpen = false; state.inFlight = false; @@ -746,9 +750,17 @@ export async function updateTail( }; const deferEnd = (ts: number) => { state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_PENDING_END"; + debugger; + } }; const expireInFlight = () => { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_EXPIRE_CHECK"; + debugger; + } if (!state.inFlight) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; @@ -781,6 +793,10 @@ export async function updateTail( typeof lastSignal === "number" && nowMs - lastSignal >= inflightTimeoutMs ) { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_EXPIRE_TIMEOUT"; + debugger; + } state.inFlight = false; state.inFlightStart = false; state.turnOpen = false; @@ -1010,6 +1026,10 @@ export async function updateTail( state.inFlightStart = true; markInFlightSignal(); } + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_WORK_START"; + debugger; + } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isResponseDelta) { @@ -1089,6 +1109,10 @@ export async function updateTail( if (isToolCall) { if (!state.openCallIds) state.openCallIds = new Set(); if (callId) state.openCallIds.add(callId); + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_TOOL_START"; + debugger; + } recordToolSignal(ts); if (canSignal) { clearEndMarkers(); @@ -1111,6 +1135,10 @@ export async function updateTail( if (state.openCallIds && callId) { state.openCallIds.delete(callId); } + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_TOOL_END"; + debugger; + } recordToolSignal(ts); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); if ((state.openCallIds?.size ?? 0) === 0 && state.pendingEndAt && !state.reviewMode) { @@ -1218,6 +1246,10 @@ export async function updateTail( if (kind === "tool") state.lastTool = entry; if (kind === "prompt") state.lastPrompt = entry; if (kind && workKinds.has(kind) && !isItemStarted) { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_WORK_END"; + debugger; + } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (kind === "message") { diff --git a/src/scan.ts b/src/scan.ts index 4156ca4..90fc30a 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1538,6 +1538,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise>; +type ScriptIndex = Map; + +function markerForLocation( + markers: MarkerIndex, + scriptId: string, + lineNumber: number +): string | undefined { + const scriptMarkers = markers.get(scriptId); + return scriptMarkers?.get(lineNumber); +} + +async function markerForFrame( + client: CdpClient, + markers: MarkerIndex, + scriptId: string, + lineNumber: number +): Promise { + let scriptMarkers = markers.get(scriptId); + if (!scriptMarkers) { + const source = await client.getScriptSource(scriptId); + scriptMarkers = buildMarkerIndex(source); + if (scriptMarkers.size > 0) { + markers.set(scriptId, scriptMarkers); + } + } + return scriptMarkers?.get(lineNumber); +} + +async function waitForScripts( + scripts: ScriptIndex, + timeoutMs: number +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (scripts.size > 0) return; + await delay(200); + } + throw new Error("Timed out waiting for scripts to parse"); +} + +async function setMarkerBreakpoints( + client: CdpClient, + scripts: ScriptIndex, + markerIndex: MarkerIndex, + markerNames: Set +): Promise { + for (const [scriptId, url] of scripts.entries()) { + if (!url.includes("codexLogs.ts") && !url.includes("scan.ts")) continue; + const source = await client.getScriptSource(scriptId); + const markers = buildMarkerIndex(source); + if (markers.size === 0) continue; + markerIndex.set(scriptId, markers); + for (const [lineNumber, marker] of markers.entries()) { + if (!markerNames.has(marker)) continue; + await client.setBreakpoint(scriptId, lineNumber, 0); + } + } +} + +liveTest( + "debugger captures tool start/end hooks", + { timeout: 240_000 }, + async (t) => { + if (!(await isTmuxAvailable())) { + t.skip("tmux not available"); + return; + } + + const codexBin = process.env.CODEX_BIN; + if (!(await isCodexAvailable(codexBin))) { + t.skip("codex binary not available"); + return; + } + + const testTag = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const project = await createTempProject(); + const codexHome = await createCodexHome(); + const port = await getFreePort(); + const server = await startConsensusServer({ + port, + codexHome: codexHome.home, + inspector: true, + debugActivity: true, + testHooks: true, + }); + assert.ok(server.inspectorPort, "Inspector port missing"); + const tmux = await startTmuxSession(); + const debugEnv = process.env.CODEX_DEBUG_ENV ?? "RUST_LOG=debug"; + + const client = await CdpClient.connect(server.inspectorPort!); + const markerIndex: MarkerIndex = new Map(); + const scripts: ScriptIndex = new Map(); + + client.on("Debugger.scriptParsed", (event: { scriptId: string; url?: string }) => { + if (!event.url) return; + scripts.set(event.scriptId, event.url); + if (!event.url.includes("codexLogs.ts") && !event.url.includes("scan.ts")) return; + void (async () => { + const source = await client.getScriptSource(event.scriptId); + const markers = buildMarkerIndex(source); + if (markers.size > 0) { + markerIndex.set(event.scriptId, markers); + } + })(); + }); + + await client.enable(); + + try { + const startAt = Date.now(); + await startCodexInteractiveInTmux({ + session: tmux, + projectDir: project.root, + codexHome: codexHome.home, + codexRoot: codexHome.root, + codexBin, + debugEnv, + }); + + const ready = await waitForCodexReady(tmux, 20_000); + if (ready.authRequired) { + t.skip("codex auth required in this environment"); + return; + } + if (ready.errorHint) { + t.skip(`codex failed to start: ${ready.errorHint}\n${ready.output}`); + return; + } + await tmux.sendText(`Run bash: sleep 5. Tag: ${testTag}`); + + const paneOutput = await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + if (/unauthorized|login|api key|missing bearer/i.test(paneOutput)) { + t.skip("codex auth required in this environment"); + return; + } + + try { + const panePid = await tmux.panePid(); + await waitForSessionFile(codexHome.home, 30_000, startAt, tmux); + await waitForCodexPid({ + startAfterMs: startAt, + timeoutMs: 10_000, + codexBin, + panePid, + session: tmux, + }); + } catch (err) { + t.skip(`codex did not start session or pid: ${String(err)}`); + return; + } + await waitForScripts(scripts, 10_000); + await setMarkerBreakpoints( + client, + scripts, + markerIndex, + new Set([ + "TEST_HOOK_TOOL_START", + "TEST_HOOK_TOOL_END", + "TEST_HOOK_WORK_START", + "TEST_HOOK_WORK_END", + ]) + ); + + const expectedStart = new Set(["TEST_HOOK_TOOL_START", "TEST_HOOK_WORK_START"]); + const expectedEnd = new Set(["TEST_HOOK_TOOL_END", "TEST_HOOK_WORK_END"]); + const observations: Array<{ marker: string; openCallCount: number; inFlight: boolean }> = []; + const start = Date.now(); + + let sawStart = false; + let sawEnd = false; + while ((!sawStart || !sawEnd) && Date.now() - start < 120_000) { + const paused = await client.waitForPaused(60_000); + if (!paused) break; + const frame = paused.callFrames[0]; + const marker = await markerForFrame( + client, + markerIndex, + frame.location.scriptId, + frame.location.lineNumber + ); + if (marker && (marker.includes("_START") || marker.includes("_END"))) { + const evalResult = await client.evaluateOnCallFrame( + frame.callFrameId, + "({ inFlight: state.inFlight, openCallCount: state.openCallIds ? state.openCallIds.size : 0 })" + ); + const value = evalResult?.result?.value; + observations.push({ + marker, + openCallCount: value?.openCallCount ?? 0, + inFlight: value?.inFlight ?? false, + }); + if (expectedStart.has(marker)) sawStart = true; + if (expectedEnd.has(marker)) sawEnd = true; + } + await client.resume(); + } + + assert.ok(sawStart, "Did not observe work start marker"); + assert.ok(sawEnd, "Did not observe work end marker"); + + const startObs = observations.find((obs) => obs.marker.includes("_START")); + const endObs = observations.find((obs) => obs.marker.includes("_END")); + assert.ok(startObs, "Missing tool start observation"); + assert.ok(endObs, "Missing tool end observation"); + assert.ok(startObs.inFlight, "inFlight should be true at tool start"); + assert.ok(startObs.openCallCount >= 1, "openCallCount should be >=1 at tool start"); + assert.ok( + endObs.openCallCount <= startObs.openCallCount, + "openCallCount should not increase at tool end" + ); + } finally { + client.close(); + await tmux.kill(); + await server.stop(); + await project.cleanup(); + await codexHome.cleanup(); + } + } +); diff --git a/tests/integration/codexLiveToolTimeout.test.ts b/tests/integration/codexLiveToolTimeout.test.ts new file mode 100644 index 0000000..9e1f403 --- /dev/null +++ b/tests/integration/codexLiveToolTimeout.test.ts @@ -0,0 +1,305 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createCodexHome, + createTempProject, + delay, + fetchSnapshot, + getFreePort, + isCodexAvailable, + isTmuxAvailable, + startCodexInteractiveInTmux, + startConsensusServer, + startTmuxSession, + waitForCodexReady, + waitForCodexPid, + waitForPaneOutput, + waitForSessionFile, +} from "./liveCodexHarness.ts"; +import type { SnapshotPayload, AgentSnapshot } from "../../src/types.ts"; + +const liveEnabled = process.env.RUN_LIVE_CODEX === "1"; +const liveTest = liveEnabled ? test : test.skip; + +async function waitForAgent( + port: number, + sessionPath: string, + cwd: string, + pid: number | undefined, + tag: string, + timeoutMs: number +): Promise { + const sessionBase = sessionPath ? sessionPath.split("/").pop() : undefined; + const cwdBase = cwd ? cwd.split("/").pop() : undefined; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const snapshot = (await fetchSnapshot(port)) as SnapshotPayload; + const agent = snapshot.agents.find((item) => { + if (pid && item.pid === pid) return true; + if (sessionBase && item.sessionPath?.includes(sessionBase)) return true; + if (item.sessionPath && sessionPath && item.sessionPath === sessionPath) return true; + if (item.cwd && cwd && item.cwd === cwd) return true; + if (cwdBase && item.cwd?.includes(cwdBase)) return true; + if (item.cmd?.includes(tag) || item.cmdShort?.includes(tag)) return true; + return false; + }); + if (agent) return agent; + await delay(200); + } + const snapshot = (await fetchSnapshot(port)) as SnapshotPayload; + const summary = snapshot.agents.map((item) => ({ + id: item.id, + cmd: item.cmdShort, + cwd: item.cwd, + sessionPath: item.sessionPath, + state: item.state, + })); + throw new Error(`Timed out waiting for codex agent in snapshot. Agents: ${JSON.stringify(summary)}`); +} + +async function collectStateHistory( + port: number, + agentId: string, + durationMs: number, + intervalMs: number +): Promise< + Array<{ ts: number; state: string; reason?: string; lastTool?: string; lastCommand?: string }> +> { + const history: Array<{ + ts: number; + state: string; + reason?: string; + lastTool?: string; + lastCommand?: string; + }> = []; + const start = Date.now(); + while (Date.now() - start < durationMs) { + const snapshot = (await fetchSnapshot(port)) as SnapshotPayload; + const agent = snapshot.agents.find((item) => item.id === agentId); + if (agent) { + history.push({ + ts: Date.now(), + state: agent.state, + reason: agent.activityReason, + lastTool: agent.summary?.lastTool, + lastCommand: agent.summary?.lastCommand, + }); + } + await delay(intervalMs); + } + return history; +} + +liveTest( + "live codex keeps active during long tool execution", + { timeout: 240_000 }, + async (t) => { + if (!(await isTmuxAvailable())) { + t.skip("tmux not available"); + return; + } + + const codexBin = process.env.CODEX_BIN; + if (!(await isCodexAvailable(codexBin))) { + t.skip("codex binary not available"); + return; + } + + const testTag = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const project = await createTempProject(); + const codexHome = await createCodexHome(); + const port = await getFreePort(); + const server = await startConsensusServer({ + port, + codexHome: codexHome.home, + debugActivity: true, + }); + const tmux = await startTmuxSession(); + + const debugEnv = process.env.CODEX_DEBUG_ENV ?? "RUST_LOG=debug"; + + try { + const startAt = Date.now(); + await startCodexInteractiveInTmux({ + session: tmux, + projectDir: project.root, + codexHome: codexHome.home, + codexRoot: codexHome.root, + codexBin, + debugEnv, + }); + + const ready = await waitForCodexReady(tmux, 20_000); + if (ready.authRequired) { + t.skip("codex auth required in this environment"); + return; + } + if (ready.errorHint) { + t.skip(`codex failed to start: ${ready.errorHint}\n${ready.output}`); + return; + } + await tmux.sendText(`Run bash: sleep 35. Tag: ${testTag}`); + + const paneOutput = await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + if (/unauthorized|login|api key|missing bearer/i.test(paneOutput)) { + t.skip("codex auth required in this environment"); + return; + } + + let sessionPath = ""; + let pid: number | undefined; + try { + const panePid = await tmux.panePid(); + sessionPath = await waitForSessionFile(codexHome.home, 30_000, startAt, tmux); + pid = await waitForCodexPid({ + startAfterMs: startAt, + timeoutMs: 10_000, + codexBin, + panePid, + session: tmux, + }); + } catch (err) { + t.skip(`codex did not start session or pid: ${String(err)}`); + return; + } + const agent = await waitForAgent(port, sessionPath, project.root, pid, testTag, 30_000); + + const history = await collectStateHistory(port, agent.id, 40_000, 1000); + + const firstActive = history.find((entry) => entry.state === "active"); + assert.ok(firstActive, "agent never became active"); + + const windowStart = firstActive.ts; + const windowEnd = windowStart + 30_000; + const idleDuring = history.filter( + (entry) => + entry.ts >= windowStart && entry.ts <= windowEnd && entry.state === "idle" + ); + assert.equal( + idleDuring.length, + 0, + `idle transitions detected during long tool execution: ${idleDuring.length}` + ); + + const sawTool = history.some((entry) => entry.lastTool?.includes("tool:")); + const sawCommand = history.some((entry) => entry.lastCommand?.includes("cmd:")); + assert.ok( + sawTool || sawCommand, + "no tool or command activity detected in snapshot summaries" + ); + } finally { + await tmux.kill(); + await server.stop(); + await project.cleanup(); + await codexHome.cleanup(); + } + } +); + +liveTest( + "live codex avoids idle gaps during sequential tools", + { timeout: 200_000 }, + async (t) => { + if (!(await isTmuxAvailable())) { + t.skip("tmux not available"); + return; + } + + const codexBin = process.env.CODEX_BIN; + if (!(await isCodexAvailable(codexBin))) { + t.skip("codex binary not available"); + return; + } + + const testTag = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const project = await createTempProject(); + const codexHome = await createCodexHome(); + const port = await getFreePort(); + const server = await startConsensusServer({ + port, + codexHome: codexHome.home, + debugActivity: true, + }); + const tmux = await startTmuxSession(); + const debugEnv = process.env.CODEX_DEBUG_ENV ?? "RUST_LOG=debug"; + + try { + const startAt = Date.now(); + await startCodexInteractiveInTmux({ + session: tmux, + projectDir: project.root, + codexHome: codexHome.home, + codexRoot: codexHome.root, + codexBin, + debugEnv, + }); + + const ready = await waitForCodexReady(tmux, 20_000); + if (ready.authRequired) { + t.skip("codex auth required in this environment"); + return; + } + if (ready.errorHint) { + t.skip(`codex failed to start: ${ready.errorHint}\n${ready.output}`); + return; + } + await tmux.sendText( + `Run bash: sleep 2; then run bash: sleep 2; then run bash: sleep 2. Tag: ${testTag}` + ); + + const paneOutput = await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + if (/unauthorized|login|api key|missing bearer/i.test(paneOutput)) { + t.skip("codex auth required in this environment"); + return; + } + + let sessionPath = ""; + let pid: number | undefined; + try { + const panePid = await tmux.panePid(); + sessionPath = await waitForSessionFile(codexHome.home, 30_000, startAt, tmux); + pid = await waitForCodexPid({ + startAfterMs: startAt, + timeoutMs: 10_000, + codexBin, + panePid, + session: tmux, + }); + } catch (err) { + t.skip(`codex did not start session or pid: ${String(err)}`); + return; + } + const agent = await waitForAgent(port, sessionPath, project.root, pid, testTag, 30_000); + + const history = await collectStateHistory(port, agent.id, 15_000, 500); + + const firstActive = history.find((entry) => entry.state === "active"); + assert.ok(firstActive, "agent never became active"); + + const windowStart = firstActive.ts; + const windowEnd = windowStart + 6_000; + const idleDuring = history.filter( + (entry) => + entry.ts >= windowStart && entry.ts <= windowEnd && entry.state === "idle" + ); + assert.equal( + idleDuring.length, + 0, + `idle transitions detected during sequential tools: ${idleDuring.length}` + ); + } finally { + await tmux.kill(); + await server.stop(); + await project.cleanup(); + await codexHome.cleanup(); + } + } +); diff --git a/tests/integration/codexLogs.test.ts b/tests/integration/codexLogs.test.ts index b4d1948..524e66d 100644 --- a/tests/integration/codexLogs.test.ts +++ b/tests/integration/codexLogs.test.ts @@ -962,6 +962,127 @@ test("open tool call keeps in-flight without new events", async () => { await fs.rm(dir, { recursive: true, force: true }); }); +test("tool call without call_id times out without open calls", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const start = { + type: "response_item", + ts: 1, + payload: { + type: "function_call", + arguments: "{}", + }, + }; + await fs.writeFile(file, `${JSON.stringify(start)}\n`); + + Date.now = () => 1_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.equal(stateStart.openCallIds?.size ?? 0, 0); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 5_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("pending end waits for tool output to finish", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { + type: "response_item", + ts: 1, + payload: { type: "function_call", name: "tool", call_id: "call_1" }, + }, + { type: "response.completed", ts: 2 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.ok(stateStart.pendingEndAt); + assert.equal(stateStart.inFlight, true); + + const output = { + type: "response_item", + ts: 3, + payload: { type: "function_call_output", call_id: "call_1" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + Date.now = () => 3_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + assert.equal(stateLater.pendingEndAt, undefined); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("tool output without call_id does not retain open call", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { + type: "response_item", + ts: 1, + payload: { type: "function_call", name: "tool", call_id: "call_1" }, + }, + { type: "response.completed", ts: 2 }, + { + type: "response_item", + ts: 3, + payload: { type: "function_call_output" }, + }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 3_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.equal(stateStart.openCallIds?.size ?? 0, 1); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 10_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + assert.equal(stateLater.openCallIds?.size ?? 0, 0); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + test("keeps in-flight codex state within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); diff --git a/tests/integration/liveCdpClient.ts b/tests/integration/liveCdpClient.ts new file mode 100644 index 0000000..c3865a6 --- /dev/null +++ b/tests/integration/liveCdpClient.ts @@ -0,0 +1,131 @@ +import { EventEmitter } from "node:events"; +import WebSocket from "ws"; + +type Pending = { + resolve: (value: any) => void; + reject: (err: Error) => void; +}; + +export type PausedEvent = { + callFrames: Array<{ + callFrameId: string; + location: { scriptId: string; lineNumber: number; columnNumber: number }; + }>; +}; + +export class CdpClient extends EventEmitter { + private socket: WebSocket; + private idCounter = 1; + private pending = new Map(); + + private constructor(socket: WebSocket) { + super(); + this.socket = socket; + socket.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (message.id) { + const entry = this.pending.get(message.id); + if (!entry) return; + this.pending.delete(message.id); + if (message.error) { + entry.reject(new Error(message.error.message || "CDP error")); + } else { + entry.resolve(message.result); + } + return; + } + if (message.method) { + this.emit(message.method, message.params); + } + }); + } + + static async connect(inspectorPort: number): Promise { + const res = await fetch(`http://127.0.0.1:${inspectorPort}/json/list`); + if (!res.ok) { + throw new Error(`Inspector list failed: ${res.status}`); + } + const targets = (await res.json()) as Array<{ webSocketDebuggerUrl?: string }>; + const wsUrl = targets.find((target) => target.webSocketDebuggerUrl)?.webSocketDebuggerUrl; + if (!wsUrl) { + throw new Error("No webSocketDebuggerUrl found for inspector"); + } + const socket = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("error", (err) => reject(err)); + }); + return new CdpClient(socket); + } + + async send(method: string, params?: Record): Promise { + const id = this.idCounter++; + const payload = { id, method, params }; + const result = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + this.socket.send(JSON.stringify(payload)); + return result; + } + + async enable(): Promise { + await this.send("Runtime.enable"); + await this.send("Debugger.enable"); + } + + async waitForPaused(timeoutMs: number): Promise { + return await new Promise((resolve) => { + const timer = setTimeout(() => { + this.off("Debugger.paused", onPaused); + resolve(null); + }, timeoutMs); + const onPaused = (event: PausedEvent) => { + clearTimeout(timer); + this.off("Debugger.paused", onPaused); + resolve(event); + }; + this.on("Debugger.paused", onPaused); + }); + } + + async resume(): Promise { + await this.send("Debugger.resume"); + } + + async evaluateOnCallFrame(callFrameId: string, expression: string): Promise { + return await this.send("Debugger.evaluateOnCallFrame", { + callFrameId, + expression, + returnByValue: true, + }); + } + + async getScriptSource(scriptId: string): Promise { + const result = await this.send("Debugger.getScriptSource", { scriptId }); + return result?.scriptSource ?? ""; + } + + async setBreakpoint(scriptId: string, lineNumber: number, columnNumber = 0): Promise { + await this.send("Debugger.setBreakpoint", { + location: { scriptId, lineNumber, columnNumber }, + }); + } + + close(): void { + this.socket.close(); + } +} + +export function buildMarkerIndex( + source: string +): Map { + const markers = new Map(); + const lines = source.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + const match = lines[i].match(/TEST_HOOK_[A-Z0-9_]+/); + if (match) { + markers.set(i, match[0]); + } + } + return markers; +} diff --git a/tests/integration/liveCodexHarness.ts b/tests/integration/liveCodexHarness.ts new file mode 100644 index 0000000..bf0264d --- /dev/null +++ b/tests/integration/liveCodexHarness.ts @@ -0,0 +1,564 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { spawn, execFile } from "node:child_process"; +import { once } from "node:events"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { hasCodexToken, hasCodexVendorPath, isCodexBinary } from "../../src/codexCmd.ts"; + +const execFileAsync = promisify(execFile); +const currentFile = fileURLToPath(import.meta.url); +const currentDir = path.dirname(currentFile); +const repoRoot = path.resolve(currentDir, "..", ".."); + +export type ConsensusServer = { + port: number; + inspectorPort?: number; + output: string; + stop: () => Promise; +}; + +export type TmuxSession = { + socketPath: string; + sessionId: string; + target: string; + sendText: (text: string) => Promise; + capture: () => Promise; + panePid: () => Promise; + kill: () => Promise; +}; + +const PANE_EXCERPT_CHARS = 1200; +const AUTH_PATTERN = /unauthorized|login|api key|missing bearer|not logged in|sign in/i; +const ERROR_PATTERN = + /command not found|not recognized|no such file|cannot execute|permission denied|panic|fatal|traceback/i; +const DEFAULT_READY_PATTERNS = [ + /(?:^|\n)\s*codex(?:-cli)?[^\n]*[>]\s*$/im, + /(?:^|\n)\s*[>]\s*$/m, + /\bCodex\b.*\bready\b/i, + /\bCodex\b.*\bhelp\b/i, +]; + +export async function isTmuxAvailable(): Promise { + try { + await execFileAsync("tmux", ["-V"]); + return true; + } catch { + return false; + } +} + +export async function isCodexAvailable(codexBin = "codex"): Promise { + try { + await execFileAsync(codexBin, ["--version"]); + return true; + } catch { + return false; + } +} + +export async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to acquire free port")); + return; + } + const { port } = address; + server.close(() => resolve(port)); + }); + }); +} + +export async function createTempProject(): Promise<{ root: string; cleanup: () => Promise }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-live-project-")); + const srcDir = path.join(root, "src"); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile(path.join(root, "a.config.ts"), "export const A = 1;\n"); + await fs.writeFile(path.join(root, "b.config.ts"), "export const B = 2;\n"); + await fs.writeFile(path.join(root, "c.config.ts"), "export const C = 3;\n"); + await fs.writeFile(path.join(srcDir, "alpha.ts"), "export const alpha = 'alpha';\n"); + await fs.writeFile(path.join(srcDir, "beta.ts"), "export const beta = 'beta';\n"); + await fs.writeFile(path.join(srcDir, "gamma.ts"), "export const gamma = 'gamma';\n"); + await fs.writeFile(path.join(root, "README.md"), "Deterministic test project.\n"); + return { + root, + cleanup: async () => fs.rm(root, { recursive: true, force: true }), + }; +} + +export async function createCodexHome(options?: { + useRealHome?: boolean; +}): Promise<{ + home: string; + root: string; + cleanup: () => Promise; +}> { + const useRealHome = + options?.useRealHome ?? + (process.env.RUN_LIVE_CODEX === "1" && process.env.CODEX_TEST_USE_REAL_HOME !== "0"); + if (useRealHome) { + const root = os.homedir(); + const home = process.env.CODEX_HOME || path.join(root, ".codex"); + await fs.mkdir(path.join(home, "sessions"), { recursive: true }); + await fs.mkdir(path.join(home, "consensus"), { recursive: true }); + return { + home, + root, + cleanup: async () => {}, + }; + } + const root = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-codex-home-")); + const home = path.join(root, ".codex"); + await fs.mkdir(path.join(home, "sessions"), { recursive: true }); + await fs.mkdir(path.join(home, "consensus"), { recursive: true }); + return { + home, + root, + cleanup: async () => fs.rm(root, { recursive: true, force: true }), + }; +} + +function shellQuote(value: string): string { + return JSON.stringify(value); +} + +export async function startConsensusServer(options: { + port: number; + codexHome: string; + inspector?: boolean; + debugActivity?: boolean; + testHooks?: boolean; + processMatch?: string; + timeoutMs?: number; +}): Promise { + const args = [] as string[]; + if (options.inspector) { + args.push("--inspect=0"); + } + args.push("--import", "tsx", path.join(repoRoot, "src", "server.ts")); + + const child = spawn(process.execPath, args, { + cwd: repoRoot, + env: { + ...process.env, + CONSENSUS_PORT: String(options.port), + CONSENSUS_HOST: "127.0.0.1", + CONSENSUS_CODEX_HOME: options.codexHome, + CONSENSUS_DEBUG_ACTIVITY: options.debugActivity ? "1" : "0", + CODEX_TEST_HOOKS: options.testHooks ? "1" : "0", + CONSENSUS_PROCESS_MATCH: options.processMatch, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + const onOutput = (chunk: Buffer) => { + output += chunk.toString(); + }; + child.stdout?.on("data", onOutput); + child.stderr?.on("data", onOutput); + + let inspectorPort: number | undefined; + if (options.inspector && child.stderr) { + inspectorPort = await waitForInspectorPort(child.stderr, 5000); + } + + try { + await waitForHttpOk( + `http://127.0.0.1:${options.port}/health`, + options.timeoutMs ?? 15000, + child + ); + } catch (err) { + const trimmed = output.trim(); + const suffix = trimmed ? `\nServer output:\n${trimmed}` : ""; + throw new Error(`${(err as Error).message}${suffix}`); + } + + const stop = async () => { + if (child.killed) return; + child.kill("SIGTERM"); + const exited = await waitForExit(child, 5000); + if (!exited) { + child.kill("SIGKILL"); + await waitForExit(child, 2000); + } + }; + + return { port: options.port, inspectorPort, output, stop }; +} + +async function waitForExit(child: ReturnType, timeoutMs: number): Promise { + try { + await Promise.race([once(child, "exit"), delay(timeoutMs)]); + return true; + } catch { + return false; + } +} + +async function waitForInspectorPort( + stream: NodeJS.ReadableStream, + timeoutMs: number +): Promise { + const start = Date.now(); + let buffer = ""; + return await new Promise((resolve, reject) => { + const onData = (chunk: Buffer) => { + buffer += chunk.toString(); + const match = buffer.match(/Debugger listening on ws:\/\/[^\s:]+:(\d+)\//); + if (match) { + cleanup(); + resolve(Number(match[1])); + } + }; + const onTimeout = () => { + cleanup(); + reject(new Error("Timed out waiting for inspector port")); + }; + const cleanup = () => { + stream.off("data", onData); + clearTimeout(timer); + }; + stream.on("data", onData); + const remaining = Math.max(0, timeoutMs - (Date.now() - start)); + const timer = setTimeout(onTimeout, remaining); + }); +} + +async function waitForHttpOk( + url: string, + timeoutMs: number, + child: ReturnType +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (child.exitCode !== null) { + throw new Error(`Server process exited with code ${child.exitCode}`); + } + try { + const res = await fetch(url); + if (res.ok) return; + } catch { + // ignore + } + await delay(200); + } + throw new Error(`Timed out waiting for ${url}`); +} + +export async function startTmuxSession(): Promise { + const sessionId = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const socketPath = path.join(os.tmpdir(), `${sessionId}.sock`); + await execFileAsync("tmux", [ + "-f", + "/dev/null", + "-S", + socketPath, + "new-session", + "-d", + "-s", + sessionId, + "-n", + "main", + "-x", + "120", + "-y", + "40", + ]); + const target = `${sessionId}:main.0`; + const sendText = async (text: string) => { + await execFileAsync("tmux", ["-S", socketPath, "send-keys", "-t", target, "-l", text]); + await execFileAsync("tmux", ["-S", socketPath, "send-keys", "-t", target, "C-m"]); + }; + const capture = async () => { + const { stdout } = await execFileAsync("tmux", ["-S", socketPath, "capture-pane", "-p", "-t", target]); + return stdout.toString(); + }; + const panePid = async () => { + const { stdout } = await execFileAsync("tmux", [ + "-S", + socketPath, + "display-message", + "-p", + "-t", + target, + "#{pane_pid}", + ]); + const pid = Number(stdout.toString().trim()); + if (!Number.isFinite(pid)) { + throw new Error("Failed to resolve tmux pane pid"); + } + return pid; + }; + const kill = async () => { + try { + await execFileAsync("tmux", ["-S", socketPath, "kill-server"]); + } catch { + // ignore + } + }; + return { socketPath, sessionId, target, sendText, capture, panePid, kill }; +} + +export async function waitForPaneOutput( + session: TmuxSession, + predicate: (output: string) => boolean, + timeoutMs: number +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const output = await session.capture(); + if (predicate(output)) return output; + await delay(200); + } + const output = await session.capture(); + const excerpt = formatPaneExcerpt(output); + throw new Error(`Timed out waiting for tmux output.\nLast pane output:\n${excerpt}`); +} + +function parsePsStart(value: string): number | undefined { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, ""); +} + +function formatPaneExcerpt(output: string, maxChars = PANE_EXCERPT_CHARS): string { + const cleaned = stripAnsi(output).replace(/\r/g, "").trim(); + if (!cleaned) return ""; + if (cleaned.length <= maxChars) return cleaned; + return cleaned.slice(-maxChars); +} + +function firstMatchingLine(output: string, pattern: RegExp): string | undefined { + const cleaned = stripAnsi(output).replace(/\r/g, ""); + for (const line of cleaned.split("\n")) { + if (pattern.test(line)) return line.trim(); + } + return undefined; +} + +export async function waitForCodexReady( + session: TmuxSession, + timeoutMs: number +): Promise<{ output: string; authRequired: boolean; errorHint?: string }> { + const start = Date.now(); + const readyPatterns = [...DEFAULT_READY_PATTERNS]; + const custom = process.env.CODEX_READY_REGEX; + if (custom) { + try { + readyPatterns.unshift(new RegExp(custom, "i")); + } catch { + // ignore invalid regex + } + } + while (Date.now() - start < timeoutMs) { + const output = await session.capture(); + const cleaned = stripAnsi(output).replace(/\r/g, ""); + if (AUTH_PATTERN.test(cleaned)) { + return { output: formatPaneExcerpt(output), authRequired: true }; + } + const errorLine = firstMatchingLine(cleaned, ERROR_PATTERN); + if (errorLine) { + return { + output: formatPaneExcerpt(output), + authRequired: false, + errorHint: errorLine, + }; + } + if (readyPatterns.some((pattern) => pattern.test(cleaned))) { + return { output: formatPaneExcerpt(output), authRequired: false }; + } + await delay(200); + } + const output = await session.capture(); + throw new Error( + `Timed out waiting for codex ready.\nLast pane output:\n${formatPaneExcerpt(output)}` + ); +} + +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, ""); +} + +export async function waitForCodexPid(options: { + startAfterMs: number; + timeoutMs: number; + codexBin?: string; + panePid?: number; + session?: TmuxSession; +}): Promise { + const codexBin = options.codexBin; + const codexBase = codexBin ? path.basename(stripQuotes(codexBin)) : undefined; + const codexBaseLower = codexBase?.toLowerCase(); + const start = Date.now(); + while (Date.now() - start < options.timeoutMs) { + const { stdout } = await execFileAsync("ps", [ + "-ax", + "-o", + "pid=,ppid=,lstart=,command=", + ]); + const lines = stdout.toString().split("\n"); + const procInfo = new Map< + number, + { pid: number; ppid?: number; startedAt?: number; cmd: string } + >(); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parts = trimmed.split(/\s+/); + if (parts.length < 8) continue; + const pid = Number(parts[0]); + const ppid = Number(parts[1]); + const startStr = parts.slice(2, 7).join(" "); + const startedAt = parsePsStart(startStr); + const cmd = parts.slice(7).join(" "); + procInfo.set(pid, { pid, ppid, startedAt, cmd }); + } + + const isDescendant = (pid: number): boolean => { + if (!options.panePid) return false; + let current = procInfo.get(pid)?.ppid; + const seen = new Set(); + while (current && !seen.has(current)) { + if (current === options.panePid) return true; + seen.add(current); + current = procInfo.get(current)?.ppid; + } + return false; + }; + + const candidates = Array.from(procInfo.values()).filter( + (proc) => proc.startedAt && proc.startedAt >= options.startAfterMs - 2000 + ); + const codexCandidates = candidates.filter((proc) => { + const cmd = proc.cmd; + if (hasCodexToken(cmd) || hasCodexVendorPath(cmd)) return true; + if (codexBaseLower && cmd.toLowerCase().includes(codexBaseLower)) return true; + if (codexBin && isCodexBinary(codexBin) && cmd.toLowerCase().includes("codex")) return true; + return false; + }); + + const prioritize = (list: typeof candidates) => { + let best: { pid: number; startedAt: number } | null = null; + for (const proc of list) { + if (!proc.startedAt) continue; + if (!best || proc.startedAt > best.startedAt) { + best = { pid: proc.pid, startedAt: proc.startedAt }; + } + } + return best; + }; + + const codexDescendants = options.panePid + ? codexCandidates.filter((proc) => isDescendant(proc.pid)) + : codexCandidates; + const bestCodex = prioritize(codexDescendants.length > 0 ? codexDescendants : codexCandidates); + if (bestCodex) return bestCodex.pid; + + if (options.panePid) { + const descendantCandidates = candidates.filter( + (proc) => proc.pid !== options.panePid && isDescendant(proc.pid) + ); + const bestDescendant = prioritize(descendantCandidates); + if (bestDescendant) return bestDescendant.pid; + } + await delay(200); + } + const paneOutput = options.session ? await options.session.capture() : ""; + const suffix = paneOutput + ? `\nLast pane output:\n${formatPaneExcerpt(paneOutput)}` + : ""; + throw new Error(`Timed out waiting for codex pid.${suffix}`); +} + +export async function startCodexInteractiveInTmux(options: { + session: TmuxSession; + projectDir: string; + codexHome: string; + codexRoot: string; + codexBin?: string; + debugEnv?: string; +}): Promise { + const codexBin = options.codexBin || "codex"; + const debugEnv = options.debugEnv ?? "RUST_LOG=debug"; + const cmd = + `env HOME=${shellQuote(options.codexRoot)} ` + + `CODEX_HOME=${shellQuote(options.codexHome)} ` + + `${debugEnv} ${shellQuote(codexBin)} ` + + "--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check " + + `-C ${shellQuote(options.projectDir)}`; + await options.session.sendText(cmd); +} + +async function collectSessionFiles(dir: string): Promise> { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: Array<{ path: string; mtimeMs: number }> = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectSessionFiles(full))); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const stat = await fs.stat(full); + files.push({ path: full, mtimeMs: stat.mtimeMs }); + } + return files; +} + +export async function waitForSessionFile( + codexHome: string, + timeoutMs: number, + afterMs?: number, + session?: TmuxSession +): Promise { + const sessionsDir = path.join(codexHome, "sessions"); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const fullPaths = await collectSessionFiles(sessionsDir); + if (fullPaths.length > 0) { + const filtered = + typeof afterMs === "number" + ? fullPaths.filter((entry) => entry.mtimeMs >= afterMs) + : fullPaths; + if (filtered.length > 0) { + filtered.sort((a, b) => b.mtimeMs - a.mtimeMs); + return filtered[0].path; + } + } + } catch { + // ignore + } + await delay(200); + } + const paneOutput = session ? await session.capture() : ""; + const suffix = paneOutput + ? `\nLast pane output:\n${formatPaneExcerpt(paneOutput)}` + : ""; + throw new Error(`Timed out waiting for codex session file in ${sessionsDir}.${suffix}`); +} + +export async function fetchSnapshot(port: number): Promise { + const res = await fetch(`http://127.0.0.1:${port}/api/snapshot`); + if (!res.ok) { + throw new Error(`Snapshot request failed: ${res.status}`); + } + return res.json(); +} + +export async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} From a968a74f697b7b696a44fb83c6a5f326b2940716 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:42:28 -0500 Subject: [PATCH 15/15] Add Codex environment file --- .codex/environments/environment.toml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .codex/environments/environment.toml diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..7af9c15 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,6 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "consensus" + +[setup] +script = "bun"