diff --git a/.changeset/hivemind-mcp-tool.md b/.changeset/hivemind-mcp-tool.md new file mode 100644 index 0000000..6777ae3 --- /dev/null +++ b/.changeset/hivemind-mcp-tool.md @@ -0,0 +1,5 @@ +--- +"@cavemem/mcp-server": minor +--- + +Add a compact `hivemind` MCP tool that maps active proxy-runtime agent sessions to their current tasks. diff --git a/.codex/skills/reviewer/SKILL.md b/.codex/skills/reviewer/SKILL.md new file mode 100644 index 0000000..697b89e --- /dev/null +++ b/.codex/skills/reviewer/SKILL.md @@ -0,0 +1,52 @@ +--- +name: reviewer +description: Use when reviewing active multi-agent work, PRs, or handoffs where you need Cavemem's hivemind tool to map branch and task ownership before reading full session memory. +--- + +# Reviewer + +Use this skill for review-shaped tasks where ownership matters: + +- PR review on an active agent branch +- Handoff review +- "who owns this task?" +- conflict triage across worktrees +- stale lane vs live lane checks before touching code + +## Fast path + +1. Call `hivemind` with the target `repo_root` (or `repo_roots`). +2. Pick the smallest relevant session set by exact `branch`, `task`, `agent`, or `worktree_path`. +3. Only after you know which session matters: + - use `search` for cross-session topic lookup + - or `list_sessions` -> `timeline` when you already know the session +4. Fetch full bodies with `get_observations` only for the exact evidence you need. +5. Review the code or diff with findings first: bugs, regressions, missing tests, or ownership conflicts. + +## Hivemind rules + +- Treat `activity=working` or `activity=thinking` as a live lane. +- Treat `source=worktree-lock` without a matching active session as fallback telemetry, not proof. +- Prefer the freshest exact `branch` match over loose task-name similarity. +- Do not fetch observation bodies for every session "just in case". `hivemind` exists to stop that waste. + +## Review checklist + +- Confirm which branch or worktree owns the task. +- Check whether the lane is live, stalled, or dead. +- Pull only the memory evidence needed for the review claim. +- Review the actual diff or files after ownership is clear. +- Report findings first, highest risk first. + +## Output contract + +- Findings first. +- Cite the `branch`, `worktree_path`, or session evidence you used. +- Call out stale telemetry or ownership collisions explicitly. +- If no findings, say so and mention residual risk or missing verification. + +## Example prompts + +- `Review PR #2 with hivemind first so you know which branch still owns the lane.` +- `Check whether this handoff is stale or still live before I edit the worktree.` +- `Find who owns the runtime bug, then review only that lane's evidence.` diff --git a/.codex/skills/reviewer/agents/openai.yaml b/.codex/skills/reviewer/agents/openai.yaml new file mode 100644 index 0000000..95b0448 --- /dev/null +++ b/.codex/skills/reviewer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Cavemem Reviewer" + short_description: "Review active lanes with hivemind-first context" + default_prompt: "Review this active repo or PR by using Cavemem hivemind first, then fetch only the minimum memory and code context needed for findings." diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index e73b489..3c1de7d 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,8 +1,12 @@ import { readFileSync } from 'node:fs'; -import { defineConfig } from 'vitest/config'; +import { defineConfig, mergeConfig } from 'vitest/config'; +import rootConfig from '../../vitest.config.js'; const { version } = JSON.parse(readFileSync('./package.json', 'utf8')) as { version: string }; -export default defineConfig({ - define: { __CAVEMEM_VERSION__: JSON.stringify(version) }, -}); +export default mergeConfig( + rootConfig, + defineConfig({ + define: { __CAVEMEM_VERSION__: JSON.stringify(version) }, + }), +); diff --git a/apps/mcp-server/src/hivemind.ts b/apps/mcp-server/src/hivemind.ts new file mode 100644 index 0000000..5dc88a5 --- /dev/null +++ b/apps/mcp-server/src/hivemind.ts @@ -0,0 +1,483 @@ +import { type Dirent, existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { basename, delimiter, join, resolve } from 'node:path'; + +const ACTIVE_SESSIONS_RELATIVE_DIR = join('.omx', 'state', 'active-sessions'); +const MANAGED_WORKTREE_ROOTS = [join('.omx', 'agent-worktrees'), join('.omc', 'agent-worktrees')]; +const HEARTBEAT_STALE_MS = 5 * 60 * 1000; +const WORKTREE_LOCK_STALE_MS = 15 * 60 * 1000; +const DEFAULT_LIMIT = 50; + +export type HivemindActivity = 'working' | 'thinking' | 'idle' | 'stalled' | 'dead' | 'unknown'; + +export interface HivemindOptions { + repoRoot?: string; + repoRoots?: string[]; + includeStale?: boolean; + limit?: number; + now?: number; +} + +export interface HivemindSession { + repo_root: string; + source: 'active-session' | 'worktree-lock'; + branch: string; + task: string; + task_name: string; + latest_task_preview: string; + agent: string; + cli: string; + state: string; + activity: HivemindActivity; + activity_summary: string; + worktree_path: string; + pid: number | null; + pid_alive: boolean | null; + started_at: string; + last_heartbeat_at: string; + updated_at: string; + elapsed_seconds: number; + task_mode: string; + openspec_tier: string; + routing_reason: string; + snapshot_name: string; + project_name: string; + session_key: string; + file_path: string; +} + +export interface HivemindSnapshot { + generated_at: string; + repo_roots: string[]; + session_count: number; + counts: Record; + sessions: HivemindSession[]; +} + +type JsonRecord = Record; + +export function readHivemind(options: HivemindOptions = {}): HivemindSnapshot { + const now = options.now ?? Date.now(); + const repoRoots = resolveRepoRoots(options); + const limit = normalizeLimit(options.limit); + const sessions = repoRoots.flatMap((repoRoot) => readRepoSessions(repoRoot, now)); + const visibleSessions = options.includeStale + ? sessions + : sessions.filter((session) => session.activity !== 'dead'); + const sortedSessions = visibleSessions.sort(compareSessions); + const limitedSessions = sortedSessions.slice(0, limit); + + return { + generated_at: new Date(now).toISOString(), + repo_roots: repoRoots, + session_count: sortedSessions.length, + counts: countActivities(sortedSessions), + sessions: limitedSessions, + }; +} + +function resolveRepoRoots(options: HivemindOptions): string[] { + const roots = [options.repoRoot, ...(options.repoRoots ?? []), ...envRepoRoots()] + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter(Boolean); + + const selectedRoots = roots.length > 0 ? roots : [process.cwd()]; + return [...new Set(selectedRoots.map((entry) => resolve(entry)))]; +} + +function envRepoRoots(): string[] { + const raw = process.env.CAVEMEM_HIVEMIND_REPO_ROOTS; + if (!raw) return []; + return raw.split(delimiter); +} + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isInteger(limit) || !limit || limit <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(limit, 100); +} + +function readRepoSessions(repoRoot: string, now: number): HivemindSession[] { + const activeSessions = readActiveSessionFiles(repoRoot, now); + const activeWorktrees = new Set( + activeSessions + .filter((session) => session.activity !== 'dead') + .map((session) => resolve(session.worktree_path)) + .filter(Boolean), + ); + const lockSessions = readWorktreeLockSessions(repoRoot, now).filter( + (session) => !activeWorktrees.has(resolve(session.worktree_path)), + ); + + return [...activeSessions, ...lockSessions]; +} + +function readActiveSessionFiles(repoRoot: string, now: number): HivemindSession[] { + const activeSessionsDir = join(repoRoot, ACTIVE_SESSIONS_RELATIVE_DIR); + const files = listJsonFiles(activeSessionsDir); + const sessions: HivemindSession[] = []; + + for (const filePath of files) { + const parsed = readJsonFile(filePath); + const session = parsed ? normalizeActiveSession(repoRoot, parsed, filePath, now) : null; + if (session) sessions.push(session); + } + + return sessions; +} + +function normalizeActiveSession( + fallbackRepoRoot: string, + input: JsonRecord, + filePath: string, + now: number, +): HivemindSession | null { + const repoRoot = resolve( + readString(input.repoRoot) || readString(input.repo_root) || fallbackRepoRoot, + ); + const branch = readString(input.branch); + const worktreePath = readString(input.worktreePath) || readString(input.worktree_path); + const taskName = readString(input.taskName) || readString(input.task_name) || 'task'; + const startedAt = normalizeIso(readString(input.startedAt) || readString(input.started_at)); + const lastHeartbeatAt = normalizeIso( + readString(input.lastHeartbeatAt) || readString(input.last_heartbeat_at) || startedAt, + ); + const pid = readPositiveInteger(input.pid); + + if (!branch || !worktreePath || !startedAt) { + return null; + } + + const pidAlive = pid === null ? null : isPidAlive(pid); + const state = normalizeState(readString(input.state)); + const activity = classifyActiveSession({ state, lastHeartbeatAt, pidAlive, now }); + const latestTaskPreview = + readString(input.latestTaskPreview) || readString(input.latest_task_preview); + + return { + repo_root: repoRoot, + source: 'active-session', + branch, + task: latestTaskPreview || taskName, + task_name: taskName, + latest_task_preview: latestTaskPreview, + agent: readString(input.agentName) || readString(input.agent_name) || 'agent', + cli: readString(input.cliName) || readString(input.cli_name) || 'codex', + state, + activity, + activity_summary: activeActivitySummary(activity, state, lastHeartbeatAt, pidAlive, now), + worktree_path: resolve(worktreePath), + pid, + pid_alive: pidAlive, + started_at: startedAt, + last_heartbeat_at: lastHeartbeatAt, + updated_at: lastHeartbeatAt || startedAt, + elapsed_seconds: elapsedSeconds(startedAt, now), + task_mode: readString(input.taskMode) || readString(input.task_mode), + openspec_tier: readString(input.openspecTier) || readString(input.openspec_tier), + routing_reason: readString(input.taskRoutingReason) || readString(input.routing_reason), + snapshot_name: '', + project_name: '', + session_key: readString(input.sessionKey) || readString(input.session_key), + file_path: filePath, + }; +} + +function readWorktreeLockSessions(repoRoot: string, now: number): HivemindSession[] { + const sessions: HivemindSession[] = []; + for (const relativeRoot of MANAGED_WORKTREE_ROOTS) { + const managedRoot = join(repoRoot, relativeRoot); + if (!existsSync(managedRoot)) continue; + + for (const entry of safeReadDir(managedRoot)) { + if (!entry.isDirectory()) continue; + const worktreePath = join(managedRoot, entry.name); + const lockPath = join(worktreePath, 'AGENT.lock'); + const payload = readJsonFile(lockPath); + if (!payload) continue; + sessions.push(...normalizeWorktreeLock(repoRoot, worktreePath, lockPath, payload, now)); + } + } + return sessions; +} + +function normalizeWorktreeLock( + repoRoot: string, + worktreePath: string, + filePath: string, + payload: JsonRecord, + now: number, +): HivemindSession[] { + const telemetryUpdatedAt = normalizeIso( + readString(payload.updatedAt) || readString(payload.updated_at), + ); + const branch = readWorktreeBranch(worktreePath) || `agent/telemetry/${basename(worktreePath)}`; + const entries = flattenLockSessions(payload); + const lockSessions = + entries.length > 0 + ? entries + : [ + { + taskPreview: readString(payload.taskPreview) || readString(payload.task_preview), + taskUpdatedAt: telemetryUpdatedAt, + projectName: '', + projectPath: worktreePath, + snapshotName: '', + sessionKey: '', + }, + ]; + + return lockSessions + .filter((entry) => entry.taskPreview || telemetryUpdatedAt) + .map((entry) => { + const updatedAt = entry.taskUpdatedAt || telemetryUpdatedAt; + const startedAt = updatedAt || new Date(now).toISOString(); + const activity = classifyWorktreeLock(updatedAt, now); + const taskName = entry.taskPreview || basename(worktreePath) || 'task'; + + return { + repo_root: resolve(repoRoot), + source: 'worktree-lock' as const, + branch, + task: taskName, + task_name: taskName, + latest_task_preview: entry.taskPreview, + agent: deriveAgentName(branch), + cli: 'codex', + state: '', + activity, + activity_summary: worktreeLockSummary(activity, updatedAt, now), + worktree_path: resolve(entry.projectPath || worktreePath), + pid: null, + pid_alive: null, + started_at: startedAt, + last_heartbeat_at: '', + updated_at: updatedAt, + elapsed_seconds: elapsedSeconds(startedAt, now), + task_mode: '', + openspec_tier: '', + routing_reason: '', + snapshot_name: entry.snapshotName, + project_name: entry.projectName, + session_key: entry.sessionKey, + file_path: filePath, + }; + }); +} + +function flattenLockSessions(payload: JsonRecord): Array<{ + taskPreview: string; + taskUpdatedAt: string; + projectName: string; + projectPath: string; + snapshotName: string; + sessionKey: string; +}> { + const snapshots = Array.isArray(payload.snapshots) ? payload.snapshots : []; + const entries: Array<{ + taskPreview: string; + taskUpdatedAt: string; + projectName: string; + projectPath: string; + snapshotName: string; + sessionKey: string; + }> = []; + + for (const snapshot of snapshots) { + if (!isRecord(snapshot)) continue; + const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : []; + for (const session of sessions) { + if (!isRecord(session)) continue; + entries.push({ + taskPreview: readString(session.taskPreview) || readString(session.task_preview), + taskUpdatedAt: normalizeIso( + readString(session.taskUpdatedAt) || readString(session.task_updated_at), + ), + projectName: readString(session.projectName) || readString(session.project_name), + projectPath: readString(session.projectPath) || readString(session.project_path), + snapshotName: readString(snapshot.snapshotName) || readString(snapshot.snapshot_name), + sessionKey: readString(session.sessionKey) || readString(session.session_key), + }); + } + } + + return entries; +} + +function classifyActiveSession(input: { + state: string; + lastHeartbeatAt: string; + pidAlive: boolean | null; + now: number; +}): HivemindActivity { + const heartbeatMs = Date.parse(input.lastHeartbeatAt); + if (Number.isFinite(heartbeatMs) && input.now - heartbeatMs > HEARTBEAT_STALE_MS) { + return 'dead'; + } + if (input.pidAlive === false) { + return 'dead'; + } + if (input.state === 'working') return 'working'; + if (input.state === 'thinking') return 'thinking'; + if (input.state === 'idle') return 'idle'; + return 'unknown'; +} + +function classifyWorktreeLock(updatedAt: string, now: number): HivemindActivity { + const updatedAtMs = Date.parse(updatedAt); + if (!Number.isFinite(updatedAtMs)) return 'unknown'; + return now - updatedAtMs > WORKTREE_LOCK_STALE_MS ? 'stalled' : 'working'; +} + +function activeActivitySummary( + activity: HivemindActivity, + state: string, + lastHeartbeatAt: string, + pidAlive: boolean | null, + now: number, +): string { + if (activity === 'dead' && pidAlive === false) return 'Recorded PID is not alive.'; + if (activity === 'dead') return `Heartbeat stale for ${formatElapsed(lastHeartbeatAt, now)}.`; + if (state) return `Runtime state ${state}.`; + return 'Runtime state unavailable.'; +} + +function worktreeLockSummary(activity: HivemindActivity, updatedAt: string, now: number): string { + if (!updatedAt) return 'Telemetry task preview without timestamp.'; + const elapsed = formatElapsed(updatedAt, now); + if (activity === 'stalled') return `Telemetry stale for ${elapsed}.`; + return `Telemetry updated ${elapsed} ago.`; +} + +function compareSessions(left: HivemindSession, right: HivemindSession): number { + const updatedDelta = Date.parse(right.updated_at) - Date.parse(left.updated_at); + if (Number.isFinite(updatedDelta) && updatedDelta !== 0) return updatedDelta; + return `${left.repo_root}:${left.branch}:${left.task}`.localeCompare( + `${right.repo_root}:${right.branch}:${right.task}`, + ); +} + +function countActivities(sessions: HivemindSession[]): Record { + const counts: Record = { + working: 0, + thinking: 0, + idle: 0, + stalled: 0, + dead: 0, + unknown: 0, + }; + for (const session of sessions) { + counts[session.activity] += 1; + } + return counts; +} + +function listJsonFiles(dir: string): string[] { + return safeReadDir(dir) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => join(dir, entry.name)) + .sort((left, right) => left.localeCompare(right)); +} + +function safeReadDir(dir: string): Array> { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +function readJsonFile(filePath: string): JsonRecord | null { + try { + const stats = statSync(filePath); + if (!stats.isFile()) return null; + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function readWorktreeBranch(worktreePath: string): string { + const gitDir = resolveGitDir(worktreePath); + if (!gitDir) return ''; + const headPath = join(gitDir, 'HEAD'); + try { + const head = readFileSync(headPath, 'utf8').trim(); + const refPrefix = 'ref: refs/heads/'; + if (head.startsWith(refPrefix)) return head.slice(refPrefix.length); + return ''; + } catch { + return ''; + } +} + +function resolveGitDir(worktreePath: string): string { + const dotGitPath = join(worktreePath, '.git'); + try { + const stats = statSync(dotGitPath); + if (stats.isDirectory()) return dotGitPath; + if (!stats.isFile()) return ''; + const pointer = readFileSync(dotGitPath, 'utf8'); + const match = pointer.match(/^gitdir:\s*(.+)$/m); + return match?.[1] ? resolve(worktreePath, match[1].trim()) : ''; + } catch { + return ''; + } +} + +function readPositiveInteger(value: unknown): number | null { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeState(value: string): string { + const normalized = value.toLowerCase(); + return ['working', 'thinking', 'idle'].includes(normalized) ? normalized : ''; +} + +function normalizeIso(value: string): string { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : ''; +} + +function elapsedSeconds(startedAt: string, now: number): number { + const startedAtMs = Date.parse(startedAt); + if (!Number.isFinite(startedAtMs)) return 0; + return Math.max(0, Math.floor((now - startedAtMs) / 1000)); +} + +function formatElapsed(startedAt: string, now: number): string { + const totalSeconds = elapsedSeconds(startedAt, now); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return isRecord(error) && error.code === 'EPERM'; + } +} + +function deriveAgentName(branch: string): string { + const parts = branch.split('/').filter(Boolean); + return parts[0] === 'agent' && parts[1] ? parts[1] : 'agent'; +} + +function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/apps/mcp-server/src/server.ts b/apps/mcp-server/src/server.ts index 845e864..a54d5bc 100644 --- a/apps/mcp-server/src/server.ts +++ b/apps/mcp-server/src/server.ts @@ -8,6 +8,7 @@ import { createEmbedder } from '@cavemem/embedding'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; +import { readHivemind } from './hivemind.js'; /** * MCP stdio server exposing progressive-disclosure tools: @@ -15,6 +16,7 @@ import { z } from 'zod'; * - timeline: chronological IDs around a point * - get_observations: full bodies by ID * - list_sessions: recent sessions for navigation + * - hivemind: compact proxy-runtime active task map * * Embedder is loaded lazily on first search — keeps MCP handshake fast. */ @@ -113,6 +115,26 @@ export function buildServer(store: MemoryStore, settings: Settings): McpServer { }, ); + server.tool( + 'hivemind', + 'Summarize active agent sessions and task ownership from proxy-runtime state files.', + { + repo_root: z.string().min(1).optional(), + repo_roots: z.array(z.string().min(1)).max(20).optional(), + include_stale: z.boolean().optional(), + limit: z.number().int().positive().max(100).optional(), + }, + async ({ repo_root, repo_roots, include_stale, limit }) => { + const options: Parameters[0] = {}; + if (repo_root !== undefined) options.repoRoot = repo_root; + if (repo_roots !== undefined) options.repoRoots = repo_roots; + if (include_stale !== undefined) options.includeStale = include_stale; + if (limit !== undefined) options.limit = limit; + const snapshot = readHivemind(options); + return { content: [{ type: 'text', text: JSON.stringify(snapshot) }] }; + }, + ); + return server; } diff --git a/apps/mcp-server/test/server.test.ts b/apps/mcp-server/test/server.test.ts index 7b2a913..4c0b23a 100644 --- a/apps/mcp-server/test/server.test.ts +++ b/apps/mcp-server/test/server.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defaultSettings } from '@cavemem/config'; @@ -47,12 +47,134 @@ describe('MCP server', () => { const { tools } = await client.listTools(); expect(tools.map((t) => t.name).sort()).toEqual([ 'get_observations', + 'hivemind', 'list_sessions', 'search', 'timeline', ]); }); + it('hivemind returns compact active-session task state', async () => { + const repoRoot = join(dir, 'repo'); + const worktreePath = join(repoRoot, '.omx', 'agent-worktrees', 'agent__codex__live-task'); + const activeSessionDir = join(repoRoot, '.omx', 'state', 'active-sessions'); + const now = new Date().toISOString(); + mkdirSync(activeSessionDir, { recursive: true }); + mkdirSync(worktreePath, { recursive: true }); + writeFileSync( + join(activeSessionDir, 'agent__codex__live-task.json'), + `${JSON.stringify( + { + schemaVersion: 1, + repoRoot, + branch: 'agent/codex/live-task', + taskName: 'Ship hivemind MCP tool', + latestTaskPreview: 'Expose runtime tasks to Codex', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + taskMode: 'caveman', + openspecTier: 'T1', + taskRoutingReason: 'runtime lookup', + startedAt: now, + lastHeartbeatAt: now, + state: 'working', + }, + null, + 2, + )}\n`, + 'utf8', + ); + + const res = await client.callTool({ + name: 'hivemind', + arguments: { repo_root: repoRoot, limit: 5 }, + }); + const text = (res.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}'; + const payload = JSON.parse(text) as { + session_count: number; + counts: Record; + sessions: Array>; + }; + + expect(payload.session_count).toBe(1); + expect(payload.counts.working).toBe(1); + expect(payload.sessions[0]).toMatchObject({ + branch: 'agent/codex/live-task', + task: 'Expose runtime tasks to Codex', + task_name: 'Ship hivemind MCP tool', + agent: 'codex', + activity: 'working', + source: 'active-session', + pid_alive: true, + }); + expect(payload.sessions[0]).not.toHaveProperty('content'); + }); + + it('hivemind falls back to worktree AGENT.lock task previews', async () => { + const repoRoot = join(dir, 'repo-lock'); + const worktreePath = join(repoRoot, '.omx', 'agent-worktrees', 'agent__codex__proxy-task'); + mkdirSync(join(worktreePath, '.git'), { recursive: true }); + writeFileSync( + join(worktreePath, '.git', 'HEAD'), + 'ref: refs/heads/agent/codex/proxy-task\n', + 'utf8', + ); + writeFileSync( + join(worktreePath, 'AGENT.lock'), + `${JSON.stringify( + { + schemaVersion: 1, + source: 'recodee-live-telemetry', + updatedAt: '2026-04-23T08:01:00.000Z', + worktreePath, + worktreeName: 'agent__codex__proxy-task', + snapshotCount: 1, + sessionCount: 1, + snapshots: [ + { + snapshotName: 'default', + email: 'agent@example.com', + sessions: [ + { + sessionKey: 'pid:123', + taskPreview: 'Map proxy runtime sessions to current tasks', + taskUpdatedAt: '2026-04-23T08:01:00.000Z', + projectName: 'recodee', + projectPath: worktreePath, + }, + ], + }, + ], + }, + null, + 2, + )}\n`, + 'utf8', + ); + + const res = await client.callTool({ + name: 'hivemind', + arguments: { repo_root: repoRoot, limit: 5 }, + }); + const text = (res.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}'; + const payload = JSON.parse(text) as { + session_count: number; + sessions: Array>; + }; + + expect(payload.session_count).toBe(1); + expect(payload.sessions[0]).toMatchObject({ + branch: 'agent/codex/proxy-task', + task: 'Map proxy runtime sessions to current tasks', + source: 'worktree-lock', + project_name: 'recodee', + snapshot_name: 'default', + }); + expect(payload.sessions[0]).not.toHaveProperty('email'); + }); + it('search returns compact hits (id, snippet, score, ts)', async () => { await seed(); const res = await client.callTool({ name: 'search', arguments: { query: 'cargo' } }); diff --git a/docs/mcp.md b/docs/mcp.md index 591802b..e998e05 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # MCP tools -cavemem exposes four tools over an MCP stdio server. The design goal is **progressive disclosure**: hits are compact until the agent asks for more. +cavemem exposes five tools over an MCP stdio server. The design goal is **progressive disclosure**: hits are compact until the agent asks for more. The recommended workflow is a three-layer pattern: @@ -8,6 +8,8 @@ The recommended workflow is a three-layer pattern: 2. Review IDs. 3. `get_observations` with the filtered set. +For multi-agent runtime awareness, call `hivemind` first. It returns a compact map of active worktrees, branches, agents, and task previews from `.omx` proxy-runtime state without fetching observation bodies. + Following this pattern saves ~10× tokens versus fetching full bodies upfront. ## `search` @@ -66,6 +68,56 @@ List recent sessions in reverse chronological order. Returns: `[ { id, ide, cwd, started_at, ended_at } ]`. Use `id` with `timeline` to navigate within a session. +## `hivemind` + +Summarize what active agent sessions are doing now. + +```json +{ + "name": "hivemind", + "input": { + "repo_root": "/home/deadpool/Documents/recodee", + "include_stale": false, + "limit": 50 + } +} +``` + +Returns: + +```json +{ + "generated_at": "2026-04-23T08:01:00.000Z", + "repo_roots": ["/home/deadpool/Documents/recodee"], + "session_count": 1, + "counts": { "working": 1, "thinking": 0, "idle": 0, "stalled": 0, "dead": 0, "unknown": 0 }, + "sessions": [ + { + "branch": "agent/codex/live-task", + "task": "Expose runtime tasks to Codex", + "agent": "codex", + "activity": "working", + "worktree_path": "/home/deadpool/Documents/recodee/.omx/agent-worktrees/live-task", + "source": "active-session" + } + ] +} +``` + +Inputs: + +- `repo_root`: one workspace root to inspect. Defaults to the MCP server process cwd. +- `repo_roots`: multiple workspace roots to inspect. Also configurable with `CAVEMEM_HIVEMIND_REPO_ROOTS` separated by the platform path delimiter. +- `include_stale`: include dead active-session records. Defaults to `false`. +- `limit`: maximum sessions returned, capped at 100. + +Sources: + +- `.omx/state/active-sessions/*.json` from the proxy runtime / Guardex active-agent producer. +- `.omx/agent-worktrees/*/AGENT.lock` and `.omc/agent-worktrees/*/AGENT.lock` as a telemetry fallback for task previews. + +Use this from a Codex skill as the first context step when the skill needs to know which session owns which task before reading memory timelines. + ## Contract stability Fields may be added. Existing fields will not be removed or renamed within a minor version. diff --git a/packages/embedding/vitest.config.ts b/packages/embedding/vitest.config.ts index 43e56f4..cdd9c81 100644 --- a/packages/embedding/vitest.config.ts +++ b/packages/embedding/vitest.config.ts @@ -1,7 +1,11 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, mergeConfig } from 'vitest/config'; +import rootConfig from '../../vitest.config.js'; -export default defineConfig({ - test: { - include: ['test/**/*.test.ts'], - }, -}); +export default mergeConfig( + rootConfig, + defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, + }), +); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1fce128 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const rootDir = fileURLToPath(new URL('.', import.meta.url)); + +export const workspaceAliases = { + '@cavemem/compress': resolve(rootDir, 'packages/compress/src/index.ts'), + '@cavemem/config': resolve(rootDir, 'packages/config/src/index.ts'), + '@cavemem/core': resolve(rootDir, 'packages/core/src/index.ts'), + '@cavemem/embedding': resolve(rootDir, 'packages/embedding/src/index.ts'), + '@cavemem/hooks': resolve(rootDir, 'packages/hooks/src/index.ts'), + '@cavemem/installers': resolve(rootDir, 'packages/installers/src/index.ts'), + '@cavemem/mcp-server': resolve(rootDir, 'apps/mcp-server/src/server.ts'), + '@cavemem/storage': resolve(rootDir, 'packages/storage/src/index.ts'), + '@cavemem/worker': resolve(rootDir, 'apps/worker/src/server.ts'), +}; + +export default defineConfig({ + resolve: { + alias: workspaceAliases, + }, +}); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 3bf5573..1687ecc 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -18,12 +18,20 @@ const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json'; const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json'; const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock'; const AGENT_LOG_FILES_GLOB = '**/.omx/logs/*.log'; -const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; +const SESSION_SCAN_EXCLUDE_GLOB = + '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**'; const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**'; const SESSION_SCAN_LIMIT = 200; const REFRESH_DEBOUNCE_MS = 250; -const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agents', 'package.json'); -const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js'); +const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join( + 'vscode', + 'guardex-active-agents', + 'package.json', +); +const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join( + 'scripts', + 'install-vscode-active-agents-extension.js', +); const RELOAD_WINDOW_ACTION = 'Reload Window'; const UPDATE_LATER_ACTION = 'Later'; const REFRESH_POLL_INTERVAL_MS = 30_000; @@ -140,7 +148,9 @@ function agentNameFromBranch(branch) { } function agentBadgeFromBranch(branch) { - const normalized = agentNameFromBranch(branch).toUpperCase().replace(/[^A-Z0-9]/g, ''); + const normalized = agentNameFromBranch(branch) + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); return normalized.slice(0, 2) || 'LK'; } @@ -160,7 +170,9 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) { formatCountLabel(selectedSession.lockCount || 0, 'lock'), selectedSession.worktreePath, 'Click to open Source Control.', - ].filter(Boolean).join('\n'); + ] + .filter(Boolean) + .join('\n'); } const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0)); @@ -169,7 +181,9 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) { formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '', 'Click to open Source Control.', - ].filter(Boolean).join('\n'); + ] + .filter(Boolean) + .join('\n'); } function escapeHtml(value) { @@ -193,11 +207,10 @@ function inspectPanelTitle(session) { } function renderInspectPanelHtml(session, inspectData) { - const heldLocksMarkup = Array.isArray(inspectData?.heldLocks) && inspectData.heldLocks.length > 0 - ? `
    ${inspectData.heldLocks.map((entry) => ( - `
  • ${escapeHtml(entry.relativePath)}${entry.allowDelete ? ' delete ok' : ''}${entry.claimedAt ? ` ${escapeHtml(entry.claimedAt)}` : ''}
  • ` - )).join('')}
` - : '

No held locks recorded for this session.

'; + const heldLocksMarkup = + Array.isArray(inspectData?.heldLocks) && inspectData.heldLocks.length > 0 + ? `
    ${inspectData.heldLocks.map((entry) => `
  • ${escapeHtml(entry.relativePath)}${entry.allowDelete ? ' delete ok' : ''}${entry.claimedAt ? ` ${escapeHtml(entry.claimedAt)}` : ''}
  • `).join('')}
` + : '

No held locks recorded for this session.

'; const logContent = inspectData?.logTailText ? escapeHtml(inspectData.logTailText) : 'No log output available.'; @@ -320,10 +333,9 @@ class SessionDecorationProvider { const nextEntriesByUri = new Map(); for (const entry of repoEntries || []) { for (const [relativePath, lockEntry] of entry.lockEntries || []) { - nextEntriesByUri.set( - vscode.Uri.file(path.join(entry.repoRoot, relativePath)).toString(), - { branch: lockEntry.branch }, - ); + nextEntriesByUri.set(vscode.Uri.file(path.join(entry.repoRoot, relativePath)).toString(), { + branch: lockEntry.branch, + }); } } this.lockEntriesByFileUri = nextEntriesByUri; @@ -348,7 +360,8 @@ class SessionDecorationProvider { return undefined; } - const ownsSelectedSession = Boolean(this.selectedBranch) && lockEntry.branch === this.selectedBranch; + const ownsSelectedSession = + Boolean(this.selectedBranch) && lockEntry.branch === this.selectedBranch; return { badge: agentBadgeFromBranch(lockEntry.branch), tooltip: ownsSelectedSession @@ -402,10 +415,7 @@ class RepoItem extends vscode.TreeItem { descriptionParts.push(`${changedCount} changed`); } this.description = descriptionParts.join(' · '); - this.tooltip = [ - repoRoot, - this.description, - ].join('\n'); + this.tooltip = [repoRoot, this.description].join('\n'); this.iconPath = new vscode.ThemeIcon('repo'); this.contextValue = 'gitguardex.repo'; } @@ -415,8 +425,7 @@ class SectionItem extends vscode.TreeItem { constructor(label, items, options = {}) { super(label, vscode.TreeItemCollapsibleState.Expanded); this.items = items; - this.description = options.description - || (items.length > 0 ? String(items.length) : ''); + this.description = options.description || (items.length > 0 ? String(items.length) : ''); this.contextValue = 'gitguardex.section'; } } @@ -434,7 +443,9 @@ class WorktreeItem extends vscode.TreeItem { } super( path.basename(normalizedWorktreePath || '') || 'worktree', - items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + items.length > 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.None, ); this.worktreePath = normalizedWorktreePath; this.sessions = sessionList; @@ -443,7 +454,9 @@ class WorktreeItem extends vscode.TreeItem { this.tooltip = [ normalizedWorktreePath, ...sessionList.map((session) => session.branch).filter(Boolean), - ].filter(Boolean).join('\n'); + ] + .filter(Boolean) + .join('\n'); this.iconPath = new vscode.ThemeIcon('folder'); this.contextValue = 'gitguardex.worktree'; if (sessionList[0]?.worktreePath) { @@ -459,12 +472,15 @@ class WorktreeItem extends vscode.TreeItem { class SessionItem extends vscode.TreeItem { constructor(session, items = [], options = {}) { const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0; - const label = typeof options.label === 'string' && options.label.trim() - ? options.label.trim() - : session.label; + const label = + typeof options.label === 'string' && options.label.trim() + ? options.label.trim() + : session.label; super( label, - items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, + items.length > 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.None, ); this.session = session; this.items = items; @@ -534,7 +550,9 @@ class ChangeItem extends vscode.TreeItem { change.originalPath ? `Renamed from ${change.originalPath}` : '', change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '', change.absolutePath, - ].filter(Boolean).join('\n'); + ] + .filter(Boolean) + .join('\n'); this.resourceUri = vscode.Uri.file(change.absolutePath); if (change.hasForeignLock) { this.iconPath = new vscode.ThemeIcon('warning'); @@ -579,7 +597,13 @@ function resolveStartAgentCommand(repoRoot, details) { } function sessionDisplayLabel(session) { - return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session'; + return ( + session?.taskName || + session?.label || + session?.branch || + path.basename(session?.worktreePath || '') || + 'session' + ); } function sessionTreeLabel(session) { @@ -703,8 +727,9 @@ function sessionChangedPaths(session) { return []; } - const liveSession = readActiveSessions(session.repoRoot) - .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(session)); + const liveSession = readActiveSessions(session.repoRoot).find( + (entry) => sessionSelectionKey(entry) === sessionSelectionKey(session), + ); return Array.isArray(liveSession?.changedPaths) ? [...new Set(liveSession.changedPaths.map(normalizeRelativePath).filter(Boolean))] : []; @@ -753,7 +778,9 @@ async function openSessionDiff(session) { await vscode.commands.executeCommand('vscode.open', resourceUri); return; } - showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`); + showSessionMessage( + `Failed to open diff for ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`, + ); } } @@ -770,7 +797,9 @@ function repoRootFromLockFile(filePath) { } function normalizeRelativePath(relativePath) { - return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); + return String(relativePath || '') + .replace(/\\/g, '/') + .replace(/^\.\//, ''); } function emptyLockRegistry() { @@ -827,10 +856,12 @@ function readLockRegistry(repoRoot) { function readCurrentBranch(repoRoot) { try { - return cp.execFileSync('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); + return cp + .execFileSync('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + .trim(); } catch (_error) { return ''; } @@ -906,7 +937,9 @@ function runActiveAgentsInstallScript(repoRoot, installScriptPath) { { cwd: repoRoot, encoding: 'utf8' }, (error, stdout, stderr) => { if (error) { - reject(new Error(String(stderr || stdout || error.message || '').trim() || 'install failed')); + reject( + new Error(String(stderr || stdout || error.message || '').trim() || 'install failed'), + ); return; } resolve({ stdout, stderr }); @@ -916,9 +949,10 @@ function runActiveAgentsInstallScript(repoRoot, installScriptPath) { } async function maybeAutoUpdateActiveAgentsExtension(context) { - const installedVersion = typeof context?.extension?.packageJSON?.version === 'string' - ? context.extension.packageJSON.version.trim() - : ''; + const installedVersion = + typeof context?.extension?.packageJSON?.version === 'string' + ? context.extension.packageJSON.version.trim() + : ''; if (!installedVersion) { return; } @@ -931,9 +965,10 @@ async function maybeAutoUpdateActiveAgentsExtension(context) { try { await runActiveAgentsInstallScript(candidate.repoRoot, candidate.installScriptPath); } catch (error) { - const failure = typeof error?.message === 'string' && error.message.trim() - ? error.message.trim() - : 'install failed'; + const failure = + typeof error?.message === 'string' && error.message.trim() + ? error.message.trim() + : 'install failed'; vscode.window.showWarningMessage?.( `GitGuardex Active Agents could not auto-update to ${candidate.version}: ${failure}`, ); @@ -1006,7 +1041,9 @@ function localizeChangeForSession(session, change) { if (originalPath) { const originalAbsolutePath = path.join(session.repoRoot, originalPath); if (isPathWithin(session.worktreePath, originalAbsolutePath)) { - originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath)); + originalPath = normalizeRelativePath( + path.relative(session.worktreePath, originalAbsolutePath), + ); } } @@ -1090,11 +1127,7 @@ function resolveSessionGitIndexPath(worktreePath) { } function bindRefreshWatcher(watcher, refresh) { - return [ - watcher.onDidCreate(refresh), - watcher.onDidChange(refresh), - watcher.onDidDelete(refresh), - ]; + return [watcher.onDidCreate(refresh), watcher.onDidChange(refresh), watcher.onDidDelete(refresh)]; } function disposeAll(disposables) { @@ -1134,7 +1167,9 @@ function buildChangeTreeNodes(changes) { let folderPath = ''; for (const segment of segments.slice(0, -1)) { folderPath = folderPath ? path.posix.join(folderPath, segment) : segment; - let folderNode = nodes.find((node) => node.kind === 'folder' && node.relativePath === folderPath); + let folderNode = nodes.find( + (node) => node.kind === 'folder' && node.relativePath === folderPath, + ); if (!folderNode) { folderNode = { kind: 'folder', @@ -1179,11 +1214,12 @@ function countChangedPaths(repoRoot, sessions, changes) { for (const session of sessions || []) { for (const change of session.touchedChanges || []) { - const absolutePath = change?.absolutePath - || path.join(session.worktreePath || '', change?.relativePath || ''); - const normalizedRelativePath = absolutePath && isPathWithin(repoRoot, absolutePath) - ? normalizeRelativePath(path.relative(repoRoot, absolutePath)) - : `${session.branch}:${normalizeRelativePath(change?.relativePath)}`; + const absolutePath = + change?.absolutePath || path.join(session.worktreePath || '', change?.relativePath || ''); + const normalizedRelativePath = + absolutePath && isPathWithin(repoRoot, absolutePath) + ? normalizeRelativePath(path.relative(repoRoot, absolutePath)) + : `${session.branch}:${normalizeRelativePath(change?.relativePath)}`; if (normalizedRelativePath) { changedKeys.add(normalizedRelativePath); } @@ -1211,15 +1247,17 @@ function groupSessionsByWorktree(sessions) { return [...sessionsByWorktree.values()] .map((entry) => ({ ...entry, - sessions: entry.sessions.sort((left, right) => ( - sessionTreeLabel(left).localeCompare(sessionTreeLabel(right)) - )), + sessions: entry.sessions.sort((left, right) => + sessionTreeLabel(left).localeCompare(sessionTreeLabel(right)), + ), })) .sort((left, right) => { const leftLabel = path.basename(left.worktreePath || '') || ''; const rightLabel = path.basename(right.worktreePath || '') || ''; - return leftLabel.localeCompare(rightLabel) - || (left.worktreePath || '').localeCompare(right.worktreePath || ''); + return ( + leftLabel.localeCompare(rightLabel) || + (left.worktreePath || '').localeCompare(right.worktreePath || '') + ); }); } @@ -1239,8 +1277,9 @@ function buildGroupedChangeTreeNodes(sessions, changes) { for (const change of changes) { const normalizedRelativePath = normalizeRelativePath(change.relativePath); - const session = sessionByChangedPath.get(normalizedRelativePath) - || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); + const session = + sessionByChangedPath.get(normalizedRelativePath) || + sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath)); if (!session) { repoRootChanges.push(change); continue; @@ -1258,24 +1297,25 @@ function buildGroupedChangeTreeNodes(sessions, changes) { const items = groupSessionsByWorktree( sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0), ).map(({ worktreePath, sessions: worktreeSessions }) => { - const sessionItems = worktreeSessions.map((session) => ( - new SessionItem( - session, - buildChangeTreeNodes(changesBySession.get(session.branch) || []), - { label: sessionTreeLabel(session) }, - ) - )); + const sessionItems = worktreeSessions.map( + (session) => + new SessionItem(session, buildChangeTreeNodes(changesBySession.get(session.branch) || []), { + label: sessionTreeLabel(session), + }), + ); const changedCount = worktreeSessions.reduce( - (total, session) => total + ((changesBySession.get(session.branch) || []).length), + (total, session) => total + (changesBySession.get(session.branch) || []).length, 0, ); return new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }); }); if (repoRootChanges.length > 0) { - items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { - description: String(repoRootChanges.length), - })); + items.push( + new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), { + description: String(repoRootChanges.length), + }), + ); } return items; @@ -1320,7 +1360,7 @@ async function promptStartAgentDetails() { prompt: 'Task for the Guardex agent launcher', placeHolder: 'vscode active agents welcome view', ignoreFocusOut: true, - validateInput: (value) => value.trim() ? undefined : 'Task is required.', + validateInput: (value) => (value.trim() ? undefined : 'Task is required.'), }); if (!taskName) { return null; @@ -1331,7 +1371,7 @@ async function promptStartAgentDetails() { placeHolder: 'codex', value: 'codex', ignoreFocusOut: true, - validateInput: (value) => value.trim() ? undefined : 'Agent name is required.', + validateInput: (value) => (value.trim() ? undefined : 'Agent name is required.'), }); if (!agentName) { return null; @@ -1399,17 +1439,19 @@ function buildActiveAgentGroupNodes(sessions) { const groups = []; for (const group of SESSION_ACTIVITY_GROUPS) { const groupSessions = sessions.filter((session) => session.activityKind === group.kind); - const worktreeItems = groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ( - new WorktreeItem( - worktreePath, - worktreeSessions, - worktreeSessions.map((session) => new SessionItem( - session, - buildChangeTreeNodes(session.touchedChanges || []), - { label: sessionTreeLabel(session) }, - )), - ) - )); + const worktreeItems = groupSessionsByWorktree(groupSessions).map( + ({ worktreePath, sessions: worktreeSessions }) => + new WorktreeItem( + worktreePath, + worktreeSessions, + worktreeSessions.map( + (session) => + new SessionItem(session, buildChangeTreeNodes(session.touchedChanges || []), { + label: sessionTreeLabel(session), + }), + ), + ), + ); if (worktreeItems.length > 0) { groups.push(new SectionItem(group.label, worktreeItems)); } @@ -1475,7 +1517,9 @@ class ActiveAgentsProvider { const nextSession = repoEntries .flatMap((entry) => entry.sessions) - .find((session) => sessionSelectionKey(session) === sessionSelectionKey(this.selectedSession)); + .find( + (session) => sessionSelectionKey(session) === sessionSelectionKey(this.selectedSession), + ); this.setSelectedSession(nextSession || null); } @@ -1507,12 +1551,13 @@ class ActiveAgentsProvider { badgeTooltipParts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`); } - this.treeView.badge = sessionCount > 0 - ? { - value: sessionCount, - tooltip: badgeTooltipParts.join(' · '), - } - : undefined; + this.treeView.badge = + sessionCount > 0 + ? { + value: sessionCount, + tooltip: badgeTooltipParts.join(' · '), + } + : undefined; this.treeView.message = undefined; } @@ -1566,14 +1611,25 @@ class ActiveAgentsProvider { }), ]; if (element.changes.length > 0) { - sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), { - description: String(element.changes.length), - })); + sectionItems.push( + new SectionItem( + 'CHANGES', + buildGroupedChangeTreeNodes(element.sessions, element.changes), + { + description: String(element.changes.length), + }, + ), + ); } return sectionItems; } - if (element instanceof SectionItem || element instanceof FolderItem || element instanceof WorktreeItem || element instanceof SessionItem) { + if ( + element instanceof SectionItem || + element instanceof FolderItem || + element instanceof WorktreeItem || + element instanceof SessionItem + ) { return element.items; } @@ -1596,9 +1652,9 @@ class ActiveAgentsProvider { return { repoRoot, sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)), - changes: readRepoChanges(repoRoot).map((change) => ( - decorateChange(change, lockRegistry, currentBranch) - )), + changes: readRepoChanges(repoRoot).map((change) => + decorateChange(change, lockRegistry, currentBranch), + ), lockEntries: Array.from(lockRegistry.entriesByPath.entries()), }; }); @@ -1659,9 +1715,11 @@ class SessionInspectPanelManager { return this.session ? { ...this.session } : null; } - return readActiveSessions(this.session.repoRoot, { includeStale: true }) - .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(this.session)) - || { ...this.session }; + return ( + readActiveSessions(this.session.repoRoot, { includeStale: true }).find( + (entry) => sessionSelectionKey(entry) === sessionSelectionKey(this.session), + ) || { ...this.session } + ); } render() { @@ -1772,7 +1830,10 @@ function activate(context) { 'gitguardex.activeAgents.commitInput', 'Active Agents Commit', ); - const activeAgentsStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10); + const activeAgentsStatusItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 10, + ); activeAgentsStatusItem.name = 'GitGuardex Active Agents'; activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus'; provider.attachTreeView(treeView); @@ -1866,12 +1927,17 @@ function activate(context) { inspectPanelManager, refreshController, vscode.window.registerFileDecorationProvider(decorationProvider), - vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), + vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => + startAgentFromPrompt(refresh), + ), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => { await vscode.commands.executeCommand('workbench.view.scm'); }), - vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), + vscode.commands.registerCommand( + 'gitguardex.activeAgents.commitSelectedSession', + commitSelectedSession, + ), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { return; @@ -1889,7 +1955,9 @@ function activate(context) { } if (!fs.existsSync(change.absolutePath)) { - vscode.window.showInformationMessage?.(`Changed path is no longer on disk: ${change.relativePath}`); + vscode.window.showInformationMessage?.( + `Changed path is no longer on disk: ${change.relativePath}`, + ); return; } @@ -1900,7 +1968,9 @@ function activate(context) { }), vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), - vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), + vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => + stopSession(session, refresh), + ), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), activeSessionsWatcher, diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 8e1224e..d148ef4 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -9,10 +9,7 @@ "engines": { "vscode": "^1.88.0" }, - "categories": [ - "Source Control", - "Other" - ], + "categories": ["Source Control", "Other"], "activationEvents": [ "onStartupFinished", "workspaceContains:.omx/state/active-sessions", diff --git a/vscode/guardex-active-agents/session-schema.js b/vscode/guardex-active-agents/session-schema.js index 252ccfc..80af442 100644 --- a/vscode/guardex-active-agents/session-schema.js +++ b/vscode/guardex-active-agents/session-schema.js @@ -14,8 +14,9 @@ const MANAGED_WORKTREE_ROOTS = [ const MAX_CHANGED_PATH_PREVIEW = 3; const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/'); const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/'); -const MANAGED_WORKTREE_FILTER_PREFIXES = MANAGED_WORKTREE_ROOTS - .map((relativeRoot) => relativeRoot.split(path.sep).join('/').replace(/\/+$/, '')); +const MANAGED_WORKTREE_FILTER_PREFIXES = MANAGED_WORKTREE_ROOTS.map((relativeRoot) => + relativeRoot.split(path.sep).join('/').replace(/\/+$/, ''), +); const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000; const HEARTBEAT_STALE_MS = 5 * 60 * 1000; @@ -109,7 +110,9 @@ function sessionFilePathForBranch(repoRoot, branch) { } function resolveManagedWorktreeRoots(repoRoot) { - return MANAGED_WORKTREE_ROOTS.map((relativeRoot) => path.join(path.resolve(repoRoot), relativeRoot)); + return MANAGED_WORKTREE_ROOTS.map((relativeRoot) => + path.join(path.resolve(repoRoot), relativeRoot), + ); } function splitOutputLines(output) { @@ -117,9 +120,7 @@ function splitOutputLines(output) { return null; } - return output - .split(/\r?\n/) - .filter((line) => line.trim().length > 0); + return output.split(/\r?\n/).filter((line) => line.trim().length > 0); } function normalizeRelativePath(value) { @@ -162,9 +163,10 @@ function readAheadBehindCounts(worktreePath, branch, baseBranch) { '--count', `${normalizedBranch}...${compareRef}`, ]); - const match = Array.isArray(lines) && typeof lines[0] === 'string' - ? lines[0].trim().match(/^(\d+)\s+(\d+)$/) - : null; + const match = + Array.isArray(lines) && typeof lines[0] === 'string' + ? lines[0].trim().match(/^(\d+)\s+(\d+)$/) + : null; if (!match) { return { compareRef, @@ -381,13 +383,14 @@ function parseRepoChangeLine(repoRoot, line) { const normalizedRelativePath = relativePath.split(path.sep).join('/'); if ( - normalizedRelativePath === LOCK_FILE_FILTER_PATH - || normalizedRelativePath.startsWith(`${LOCK_FILE_FILTER_PATH}/`) - || normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX - || normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`) - || MANAGED_WORKTREE_FILTER_PREFIXES.some((prefix) => ( - normalizedRelativePath === prefix || normalizedRelativePath.startsWith(`${prefix}/`) - )) + normalizedRelativePath === LOCK_FILE_FILTER_PATH || + normalizedRelativePath.startsWith(`${LOCK_FILE_FILTER_PATH}/`) || + normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX || + normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`) || + MANAGED_WORKTREE_FILTER_PREFIXES.some( + (prefix) => + normalizedRelativePath === prefix || normalizedRelativePath.startsWith(`${prefix}/`), + ) ) { return null; } @@ -403,8 +406,23 @@ function parseRepoChangeLine(repoRoot, line) { function collectWorktreeChangedPaths(worktreePath) { const changedGroups = [ - runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), - runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]), + runGitLines(worktreePath, [ + 'diff', + '--name-only', + '--', + '.', + `:(exclude)${LOCK_FILE_RELATIVE}`, + `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`, + ]), + runGitLines(worktreePath, [ + 'diff', + '--cached', + '--name-only', + '--', + '.', + `:(exclude)${LOCK_FILE_RELATIVE}`, + `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`, + ]), runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']), ]; @@ -413,11 +431,12 @@ function collectWorktreeChangedPaths(worktreePath) { } return [...new Set(changedGroups.flat())] - .filter((relativePath) => ( - relativePath - && relativePath !== LOCK_FILE_RELATIVE - && relativePath !== AGENT_WORKTREE_LOCK_FILE - )) + .filter( + (relativePath) => + relativePath && + relativePath !== LOCK_FILE_RELATIVE && + relativePath !== AGENT_WORKTREE_LOCK_FILE, + ) .sort((left, right) => left.localeCompare(right)); } @@ -460,7 +479,12 @@ function deriveBlockingGitLabel(worktreePath) { } function collectWorktreeTrackedPaths(worktreePath) { - const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']); + const trackedPaths = runGitLines(worktreePath, [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + ]); if (!trackedPaths) { return null; } @@ -476,9 +500,9 @@ function shouldSkipWorktreeActivityPath(relativePath) { return true; } - return WORKTREE_ACTIVITY_SKIP_PREFIXES.some((prefix) => ( - normalized === prefix.slice(0, -1) || normalized.startsWith(prefix) - )); + return WORKTREE_ACTIVITY_SKIP_PREFIXES.some( + (prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix), + ); } function worktreeActivityPathPriority(relativePath, recentPathsSet) { @@ -495,25 +519,30 @@ function worktreeActivityPathPriority(relativePath, recentPathsSet) { } function collectWorktreeActivityCandidatePaths(worktreePath, trackedPaths) { - const recentPaths = runGitLines(worktreePath, ['log', '-1', '--name-only', '--pretty=format:', '--', '.']) || []; - const filteredRecentPaths = [...new Set(recentPaths.map(normalizeRelativePath).filter(Boolean))] - .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)); + const recentPaths = + runGitLines(worktreePath, ['log', '-1', '--name-only', '--pretty=format:', '--', '.']) || []; + const filteredRecentPaths = [ + ...new Set(recentPaths.map(normalizeRelativePath).filter(Boolean)), + ].filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)); const recentPathSet = new Set(filteredRecentPaths); const prioritizedTrackedPaths = trackedPaths .map(normalizeRelativePath) .filter(Boolean) .filter((relativePath) => !shouldSkipWorktreeActivityPath(relativePath)) .sort((left, right) => { - const priorityDelta = worktreeActivityPathPriority(left, recentPathSet) - - worktreeActivityPathPriority(right, recentPathSet); + const priorityDelta = + worktreeActivityPathPriority(left, recentPathSet) - + worktreeActivityPathPriority(right, recentPathSet); if (priorityDelta !== 0) { return priorityDelta; } return left.localeCompare(right); }); - return [...new Set([...filteredRecentPaths, ...prioritizedTrackedPaths])] - .slice(0, MAX_WORKTREE_ACTIVITY_STAT_PATHS); + return [...new Set([...filteredRecentPaths, ...prioritizedTrackedPaths])].slice( + 0, + MAX_WORKTREE_ACTIVITY_STAT_PATHS, + ); } function clearWorktreeActivityCache(worktreePath = '') { @@ -531,7 +560,7 @@ function deriveLatestWorktreeFileActivity(worktreePath, options = {}) { const cacheKey = path.resolve(worktreePath); if (useCache) { const cached = worktreeActivityCache.get(cacheKey); - if (cached && (now - cached.checkedAtMs) < WORKTREE_ACTIVITY_CACHE_TTL_MS) { + if (cached && now - cached.checkedAtMs < WORKTREE_ACTIVITY_CACHE_TTL_MS) { return cached.latestMtimeMs; } } @@ -550,12 +579,9 @@ function deriveLatestWorktreeFileActivity(worktreePath, options = {}) { if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) { continue; } - latestMtimeMs = latestMtimeMs === null - ? stats.mtimeMs - : Math.max(latestMtimeMs, stats.mtimeMs); - } catch (_error) { - continue; - } + latestMtimeMs = + latestMtimeMs === null ? stats.mtimeMs : Math.max(latestMtimeMs, stats.mtimeMs); + } catch (_error) {} } if (useCache) { @@ -637,17 +663,25 @@ function deriveSessionActivity(session, options = {}) { } if (worktreeChangedPaths.length > 0) { - const worktreeRelativePaths = [...new Set(worktreeChangedPaths - .map((relativePath) => normalizeRelativePath(relativePath)) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right)); + const worktreeRelativePaths = [ + ...new Set( + worktreeChangedPaths + .map((relativePath) => normalizeRelativePath(relativePath)) + .filter(Boolean), + ), + ].sort((left, right) => left.localeCompare(right)); clearWorktreeActivityCache(session.worktreePath); - const changedPaths = [...new Set(worktreeChangedPaths - .map((relativePath) => normalizeRelativePath( - path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), - )) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right)); + const changedPaths = [ + ...new Set( + worktreeChangedPaths + .map((relativePath) => + normalizeRelativePath( + path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)), + ), + ) + .filter(Boolean), + ), + ].sort((left, right) => left.localeCompare(right)); return { activityKind: 'working', @@ -696,11 +730,12 @@ function deriveSessionActivity(session, options = {}) { activityKind: 'idle', activityLabel: 'idle', activityCountLabel: '', - activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS - ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` - : lastFileActivityLabel - ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` - : 'Worktree clean.', + activitySummary: + lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS + ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.` + : lastFileActivityLabel + ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.` + : 'Worktree clean.', changeCount: 0, changedPaths: [], worktreeChangedPaths: [], @@ -777,12 +812,12 @@ function normalizeSessionRecord(input, options = {}) { const pid = toPositiveInteger(input.pid); if ( - !repoRoot - || !branch - || !worktreePath - || !pid - || Number.isNaN(startedAt.getTime()) - || Number.isNaN(lastHeartbeatAt.getTime()) + !repoRoot || + !branch || + !worktreePath || + !pid || + Number.isNaN(startedAt.getTime()) || + Number.isNaN(lastHeartbeatAt.getTime()) ) { return null; } @@ -922,9 +957,8 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload); const telemetryUpdatedAt = normalizeIsoString(lockPayload?.updatedAt); const branch = readWorktreeBranch(worktreePath); - const effectiveBranch = branch && branch !== 'HEAD' - ? branch - : `agent/telemetry/${path.basename(worktreePath)}`; + const effectiveBranch = + branch && branch !== 'HEAD' ? branch : `agent/telemetry/${path.basename(worktreePath)}`; const label = deriveSessionLabel(effectiveBranch, worktreePath); const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt); const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString();