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. diff --git a/docs/clean-codebase-report.md b/docs/clean-codebase-report.md new file mode 100644 index 0000000..79de7f6 --- /dev/null +++ b/docs/clean-codebase-report.md @@ -0,0 +1,23 @@ +# Clean Codebase Report + +Date: 2026-02-04 + +## Summary +Removed unused scanner module code and reduced process classification passes in `scan.ts` to lower CPU and memory overhead without changing behavior. + +## Changes +- Removed `src/scanner/` (unused Effect-based scanner module with no imports or exports in runtime paths). +- Refactored process classification in `src/scan.ts` to build codex/opencode/claude sets with fewer passes and fewer intermediate allocations. + +## Complexity And Performance +- Process classification now uses two linear passes plus set merges instead of multiple `Array.filter` and `Array.map` passes. +- PID set construction avoids `Array.from(new Set([...]))` on concatenated arrays, reducing temporary array allocations. +- Build output shrinks by removing unused scanner compilation units. + +## Tradeoffs +- Process classification is slightly more imperative (explicit loops) in exchange for less allocation and fewer passes. +- Removing `src/scanner/` drops a placeholder Effect-based API that was not referenced; if a future feature needs it, it should be restored with a concrete use site and tests. + +## Verification +- `npm install` +- `npm run build` 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, }; } 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); + } } } diff --git a/src/scan.ts b/src/scan.ts index 47096a5..a4c2561 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -769,36 +769,58 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise - isCodexProcess(proc.cmd, proc.name, matchRe) - ); - const codexWrapperPidSet = new Set(codexWrapperProcs.map((proc) => proc.pid)); - const codexVendorCandidates = processes.filter((proc) => - isCodexVendorProcess(proc.cmd, proc.name) - ); - const codexVendorChildren = codexVendorCandidates.filter((proc) => - codexWrapperPidSet.has(proc.ppid ?? -1) - ); - const codexVendorProcs = codexVendorCandidates.filter( - (proc) => !codexWrapperPidSet.has(proc.ppid ?? -1) - ); + const codexWrapperProcs: PsProcess[] = []; + const codexVendorCandidates: PsProcess[] = []; + const codexWrapperPidSet = new Set(); + for (const proc of processes) { + if (isCodexProcess(proc.cmd, proc.name, matchRe)) { + codexWrapperProcs.push(proc); + codexWrapperPidSet.add(proc.pid); + } + if (isCodexVendorProcess(proc.cmd, proc.name)) { + codexVendorCandidates.push(proc); + } + } + const codexVendorChildren: PsProcess[] = []; + const codexVendorProcs: PsProcess[] = []; + for (const proc of codexVendorCandidates) { + if (codexWrapperPidSet.has(proc.ppid ?? -1)) { + codexVendorChildren.push(proc); + } else { + codexVendorProcs.push(proc); + } + } const includeVendor = process.env.CONSENSUS_INCLUDE_CODEX_VENDOR === "1" || process.env.CONSENSUS_INCLUDE_CODEX_VENDOR === "true"; const codexProcs = includeVendor ? [...codexWrapperProcs, ...codexVendorProcs] : codexWrapperProcs; - const codexPidSet = new Set(codexProcs.map((proc) => proc.pid)); - const opencodeProcs = processes - .filter((proc) => isOpenCodeProcess(proc.cmd, proc.name)) - .filter((proc) => !codexPidSet.has(proc.pid)); - const opencodePidSet = new Set(opencodeProcs.map((proc) => proc.pid)); - const claudeProcs = processes - .filter((proc) => isClaudeProcess(proc.cmd, proc.name)) - .filter((proc) => !codexPidSet.has(proc.pid) && !opencodePidSet.has(proc.pid)); - const pids = Array.from( - new Set([...codexProcs, ...opencodeProcs, ...claudeProcs].map((proc) => proc.pid)) - ); + const codexPidSet = new Set(); + for (const proc of codexProcs) codexPidSet.add(proc.pid); + + const opencodeProcs: PsProcess[] = []; + const claudeProcs: PsProcess[] = []; + const opencodePidSet = new Set(); + for (const proc of processes) { + if (codexPidSet.has(proc.pid)) continue; + if (isOpenCodeProcess(proc.cmd, proc.name)) { + opencodeProcs.push(proc); + opencodePidSet.add(proc.pid); + continue; + } + if (isClaudeProcess(proc.cmd, proc.name)) { + claudeProcs.push(proc); + } + } + + const pidSet = new Set(); + for (const proc of codexProcs) pidSet.add(proc.pid); + for (const proc of opencodeProcs) pidSet.add(proc.pid); + for (const proc of claudeProcs) pidSet.add(proc.pid); + const pids = Array.from(pidSet); const codexVendorPids = codexVendorChildren.map((proc) => proc.pid); - const usagePids = Array.from(new Set([...pids, ...codexVendorPids])); + const usagePidSet = new Set(pidSet); + for (const proc of codexVendorChildren) usagePidSet.add(proc.pid); + const usagePids = Array.from(usagePidSet); if (shouldUseProcessCache) { const refreshPids = new Set( [...opencodeProcs, ...claudeProcs].map((proc) => proc.pid) diff --git a/src/scanner/cache.ts b/src/scanner/cache.ts deleted file mode 100644 index f5cc07e..0000000 --- a/src/scanner/cache.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Effect-based caching for scanner - * Uses Ref for mutable state within Effect - */ - -import { Effect, Ref, Option } from "effect" -import { Config } from "../config/service.js" -import type { ProcessCache, PsProcess, ProcessUsage } from "./types.js" - -// ============================================================================ -// Cache Operations -// ============================================================================ - -/** Create a new empty cache */ -export const makeCache = (): ProcessCache => ({ - at: 0, - processes: [], - usage: {}, - cwds: new Map(), - startTimes: new Map(), - jsonlByPid: new Map(), -}) - -/** Check if cache is valid for given TTL */ -export const isValid = ( - cache: ProcessCache, - now: number, - ttlMs: number -): boolean => { - if (cache.processes.length === 0) return false - return now - cache.at < ttlMs -} - -/** Get TTL based on scan mode */ -export const getTtl = ( - mode: "fast" | "full", - config: { readonly ttlMs: number; readonly fastTtlMs: number } -): number => { - return mode === "fast" ? config.fastTtlMs : config.ttlMs -} - -// ============================================================================ -// Effect-based Cache Service -// ============================================================================ - -/** Cache service interface */ -export interface CacheService { - readonly get: (mode: "fast" | "full") => Effect.Effect, never, Config> - readonly set: (cache: ProcessCache) => Effect.Effect - readonly updateUsage: (usage: Record) => Effect.Effect - readonly invalidate: () => Effect.Effect -} - -/** Create cache service implementation */ -export const makeCacheService = (): Effect.Effect => - Effect.gen(function* () { - const ref = yield* Ref.make(makeCache()) - - return { - get: (mode) => Effect.gen(function* () { - const cache = yield* Ref.get(ref) - const config = yield* Config - const now = Date.now() - const ttl = getTtl(mode, config.scan.processCache) - - if (isValid(cache, now, ttl)) { - return Option.some(cache) - } - return Option.none() - }), - - set: (cache) => Ref.set(ref, { ...cache, at: Date.now() }), - - updateUsage: (usage) => Ref.update(ref, cache => ({ - ...cache, - usage: { ...cache.usage, ...usage }, - })), - - invalidate: () => Ref.set(ref, makeCache()), - } - }) - -// ============================================================================ -// Synchronous Cache (for compatibility with existing code) -// ============================================================================ - -/** Simple mutable cache for use in async functions */ -export class MutableCache { - private cache: ProcessCache = makeCache() - - get(): ProcessCache { - return this.cache - } - - set(cache: Omit): void { - this.cache = { ...cache, at: Date.now() } - } - - isValid(now: number, ttlMs: number): boolean { - return isValid(this.cache, now, ttlMs) - } - - updateUsage(usage: Record): void { - this.cache = { - ...this.cache, - usage: { ...this.cache.usage, ...usage }, - } - } - - invalidate(): void { - this.cache = makeCache() - } -} diff --git a/src/scanner/detectors.ts b/src/scanner/detectors.ts deleted file mode 100644 index 24b2129..0000000 --- a/src/scanner/detectors.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Process detectors for different agent types - * Uses Effect Match for declarative pattern matching - */ - -import { Match, Option } from "effect" -import type { ProcessDetector } from "./types.js" - -// ============================================================================ -// Utility Functions -// ============================================================================ - -const stripQuotes = (value: string): string => - value.replace(/^["']|["']$/g, "") - -const basename = (filepath: string): string => { - const parts = filepath.split(/[\\/]/) - return parts[parts.length - 1] ?? filepath -} - -// ============================================================================ -// Detection Predicates -// ============================================================================ - -const hasCodexVendorPath = (cmdLine: string): boolean => - /[\\/]+codex[\\/]+vendor[\\/]+/i.test(cmdLine) - -const hasCodexToken = (cmdLine: string): boolean => - /(?:^|\s|[\\/])codex(\.exe)?(?:\s|$)/i.test(cmdLine) || - /[\\/]+codex(\.exe)?/i.test(cmdLine) - -const isCodexBinary = (value: string | undefined): boolean => { - if (!value) return false - const cleaned = stripQuotes(value) - const base = basename(cleaned).toLowerCase() - return base === "codex" || base === "codex.exe" -} - -// ============================================================================ -// Codex Detector -// ============================================================================ - -export const codexDetector: ProcessDetector = { - name: "codex", - - isMatch(cmd, name) { - if (!cmd && !name) return false - - return Match.value({ cmd, name }).pipe( - // Exclude OpenCode and Claude - Match.when( - ({ cmd, name }) => isOpenCodeProcess(cmd, name) || isClaudeProcess(cmd, name), - () => false - ), - // Exclude vendor processes - Match.when( - ({ cmd }) => hasCodexVendorPath(cmd || ""), - () => false - ), - // Match by binary name - Match.when( - ({ name }) => isCodexBinary(name), - () => true - ), - // Match by command first token - Match.when( - ({ cmd }) => isCodexBinary(cmd?.split(/\s+/g)[0]), - () => true - ), - // Match by token presence - Match.when( - ({ cmd }) => hasCodexToken(cmd || ""), - () => true - ), - Match.orElse(() => false) - ) - }, - - inferKind(cmd) { - if (cmd.includes(" app-server")) return "app-server" - if (cmd.includes(" exec")) return "exec" - return "tui" - } -} - -// ============================================================================ -// OpenCode Detector -// ============================================================================ - -export const isOpenCodeProcess = ( - cmd: string | undefined, - name: string | undefined -): boolean => { - if (!cmd && !name) return false - if (name?.toLowerCase() === "opencode") return true - if (!cmd) return false - - const firstToken = cmd.trim().split(/\s+/)[0] - const base = basename(firstToken).toLowerCase() - return base === "opencode" || base === "opencode.exe" -} - -export const opencodeDetector: ProcessDetector = { - name: "opencode", - - isMatch(cmd, name) { - return isOpenCodeProcess(cmd, name) - }, - - inferKind(cmd) { - if (cmd.includes(" serve")) return "opencode-server" - if (cmd.includes(" web")) return "opencode-server" - if (/opencode\s+run/i.test(cmd)) return "opencode-cli" - return "opencode-tui" - } -} - -// ============================================================================ -// Claude Detector -// ============================================================================ - -export const isClaudeProcess = ( - cmd: string | undefined, - name: string | undefined -): boolean => { - if (!cmd && !name) return false - if (name === "claude") return true - if (!cmd) return false - - const firstToken = cmd.trim().split(/\s+/)[0] - const base = basename(firstToken) - return base === "claude" || base === "claude.exe" -} - -export const claudeDetector: ProcessDetector = { - name: "claude", - - isMatch(cmd, name) { - return isClaudeProcess(cmd, name) - }, - - inferKind(cmd) { - if (/\b(print|prompt)\b/i.test(cmd)) return "claude-cli" - return "claude-tui" - } -} - -// ============================================================================ -// Detector Registry -// ============================================================================ - -export const detectors = [ - codexDetector, - opencodeDetector, - claudeDetector, -] as const - -export type DetectorName = typeof detectors[number]["name"] - -/** Find matching detector for a process */ -export const findDetector = ( - cmd: string | undefined, - name: string | undefined -): Option.Option => { - for (const detector of detectors) { - if (detector.isMatch(cmd, name)) { - return Option.some(detector) - } - } - return Option.none() -} diff --git a/src/scanner/index.ts b/src/scanner/index.ts deleted file mode 100644 index e174983..0000000 --- a/src/scanner/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Scanner module for process detection and monitoring - */ - -export type { - PsProcess, - ProcessUsage, - ScanMode, - ScanOptions, - ProcessCache, - ProcessDetector, - Scanner, -} from "./types.js" - -export { - codexDetector, - opencodeDetector, - claudeDetector, - isOpenCodeProcess, - isClaudeProcess, - detectors, - findDetector, -} from "./detectors.js" - -export { - makeCache, - isValid, - getTtl, - makeCacheService, - MutableCache, - type CacheService, -} from "./cache.js" - -export { - ScannerService, - ScannerLive, - ScannerLayer, -} from "./service.js" diff --git a/src/scanner/service.ts b/src/scanner/service.ts deleted file mode 100644 index ca24af4..0000000 --- a/src/scanner/service.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Scanner service implementation using Effect - */ - -import { Effect, Context, Layer, Option } from "effect" -import type { Scanner, ScanOptions, PsProcess, ProcessUsage } from "./types.js" -import type { SnapshotPayload } from "../types.js" -import { Config, ConfigLive } from "../config/service.js" - -// ============================================================================ -// Service Tag -// ============================================================================ - -export class ScannerService extends Context.Tag("ScannerService")< - ScannerService, - Scanner ->() {} - -// ============================================================================ -// Implementation (placeholder - full implementation would integrate with scan.ts logic) -// ============================================================================ - -/** Placeholder implementation that delegates to existing scanCodexProcesses */ -export const ScannerLive = Layer.effect( - ScannerService, - Effect.gen(function* () { - // This will be replaced with full Effect-based implementation - // For now, it provides the service interface - return { - scan: (options: ScanOptions) => - Effect.tryPromise({ - try: async () => { - // Import dynamically to avoid circular dependency - const { scanCodexProcesses } = await import("../scan.js") - return scanCodexProcesses(options) - }, - catch: (e) => new Error(`Scan failed: ${e}`), - }).pipe( - Effect.catchAll(() => - Effect.succeed({ ts: Date.now(), agents: [] } as SnapshotPayload) - ) - ), - - getProcessList: () => - Effect.tryPromise({ - try: async () => { - const { default: psList } = await import("ps-list") - return psList() as Promise - }, - catch: (e) => new Error(`Failed to get process list: ${e}`), - }).pipe( - Effect.catchAll(() => Effect.succeed([] as PsProcess[])) - ), - - getUsage: (pids) => - Effect.tryPromise({ - try: async () => { - const pidusage = await import("pidusage") - const result = await pidusage.default([...pids] as number[]) - // Transform to our ProcessUsage type - const transformed: Record = {} - for (const [pid, status] of Object.entries(result)) { - transformed[Number(pid)] = { - cpu: status.cpu, - memory: status.memory, - elapsed: (status as { elapsed?: number }).elapsed, - } - } - return transformed - }, - catch: (e) => new Error(`Failed to get usage: ${e}`), - }).pipe( - Effect.catchAll(() => Effect.succeed({} as Record)) - ), - - getCwd: (pid) => - Effect.tryPromise({ - try: async () => { - // Use lsof or ps to get cwd - const { execFile } = await import("child_process") - const { promisify } = await import("util") - const execFileAsync = promisify(execFile) - - try { - const { stdout } = await execFileAsync("ps", [ - "-o", - "cwd=", - "-p", - String(pid), - ]) - const cwd = stdout.trim() - return cwd ? Option.some(cwd) : Option.none() - } catch { - return Option.none() - } - }, - catch: () => Option.none(), - }).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))), - } - }) -) - -// ============================================================================ -// Layer Composition -// ============================================================================ - -/** Complete scanner layer with all dependencies */ -export const ScannerLayer = ScannerLive.pipe( - Layer.provide(ConfigLive) -) diff --git a/src/scanner/types.ts b/src/scanner/types.ts deleted file mode 100644 index a84c5cd..0000000 --- a/src/scanner/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Scanner service types - */ - -import type { Effect, Option } from "effect" -import type { SnapshotPayload } from "../types.js" - -/** Process information from ps-list */ -export interface PsProcess { - readonly pid: number - readonly ppid?: number - readonly name?: string - readonly cmd?: string -} - -/** Process usage stats */ -export interface ProcessUsage { - readonly cpu: number - readonly memory: number - readonly elapsed?: number -} - -/** Scan mode - fast uses caches, full does complete scan */ -export type ScanMode = "fast" | "full" - -/** Scan options */ -export interface ScanOptions { - readonly mode?: ScanMode - readonly includeActivity?: boolean -} - -/** Cached process data */ -export interface ProcessCache { - readonly at: number - readonly processes: readonly PsProcess[] - readonly usage: Readonly> - readonly cwds: ReadonlyMap - readonly startTimes: ReadonlyMap - readonly jsonlByPid: ReadonlyMap -} - -/** Process detector interface */ -export interface ProcessDetector { - readonly name: string - isMatch(cmd: string | undefined, name: string | undefined): boolean - inferKind(cmd: string): string -} - -/** Scanner service interface */ -export interface Scanner { - readonly scan: (options: ScanOptions) => Effect.Effect - readonly getProcessList: () => Effect.Effect - readonly getUsage: (pids: readonly number[]) => Effect.Effect>> - readonly getCwd: (pid: number) => Effect.Effect> -}