From 66c7013e0c20f60d36b819b61cae65142d28fdc1 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 10:56:37 +0200 Subject: [PATCH 1/4] feat(mcp): let agents see active runtime tasks Expose a compact hivemind MCP tool so Codex skills can ask Cavemem which proxy-runtime sessions own which tasks before reading memory bodies. The tool reads only local active-session JSON and AGENT.lock telemetry, keeps stale active records from hiding fresh lock telemetry, and documents the skill-first usage path. Constraint: MCP compact tools must preserve progressive disclosure and avoid raw observation bodies. Rejected: Persisting runtime task state in Cavemem storage | runtime ownership is transient and already emitted by proxy/Guardex state files. Confidence: high Scope-risk: narrow Tested: pnpm exec biome check apps/mcp-server/src/server.ts apps/mcp-server/src/hivemind.ts apps/mcp-server/test/server.test.ts docs/mcp.md .changeset/hivemind-mcp-tool.md Tested: pnpm --filter @cavemem/mcp-server test Tested: pnpm --filter @cavemem/mcp-server typecheck Tested: pnpm --filter @cavemem/mcp-server build --- .changeset/hivemind-mcp-tool.md | 5 + apps/mcp-server/src/hivemind.ts | 483 ++++++++++++++++++++++++++++ apps/mcp-server/src/server.ts | 22 ++ apps/mcp-server/test/server.test.ts | 124 ++++++- docs/mcp.md | 54 +++- 5 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 .changeset/hivemind-mcp-tool.md create mode 100644 apps/mcp-server/src/hivemind.ts 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/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. From 1563b79d435f8845ff51b60cf00eca8c2f9e3e4c Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 11:07:37 +0200 Subject: [PATCH 2/4] chore: keep active-agents files biome-clean Current origin/main introduced tracked Active Agents files that make the repo-wide CI lint gate fail before the MCP change is tested. Apply Biome formatting and remove the no-op continue so the existing CI gate can run cleanly. Constraint: CI runs `pnpm lint` across the whole repository, not only changed MCP files. Rejected: Ignore the red CI check | the PR would remain unmergeable even though the failure is formatting drift. Confidence: high Scope-risk: narrow Tested: pnpm lint Tested: pnpm typecheck Tested: pnpm test Tested: pnpm build --- vscode/guardex-active-agents/extension.js | 290 +++++++++++------- vscode/guardex-active-agents/package.json | 5 +- .../guardex-active-agents/session-schema.js | 164 ++++++---- 3 files changed, 280 insertions(+), 179 deletions(-) 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(); From 79121d0abe2c6504aaf10989bb7c40b9b1b59883 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 11:20:03 +0200 Subject: [PATCH 3/4] test: resolve workspace packages from source Vitest runs before the build gate in CI, so clean checkouts do not have package dist outputs yet. Route workspace package imports to source during tests while preserving published dist exports for runtime consumers. Constraint: CI executes pnpm test before pnpm build. Rejected: Move the CI build before tests | would mask the clean pre-build test contract instead of fixing package resolution. Confidence: high Scope-risk: narrow Tested: pnpm clean; pnpm test; pnpm lint; pnpm typecheck; pnpm build --- apps/cli/vitest.config.ts | 12 ++++++++---- packages/embedding/vitest.config.ts | 16 ++++++++++------ vitest.config.ts | 23 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 vitest.config.ts 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/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, + }, +}); From 9e1bf46f9c4b1bbb03fed1daff4eabcdc442bf79 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 11:31:12 +0200 Subject: [PATCH 4/4] docs: teach reviewers to start from hivemind ownership The new `hivemind` MCP tool only helps if review flows call it before pulling full memory bodies. This adds a repo-local `reviewer` skill so Codex can map active branches and worktrees first, then drill into targeted session evidence only when needed. Constraint: Keep the repo-local skill lean and dependency-free Rejected: README-only guidance | skill discovery needs an executable workflow surface Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the skill hivemind-first; do not broaden it into a general review replacement without validating the trigger surface Tested: git diff --check; reviewed skill metadata and unignored file state Not-tested: pnpm typecheck; pnpm lint; pnpm test; pnpm build (docs-only follow-up on a previously verified PR branch) --- .codex/skills/reviewer/SKILL.md | 52 +++++++++++++++++++++++ .codex/skills/reviewer/agents/openai.yaml | 4 ++ 2 files changed, 56 insertions(+) create mode 100644 .codex/skills/reviewer/SKILL.md create mode 100644 .codex/skills/reviewer/agents/openai.yaml 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."