codehere 0.8.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +37 -184
  2. package/dist/application/use-cases/check-claimed-tests.js +81 -0
  3. package/dist/application/use-cases/detect-test-weakening.js +237 -0
  4. package/dist/application/use-cases/rerun-tests.js +329 -0
  5. package/dist/index.js +74 -1004
  6. package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -265
  7. package/dist/presentation/cli/commands/check-command.js +251 -0
  8. package/dist/presentation/cli/commands/help-command.js +22 -286
  9. package/dist/ui.js +19 -438
  10. package/dist/utils/version.js +0 -12
  11. package/package.json +18 -27
  12. package/dist/application/use-cases/domain-trust.js +0 -110
  13. package/dist/application/use-cases/generate-final-report.js +0 -134
  14. package/dist/application/use-cases/generate-narration.js +0 -128
  15. package/dist/application/use-cases/generate-thread-title.js +0 -178
  16. package/dist/application/use-cases/narrator-prompt-template.js +0 -158
  17. package/dist/application/use-cases/thread-export.js +0 -155
  18. package/dist/application/use-cases/verify-claim.js +0 -274
  19. package/dist/application/use-cases/write-pass.js +0 -277
  20. package/dist/domain/entities/task.js +0 -61
  21. package/dist/domain/services/codebase-detector.js +0 -91
  22. package/dist/error-handler.js +0 -251
  23. package/dist/infrastructure/completion/bash-completion.js +0 -25
  24. package/dist/infrastructure/completion/fish-completion.js +0 -22
  25. package/dist/infrastructure/completion/zsh-completion.js +0 -30
  26. package/dist/infrastructure/config/config-manager.js +0 -173
  27. package/dist/infrastructure/config/product-context.js +0 -67
  28. package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
  29. package/dist/infrastructure/context/adapters/figma-source.js +0 -163
  30. package/dist/infrastructure/context/adapters/github-issue-source.js +0 -159
  31. package/dist/infrastructure/context/adapters/linear-source.js +0 -190
  32. package/dist/infrastructure/context/context-resolver.js +0 -144
  33. package/dist/infrastructure/context/context-source.js +0 -114
  34. package/dist/infrastructure/context/oauth-flow.js +0 -207
  35. package/dist/infrastructure/context/oauth-store.js +0 -119
  36. package/dist/infrastructure/context/registry-bootstrap.js +0 -99
  37. package/dist/infrastructure/context/write-pass-resolver.js +0 -106
  38. package/dist/infrastructure/cost/budget-config.js +0 -113
  39. package/dist/infrastructure/cost/cost-router.js +0 -104
  40. package/dist/infrastructure/cost/spend-aggregator.js +0 -147
  41. package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -888
  42. package/dist/infrastructure/hooks/hook-mode.js +0 -71
  43. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -43
  44. package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
  45. package/dist/infrastructure/hooks/session-analyzer.js +0 -177
  46. package/dist/infrastructure/observability/execution-tracer.js +0 -227
  47. package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
  48. package/dist/infrastructure/orchestration/agent-installer.js +0 -101
  49. package/dist/infrastructure/orchestration/agents-config-seed.js +0 -56
  50. package/dist/infrastructure/orchestration/aider-adapter.js +0 -447
  51. package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
  52. package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -478
  53. package/dist/infrastructure/orchestration/codex-adapter.js +0 -538
  54. package/dist/infrastructure/orchestration/conductor-adapter.js +0 -377
  55. package/dist/infrastructure/orchestration/context-handoff.js +0 -144
  56. package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
  57. package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
  58. package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
  59. package/dist/infrastructure/orchestration/failover-config.js +0 -77
  60. package/dist/infrastructure/orchestration/failover-policy.js +0 -106
  61. package/dist/infrastructure/orchestration/gemini-adapter.js +0 -583
  62. package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -558
  63. package/dist/infrastructure/orchestration/graphify-context.js +0 -254
  64. package/dist/infrastructure/orchestration/memory-injector.js +0 -380
  65. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -233
  66. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
  67. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
  68. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
  69. package/dist/infrastructure/orchestration/opencode-adapter.js +0 -510
  70. package/dist/infrastructure/orchestration/progress-aggregator.js +0 -689
  71. package/dist/infrastructure/orchestration/readiness.js +0 -103
  72. package/dist/infrastructure/orchestration/session-manager.js +0 -1091
  73. package/dist/infrastructure/orchestration/session-persistence.js +0 -258
  74. package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
  75. package/dist/infrastructure/orchestration/write-pass-store.js +0 -46
  76. package/dist/infrastructure/protocols/cloud-sync.js +0 -207
  77. package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
  78. package/dist/infrastructure/protocols/mcp-server-impl.js +0 -2853
  79. package/dist/infrastructure/protocols/probe-port.js +0 -36
  80. package/dist/infrastructure/protocols/thread-phase.js +0 -105
  81. package/dist/infrastructure/protocols/ui-page-v2.js +0 -4418
  82. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  83. package/dist/infrastructure/protocols/verify-page.js +0 -983
  84. package/dist/infrastructure/security/file-collection.js +0 -118
  85. package/dist/infrastructure/security/input-validator.js +0 -152
  86. package/dist/infrastructure/security/permissions-config.js +0 -234
  87. package/dist/infrastructure/security/trust-scorer.js +0 -657
  88. package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
  89. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  90. package/dist/infrastructure/storage/project-thread-migration.js +0 -237
  91. package/dist/infrastructure/storage/project-thread-mirror.js +0 -524
  92. package/dist/infrastructure/storage/project-thread-reader.js +0 -172
  93. package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
  94. package/dist/infrastructure/storage/task-repository.js +0 -206
  95. package/dist/monitoring.js +0 -212
  96. package/dist/presentation/cli/commands/audit-command.js +0 -126
  97. package/dist/presentation/cli/commands/budget-command.js +0 -79
  98. package/dist/presentation/cli/commands/config-command.js +0 -125
  99. package/dist/presentation/cli/commands/context-source-command.js +0 -198
  100. package/dist/presentation/cli/commands/hook-command.js +0 -265
  101. package/dist/presentation/cli/commands/init-command.js +0 -578
  102. package/dist/presentation/cli/commands/memory-command.js +0 -1031
  103. package/dist/presentation/cli/commands/narrator-command.js +0 -101
  104. package/dist/presentation/cli/commands/permission-command.js +0 -116
  105. package/dist/presentation/cli/commands/protocols-builder.js +0 -254
  106. package/dist/presentation/cli/commands/protocols-command.js +0 -84
  107. package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
  108. package/dist/presentation/cli/commands/report-command.js +0 -387
  109. package/dist/presentation/cli/commands/scan-tool-command.js +0 -94
  110. package/dist/presentation/cli/commands/setup-command.js +0 -307
  111. package/dist/presentation/cli/commands/status-command.js +0 -661
  112. package/dist/presentation/cli/commands/thread-command.js +0 -288
  113. package/dist/presentation/cli/commands/trust-command.js +0 -306
  114. package/dist/presentation/cli/commands/verify-command.js +0 -233
  115. package/dist/presentation/cli/error-display.js +0 -336
  116. package/dist/presentation/cli/repl.js +0 -797
  117. package/dist/presentation/cli/task-deliverable.js +0 -127
  118. package/dist/testing/cassette.js +0 -125
  119. package/dist/testing/replay-adapter.js +0 -166
@@ -1,1091 +0,0 @@
1
- /**
2
- * In-memory registry of active task sessions + audit-log derived observation.
3
- *
4
- * Two kinds of session surfaced to the UI:
5
- * 1. spawned — started via codehere.spawn; we own the subprocess, can kill.
6
- * 2. observed — derived from the audit log. Claude Code sessions running
7
- * in the user's terminals, IDE, or elsewhere on this machine.
8
- * codehere didn't start them and can't kill them, but shows
9
- * them so the dashboard reflects reality from first load.
10
- *
11
- * Durable state lives in the audit log (F0). On daemon restart, spawned
12
- * sessions are gone but observed ones remain (they're re-derived from the log).
13
- */
14
- import { existsSync, readFileSync } from 'fs';
15
- import { execFileSync } from 'child_process';
16
- import { ClaudeCodeAdapter } from './claude-code-adapter.js';
17
- import { CodexAdapter } from './codex-adapter.js';
18
- import { AiderAdapter } from './aider-adapter.js';
19
- import { OpenCodeAdapter } from './opencode-adapter.js';
20
- import { GeminiAdapter } from './gemini-adapter.js';
21
- import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js';
22
- import { buildConductorAdapter } from './conductor-adapter.js';
23
- import { recordTurnToWorkingMemory } from './memory-injector.js';
24
- import { seedAgentsConfig } from './agents-config-seed.js';
25
- import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
26
- import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
27
- import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
28
- import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/project-thread-mirror.js';
29
- import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
30
- import { generateNarration } from '../../application/use-cases/generate-narration.js';
31
- import { randomUUID } from 'crypto';
32
- import { classifyFailoverEligibility } from './failover-classifier.js';
33
- import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
34
- import { loadFailoverConfig } from './failover-config.js';
35
- import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
36
- import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
37
- import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
38
- // ── Agent registry ─────────────────────────────────────────────────────────────
39
- // All backends codehere can spawn. Order here drives the default UI dropdown
40
- // order. Built-in adapters first; user-config-driven ones appended from
41
- // ~/.codehere/agents.yaml (generic-cli-adapter). isInstalled() gates visibility
42
- // so an adapter registering for an un-installed CLI is harmless.
43
- const BUILTIN_REGISTRY = [
44
- ClaudeCodeAdapter,
45
- CodexAdapter,
46
- AiderAdapter,
47
- OpenCodeAdapter,
48
- GeminiAdapter,
49
- ];
50
- // Seed the default agents.yaml if this user has never configured one, so Gemini
51
- // / Cursor / Pi / Antigravity / Goose appear in the registry pre-wired. They
52
- // stay hidden in the UI until isInstalled() flips to true. Idempotent.
53
- seedAgentsConfig();
54
- const REGISTRY = [
55
- ...BUILTIN_REGISTRY,
56
- // User-configurable adapters — "plug any CLI" escape hatch, seeded with
57
- // popular CLI agents above. Users can add/edit/remove entries freely.
58
- ...loadAgentsConfig().map(buildGenericAdapter),
59
- ];
60
- // F6 Conductor — the meta-agent. Registered LAST so it sees the full registry
61
- // at lookup time. The lookup function is closed over `REGISTRY` so it always
62
- // returns the current populated array (including any future user adapters).
63
- // Conductor is appended to the same array so UIs that iterate REGISTRY see it.
64
- REGISTRY.push(buildConductorAdapter(() => REGISTRY));
65
- export function availableAgents() {
66
- return REGISTRY.map((a) => ({
67
- id: a.id,
68
- label: a.label,
69
- installed: a.isInstalled(),
70
- ...(a.description ? { description: a.description } : {}),
71
- }));
72
- }
73
- /** Per-agent readiness across the full registry. Powers the setup-aware UI. */
74
- export function readinessForAllAgents() {
75
- return REGISTRY.map((a) => {
76
- const base = a.checkReadiness
77
- ? a.checkReadiness()
78
- : a.isInstalled()
79
- ? { id: a.id, label: a.label, state: 'ready', installed: true }
80
- : { id: a.id, label: a.label, state: 'needs_install', installed: false };
81
- // v0.6 S5 — mirror the adapter's "when to use me" description into the
82
- // readiness payload so the dashboard dropdown can surface it as a
83
- // plain-English hover tooltip without a separate API call.
84
- return a.description ? { ...base, description: a.description } : base;
85
- });
86
- }
87
- export function getAdapter(id) {
88
- return REGISTRY.find((a) => a.id === id);
89
- }
90
- export function allAdapters() {
91
- return REGISTRY;
92
- }
93
- export function agentProcessPatterns() {
94
- return REGISTRY.map((a) => ({
95
- regex: a.processPattern,
96
- agent: a.id,
97
- label: a.label,
98
- }));
99
- }
100
- const DEFAULT_OBSERVED_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
101
- function parseEtime(s) {
102
- // ps etime formats: "MM:SS", "HH:MM:SS", "D-HH:MM:SS"
103
- let rest = s;
104
- let days = 0;
105
- if (rest.includes('-')) {
106
- const [d, r] = rest.split('-');
107
- days = parseInt(d, 10) || 0;
108
- rest = r;
109
- }
110
- const parts = rest.split(':').map((p) => parseInt(p, 10) || 0);
111
- if (parts.length === 2)
112
- return parts[0] * 60 + parts[1];
113
- if (parts.length === 3)
114
- return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
115
- return 0;
116
- }
117
- export class SessionManager {
118
- /** Max completed sessions kept in memory. Older sessions are evicted to
119
- * prevent unbounded memory growth in the internal tracking maps. The data
120
- * survives on disk via sessions.jsonl + thread.jsonl. */
121
- static MAX_COMPLETED_SESSIONS = 100;
122
- sessions = new Map();
123
- /** Spawn order FIFO — used to evict oldest completed sessions when the cap
124
- * is reached. Pushed on spawn, referenced by evictOldCompleted(). */
125
- spawnOrder = [];
126
- /** Read-only sessions replayed from ~/.codehere/sessions.jsonl on daemon start.
127
- * These subprocesses are long-dead; we surface them so the user doesn't lose
128
- * their Final reports across restarts. */
129
- persistedCache = loadRecent(20);
130
- /** When a spawned session exits, we capture the completion timestamp here
131
- * so the API layer can show duration on still-in-memory sessions (the
132
- * TaskSession interface intentionally has no completedAt; only PersistedSession
133
- * does, so anything reading session.completedAt got undefined pre-fix).
134
- * Surfaced May 2 by the cold-start dogfood — `duration: NaNs` in /api/tasks
135
- * for completed-but-not-yet-restarted sessions. Bug 2 sister-fix. */
136
- completedAtBySession = new Map();
137
- /** v0.6.x Slack-shape spike — cwd per session. The "Continue thread" intake
138
- * button needs to find the most recent in-memory session for the current
139
- * cwd; cwd isn't on the TaskSession interface and adapters don't all expose
140
- * it, so we track it here at spawn time. */
141
- cwdBySession = new Map();
142
- /** PM-UX Layer 3 · Final Report cache (iteration 11 MVP, UI-integration iter 12).
143
- * Populated when generateFinalReport resolves in the exit handler. Web UI
144
- * reads via getFinalReport() through the /api/tasks serializer so non-technical
145
- * users see the prose summary without opening a terminal. */
146
- finalReportBySession = new Map();
147
- /** v0.6 M3 · PM Narrator. Mid-task plain-English summaries cached per
148
- * session so the API can serve the latest narration without re-running
149
- * the LLM call on every frontend poll. Latest-only — older narrations
150
- * are overwritten when a new one fires. Per claim_user_persona HIGH:
151
- * non-technical users read narration, not tool calls. */
152
- narrationBySession = new Map();
153
- narrationTimers = new Map();
154
- narrationStepsAtLastFire = new Map();
155
- /** Stall watchdog (dogfood 2026-05-27 #1): a spawned agent can hang with no
156
- * output and sit "running" forever (opencode did exactly this). We track the
157
- * timestamp of the last event from each session and, if a running session
158
- * goes silent past the stall window, kill it and record an honest reason so
159
- * the UI shows "stopped — no response", never a fake "running" or a normal row. */
160
- stallTimers = new Map();
161
- lastActivityBySession = new Map();
162
- stalledBySession = new Map();
163
- /** Stores active failover offers keyed by sessionId+target. API layer
164
- * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
165
- failoverOffers;
166
- /** Tracks the failover error code per session so acceptFailover can emit
167
- * the correct reason in the thread event. Populated by handleFailover. */
168
- failoverReasonBySession = new Map();
169
- /** Session threading — tracks which session continued as which after failover.
170
- * continuedAsBySession: original → new session ID
171
- * continuedFromBySession: new → original session ID */
172
- continuedAsBySession = new Map();
173
- continuedFromBySession = new Map();
174
- /** Final Report failure tracking — when generateFinalReport rejects, we
175
- * record it so the API can return a definitive "failed" status instead
176
- * of leaving the UI showing "Generating report…" forever. */
177
- failedReportBySession = new Set();
178
- spawn(prompt, agentId = 'claude-code', options) {
179
- const adapter = getAdapter(agentId);
180
- if (!adapter) {
181
- throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
182
- }
183
- // CODEHERE_MOCK spawns a synthetic agent (mock REPL), so the real CLI need
184
- // not be installed — bypass the readiness gate. Makes the mock harness truly
185
- // zero-dependency (dogfood/dev without any agent CLI installed).
186
- const mockMode = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
187
- if (!mockMode && !adapter.isInstalled()) {
188
- throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
189
- }
190
- // Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
191
- // the FAQ now claims as "v0.5.x → v0.6 track": every adapter — not
192
- // just claude-code — gets a safety scan on the user's prompt before
193
- // spawn. Catches API-key paste, prompt-injection patterns, etc.
194
- // Always writes one audit entry (synthetic toolName 'pre_prompt');
195
- // refuses spawn ONLY when getHookMode() === 'block' AND the prompt
196
- // has critical/high findings. Other modes (advisory/log-only/off)
197
- // log + proceed.
198
- const preScanSessionId = randomUUID();
199
- const scan = scanPromptBeforeSpawn({
200
- sessionId: preScanSessionId,
201
- agent: agentId,
202
- prompt,
203
- cwd: options?.cwd,
204
- });
205
- if (!scan.allowed) {
206
- throw new Error(scan.blockReason || 'pre-prompt scan blocked the spawn');
207
- }
208
- // P1 — graphify-ts structural memory: enrich prompt with compact context
209
- // pack so the agent starts with a verified knowledge graph of the workspace.
210
- // Respects CODEHERE_GRAPHIFY env var (auto|force|off, default=auto).
211
- // Synchronous call — degrades to original prompt if graphify fails.
212
- const cwd = options?.cwd ?? process.cwd();
213
- const enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
214
- const session = adapter.spawn(enrichedPrompt, options);
215
- this.sessions.set(session.sessionId, session);
216
- this.spawnOrder.push(session.sessionId);
217
- // Persistence: write the spawned record immediately so a daemon crash still
218
- // leaves an audit trail of what was attempted. Subscribe to exit so we can
219
- // capture the final status + steps + artifacts when the session settles.
220
- this.cwdBySession.set(session.sessionId, cwd);
221
- appendSpawned({
222
- id: session.sessionId,
223
- agent: session.agent,
224
- prompt,
225
- cwd,
226
- startedAt: session.startedAt,
227
- });
228
- // v0.6.0 M1+M7 — forward-mirror to per-project thread.jsonl alongside
229
- // sessions.jsonl. Best-effort; mirror failures don't break the spawn path.
230
- mirrorSpawnedToThread({
231
- sessionId: session.sessionId,
232
- agent: session.agent,
233
- prompt,
234
- cwd,
235
- startedAt: session.startedAt,
236
- });
237
- this.lastActivityBySession.set(session.sessionId, Date.now());
238
- const unsubscribe = session.subscribe((event) => {
239
- // Any event = the agent is alive. Reset the stall clock. (watchdog)
240
- this.lastActivityBySession.set(session.sessionId, Date.now());
241
- // ── Failover check — intercept rate-limit / quota errors before exit ──
242
- // Adapters emit 'codehere:error' (structured: { code, message, recovery })
243
- // or 'error' (spawn-level: { message }). Both need classification.
244
- if (event.type === 'error' || event.type === 'codehere:error') {
245
- const err = event.data;
246
- if (err?.code && err?.message) {
247
- const signal = classifyFailoverEligibility(session.agent, err.code, err.message);
248
- if (signal) {
249
- const newSession = this.handleFailover(session, signal);
250
- if (newSession)
251
- return; // auto-spawned — keep listening on new session
252
- }
253
- }
254
- }
255
- if (event.type !== 'exit')
256
- return;
257
- const data = event.data;
258
- const completedAt = Date.now();
259
- // Surface completedAt to API consumers reading the still-in-memory
260
- // session (Bug 2 sister-fix, May 2). Without this, /api/tasks shows
261
- // status=completed but no completedAt → NaN duration in the UI.
262
- this.completedAtBySession.set(session.sessionId, completedAt);
263
- appendCompleted({
264
- id: session.sessionId,
265
- status: session.status,
266
- durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
267
- completedAt,
268
- steps: session.steps ? [...session.steps] : undefined,
269
- artifacts: session.artifacts,
270
- stalledReason: this.stalledBySession.get(session.sessionId)?.reason,
271
- });
272
- // v0.6.0 M1+M7 — forward-mirror completion to project thread.
273
- mirrorCompletedToThread({
274
- sessionId: session.sessionId,
275
- cwd,
276
- status: session.status,
277
- durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
278
- completedAt,
279
- steps: session.steps ? [...session.steps] : undefined,
280
- artifacts: session.artifacts,
281
- });
282
- // PM-UX Layer 3 · Final Report MVP. Fire-and-forget; the report appears
283
- // in the terminal a few seconds after exit. Failure modes return null
284
- // silently (claude CLI not installed, timeout, non-zero exit). No SSE,
285
- // no UI integration in this MVP — terminal output only so the dogfood
286
- // loop sees the artifact immediately. See `generate-final-report.ts`.
287
- const reportInput = {
288
- sessionId: session.sessionId,
289
- prompt: session.prompt,
290
- agent: session.agent,
291
- status: session.status,
292
- durationMs: completedAt - session.startedAt,
293
- steps: session.steps ? [...session.steps] : [],
294
- artifacts: session.artifacts,
295
- };
296
- generateFinalReport(reportInput)
297
- .then((report) => {
298
- if (report) {
299
- // Cache for web UI consumption via /api/tasks (iter 12 — non-technical
300
- // users live in the web UI, not the terminal).
301
- this.finalReportBySession.set(session.sessionId, report);
302
- // v0.6.x — persist to disk so the report survives daemon restart.
303
- // Pre-fix: founder's Mac restart 2026-05-03 left a session card
304
- // showing "Generating report…" forever because reports lived only
305
- // in this in-memory Map. Append-only finalReport record paired
306
- // with the existing spawned/completed records by sessionId.
307
- appendFinalReport({
308
- id: session.sessionId,
309
- summary: report.summary,
310
- generatedAt: report.generatedAt,
311
- durationMs: report.durationMs,
312
- source: report.source,
313
- });
314
- // Shared-room hand-off: record this turn's outcome to the project's
315
- // working memory so the NEXT agent spawned here reads it (closes the
316
- // loop with applyMemoryPrefix). Best-effort; only initialized projects.
317
- try {
318
- recordTurnToWorkingMemory(cwd, {
319
- agent: session.agent,
320
- prompt: session.prompt,
321
- summary: report.summary,
322
- files: session.artifacts?.files?.length,
323
- });
324
- }
325
- catch {
326
- /* memory write must never break the run */
327
- }
328
- try {
329
- printFinalReportToTerminal(reportInput, report);
330
- }
331
- catch {
332
- // Don't let a print failure crash the daemon.
333
- }
334
- }
335
- })
336
- .catch(() => {
337
- // generateFinalReport rejected — record it so the UI can show
338
- // "Report unavailable" instead of "Generating report…" forever.
339
- this.failedReportBySession.add(session.sessionId);
340
- });
341
- // P1 — persist context pack at session exit for cross-session handoff (P2).
342
- // Fire-and-forget: graphify-ts may not be installed, graph may be empty,
343
- // or pack compilation may fail. All degrade silently.
344
- void Promise.resolve().then(() => {
345
- try {
346
- const stepsStr = session.steps
347
- ?.map((s) => `${s.label}: ${s.output || s.state}`)
348
- .filter(Boolean)
349
- .join('\n') ?? '';
350
- const pack = compileSessionPackSync(cwd, {
351
- prompt: session.prompt,
352
- artifacts: JSON.stringify(session.artifacts, null, 2),
353
- steps: stepsStr,
354
- agent: session.agent,
355
- });
356
- if (pack)
357
- persistContextPack(pack, cwd);
358
- }
359
- catch {
360
- // Silent degradation — context pack is enrichment, not the primary path.
361
- }
362
- });
363
- // v0.6 M3 — clean up the narration timer for this session.
364
- const t = this.narrationTimers.get(session.sessionId);
365
- if (t) {
366
- clearInterval(t);
367
- this.narrationTimers.delete(session.sessionId);
368
- }
369
- // Watchdog: session settled on its own — stop watching it.
370
- this.clearStallTimer(session.sessionId);
371
- this.evictOldCompleted();
372
- unsubscribe();
373
- });
374
- // v0.6 M3 — PM Narrator. Mid-task plain-English summaries fired
375
- // every 30s while the session is running. Heuristic: only fire if
376
- // the step count grew by ≥3 since the last narration (otherwise
377
- // we'd narrate the same state with new words). Fire-and-forget;
378
- // failures (no claude binary, timeout, malformed output) silently
379
- // skip — narration is enrichment, not the primary surface.
380
- this.startNarrationTimer(session);
381
- this.startStallWatchdog(session);
382
- return session;
383
- }
384
- /** Tracks which agents have already been tried for a given prompt during
385
- * a failover chain. Maps sessionId → set of agent ids already attempted. */
386
- failoverTriedBySession = new Map();
387
- /** v0.6.x failover — emit a failover offer when an adapter hits a
388
- * rate-limit / quota error. The offer is surfaced via the API layer
389
- * (Slice E) and optionally auto-spawned (Slice D). Returns the new
390
- * session if auto-spawn was triggered, undefined otherwise. */
391
- handleFailover(session, signal) {
392
- const config = loadFailoverConfig();
393
- const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
394
- tried.add(signal.fromAgent);
395
- this.failoverTriedBySession.set(session.sessionId, tried);
396
- const adapters = allAdapters();
397
- const available = toAvailability(adapters, (a) => {
398
- return a.id !== signal.fromAgent && a.isInstalled();
399
- });
400
- const target = resolveFailoverTarget({
401
- failedAgent: signal.fromAgent,
402
- alreadyTried: Array.from(tried),
403
- available,
404
- preferredChain: config?.chain,
405
- });
406
- if (!target)
407
- return undefined;
408
- const targetAdapter = getAdapter(target);
409
- if (!targetAdapter || !targetAdapter.isInstalled())
410
- return undefined;
411
- // Auto-failover: spawn on the next agent in the chain
412
- const autoFailover = config?.autoFailover ?? true;
413
- if (autoFailover) {
414
- // Spawn replacement session on the next agent, preserving prompt + cwd
415
- const cwd = this.cwdBySession.get(session.sessionId) ?? process.cwd();
416
- // P2 — build handoff context for continuity across agent swap
417
- const handoff = buildFailoverHandoff(cwd, {
418
- failedAgent: session.agent,
419
- failedPrompt: session.prompt,
420
- });
421
- // Enrich prompt with handoff if available (P2)
422
- const promptWithHandoff = handoff
423
- ? enrichPromptWithHandoff(session.prompt, {
424
- packId: handoff.packId,
425
- agent: session.agent,
426
- prompt: session.prompt,
427
- status: 'failed',
428
- timestamp: handoff.timestamp,
429
- nodeCount: handoff.nodeCount,
430
- edgeCount: handoff.edgeCount,
431
- task: handoff.task,
432
- payloadPreview: handoff.payloadPreview,
433
- })
434
- : session.prompt;
435
- try {
436
- const newSession = targetAdapter.spawn(promptWithHandoff, {
437
- cwd,
438
- env: process.env,
439
- preferSubscription: true,
440
- });
441
- // Track this new session in the same way
442
- this.sessions.set(newSession.sessionId, newSession);
443
- this.spawnOrder.push(newSession.sessionId);
444
- this.cwdBySession.set(newSession.sessionId, cwd);
445
- this.lastActivityBySession.set(newSession.sessionId, Date.now());
446
- // Subscribe BEFORE persistence so exit events are never lost if the
447
- // adapter exits synchronously (command not found, instant crash, etc).
448
- const unsubscribe = newSession.subscribe((event) => {
449
- this.lastActivityBySession.set(newSession.sessionId, Date.now());
450
- if (event.type !== 'exit')
451
- return;
452
- this.clearStallTimer(newSession.sessionId);
453
- const data = event.data;
454
- const completedAt = Date.now();
455
- this.completedAtBySession.set(newSession.sessionId, completedAt);
456
- appendCompleted({
457
- id: newSession.sessionId,
458
- status: newSession.status,
459
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
460
- completedAt,
461
- steps: newSession.steps ? [...newSession.steps] : undefined,
462
- artifacts: newSession.artifacts,
463
- stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
464
- });
465
- mirrorCompletedToThread({
466
- sessionId: newSession.sessionId,
467
- cwd,
468
- status: newSession.status,
469
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
470
- completedAt,
471
- steps: newSession.steps ? [...newSession.steps] : undefined,
472
- artifacts: newSession.artifacts,
473
- });
474
- const reportInput = {
475
- sessionId: newSession.sessionId,
476
- prompt: newSession.prompt,
477
- agent: newSession.agent,
478
- status: newSession.status,
479
- durationMs: completedAt - newSession.startedAt,
480
- steps: newSession.steps ? [...newSession.steps] : [],
481
- artifacts: newSession.artifacts,
482
- };
483
- generateFinalReport(reportInput)
484
- .then((report) => {
485
- if (report) {
486
- this.finalReportBySession.set(newSession.sessionId, report);
487
- appendFinalReport({
488
- id: newSession.sessionId,
489
- summary: report.summary,
490
- generatedAt: report.generatedAt,
491
- durationMs: report.durationMs,
492
- source: report.source,
493
- });
494
- try {
495
- printFinalReportToTerminal(reportInput, report);
496
- }
497
- catch {
498
- /* defensive */
499
- }
500
- }
501
- })
502
- .catch(() => {
503
- /* defensive */
504
- });
505
- // P1 — persist context pack at failover-replacement session exit.
506
- void Promise.resolve().then(() => {
507
- try {
508
- const stepsStr = newSession.steps
509
- ?.map((s) => `${s.label}: ${s.output || s.state}`)
510
- .filter(Boolean)
511
- .join('\n') ?? '';
512
- const pack = compileSessionPackSync(cwd, {
513
- prompt: newSession.prompt,
514
- artifacts: JSON.stringify(newSession.artifacts, null, 2),
515
- steps: stepsStr,
516
- agent: newSession.agent,
517
- });
518
- if (pack)
519
- persistContextPack(pack, cwd);
520
- }
521
- catch {
522
- // Silent degradation — context pack is enrichment, not primary path.
523
- }
524
- });
525
- const t = this.narrationTimers.get(newSession.sessionId);
526
- if (t) {
527
- clearInterval(t);
528
- this.narrationTimers.delete(newSession.sessionId);
529
- }
530
- this.evictOldCompleted();
531
- unsubscribe();
532
- });
533
- // Persistence + threading — safe after subscribe because the exit
534
- // handler is already registered. These can run even if the session
535
- // already exited (spawned record still useful, exit handler handles
536
- // the completion).
537
- appendSpawned({
538
- id: newSession.sessionId,
539
- agent: newSession.agent,
540
- prompt: promptWithHandoff,
541
- cwd,
542
- startedAt: newSession.startedAt,
543
- });
544
- mirrorSpawnedToThread({
545
- sessionId: newSession.sessionId,
546
- agent: newSession.agent,
547
- prompt: promptWithHandoff,
548
- cwd,
549
- startedAt: newSession.startedAt,
550
- });
551
- mirrorFailoverToThread({
552
- sessionId: newSession.sessionId,
553
- fromAgent: signal.fromAgent,
554
- toAgent: target,
555
- reason: signal.errorCode,
556
- cwd,
557
- });
558
- this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
559
- this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
560
- this.startNarrationTimer(newSession);
561
- this.startStallWatchdog(newSession);
562
- return newSession;
563
- }
564
- catch {
565
- // Spawn failed — fall through to offer-only mode
566
- return undefined;
567
- }
568
- }
569
- // Offer-only mode: signal the API layer to present a failover banner
570
- // (Slice E handles the actual UI surfacing)
571
- this.emitFailoverOffer({ session, signal, target });
572
- this.failoverReasonBySession.set(session.sessionId, signal.errorCode);
573
- return undefined;
574
- }
575
- /** Emitted when a failover offer is generated. The API layer listens
576
- * to this to surface the banner in the web UI. */
577
- emitFailoverOffer(offer) {
578
- if (!this.failoverOffers) {
579
- this.failoverOffers = new Map();
580
- }
581
- const key = `${offer.session.sessionId}-${offer.target}`;
582
- this.failoverOffers.set(key, { offer, createdAt: Date.now() });
583
- // Auto-expire offers after 60 seconds to keep the map bounded
584
- setTimeout(() => {
585
- this.failoverOffers?.delete(key);
586
- }, 60_000);
587
- }
588
- /** Get all active failover offers for the API layer. */
589
- getFailoverOffers() {
590
- if (!this.failoverOffers)
591
- return [];
592
- const offers = [];
593
- for (const [, { offer, createdAt }] of this.failoverOffers) {
594
- offers.push({
595
- sessionId: offer.session.sessionId,
596
- signal: offer.signal,
597
- target: offer.target,
598
- expiresAt: createdAt + 60_000,
599
- });
600
- }
601
- return offers;
602
- }
603
- /** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
604
- * session on the target agent, preserving the original prompt + cwd.
605
- * P2 — injects handoff context for cross-agent continuity.
606
- * Returns the new session or undefined if the target is unavailable. */
607
- acceptFailover(sessionId, targetAgent) {
608
- const session = this.sessions.get(sessionId);
609
- if (!session)
610
- return undefined;
611
- const targetAdapter = getAdapter(targetAgent);
612
- if (!targetAdapter || !targetAdapter.isInstalled())
613
- return undefined;
614
- const cwd = this.cwdBySession.get(sessionId) ?? process.cwd();
615
- // P2 — build handoff context for continuity across agent swap
616
- const handoff = buildFailoverHandoff(cwd, {
617
- failedAgent: session.agent,
618
- failedPrompt: session.prompt,
619
- });
620
- const promptWithHandoff = handoff
621
- ? enrichPromptWithHandoff(session.prompt, {
622
- packId: handoff.packId,
623
- agent: session.agent,
624
- prompt: session.prompt,
625
- status: 'failed',
626
- timestamp: handoff.timestamp,
627
- nodeCount: handoff.nodeCount,
628
- edgeCount: handoff.edgeCount,
629
- task: handoff.task,
630
- payloadPreview: handoff.payloadPreview,
631
- })
632
- : session.prompt;
633
- const newSession = targetAdapter.spawn(promptWithHandoff, {
634
- cwd,
635
- env: process.env,
636
- preferSubscription: true,
637
- });
638
- // Track the new session
639
- this.sessions.set(newSession.sessionId, newSession);
640
- this.spawnOrder.push(newSession.sessionId);
641
- this.cwdBySession.set(newSession.sessionId, cwd);
642
- this.lastActivityBySession.set(newSession.sessionId, Date.now());
643
- // Subscribe BEFORE persistence so exit events are never lost if the
644
- // adapter exits synchronously.
645
- const unsubscribe = newSession.subscribe((event) => {
646
- this.lastActivityBySession.set(newSession.sessionId, Date.now());
647
- if (event.type !== 'exit')
648
- return;
649
- this.clearStallTimer(newSession.sessionId);
650
- const data = event.data;
651
- const completedAt = Date.now();
652
- this.completedAtBySession.set(newSession.sessionId, completedAt);
653
- appendCompleted({
654
- id: newSession.sessionId,
655
- status: newSession.status,
656
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
657
- completedAt,
658
- steps: newSession.steps ? [...newSession.steps] : undefined,
659
- artifacts: newSession.artifacts,
660
- stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
661
- });
662
- mirrorCompletedToThread({
663
- sessionId: newSession.sessionId,
664
- cwd,
665
- status: newSession.status,
666
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
667
- completedAt,
668
- steps: newSession.steps ? [...newSession.steps] : undefined,
669
- artifacts: newSession.artifacts,
670
- });
671
- const reportInput = {
672
- sessionId: newSession.sessionId,
673
- prompt: newSession.prompt,
674
- agent: newSession.agent,
675
- status: newSession.status,
676
- durationMs: completedAt - newSession.startedAt,
677
- steps: newSession.steps ? [...newSession.steps] : [],
678
- artifacts: newSession.artifacts,
679
- };
680
- generateFinalReport(reportInput)
681
- .then((report) => {
682
- if (report) {
683
- this.finalReportBySession.set(newSession.sessionId, report);
684
- appendFinalReport({
685
- id: newSession.sessionId,
686
- summary: report.summary,
687
- generatedAt: report.generatedAt,
688
- durationMs: report.durationMs,
689
- source: report.source,
690
- });
691
- try {
692
- printFinalReportToTerminal(reportInput, report);
693
- }
694
- catch {
695
- /* defensive */
696
- }
697
- }
698
- })
699
- .catch(() => {
700
- /* defensive */
701
- });
702
- // P1 — persist context pack at accept-failover replacement session exit.
703
- void Promise.resolve().then(() => {
704
- try {
705
- const stepsStr = newSession.steps
706
- ?.map((s) => `${s.label}: ${s.output || s.state}`)
707
- .filter(Boolean)
708
- .join('\n') ?? '';
709
- const pack = compileSessionPackSync(cwd, {
710
- prompt: newSession.prompt,
711
- artifacts: JSON.stringify(newSession.artifacts, null, 2),
712
- steps: stepsStr,
713
- agent: newSession.agent,
714
- });
715
- if (pack)
716
- persistContextPack(pack, cwd);
717
- }
718
- catch {
719
- // Silent degradation — context pack is enrichment, not primary path.
720
- }
721
- });
722
- const t = this.narrationTimers.get(newSession.sessionId);
723
- if (t) {
724
- clearInterval(t);
725
- this.narrationTimers.delete(newSession.sessionId);
726
- }
727
- this.evictOldCompleted();
728
- unsubscribe();
729
- });
730
- // Persistence + threading — safe after subscribe because the exit
731
- // handler is already registered.
732
- this.continuedAsBySession.set(sessionId, newSession.sessionId);
733
- this.continuedFromBySession.set(newSession.sessionId, sessionId);
734
- appendSpawned({
735
- id: newSession.sessionId,
736
- agent: newSession.agent,
737
- prompt: promptWithHandoff,
738
- cwd,
739
- startedAt: newSession.startedAt,
740
- });
741
- mirrorSpawnedToThread({
742
- sessionId: newSession.sessionId,
743
- agent: newSession.agent,
744
- prompt: promptWithHandoff,
745
- cwd,
746
- startedAt: newSession.startedAt,
747
- });
748
- mirrorFailoverToThread({
749
- sessionId: newSession.sessionId,
750
- fromAgent: session.agent,
751
- toAgent: targetAgent,
752
- reason: this.failoverReasonBySession.get(session.sessionId) ?? 'error',
753
- cwd,
754
- });
755
- this.startNarrationTimer(newSession);
756
- this.startStallWatchdog(newSession);
757
- return newSession;
758
- }
759
- /** v0.6 M3 — periodic narration loop per session. */
760
- startNarrationTimer(session) {
761
- if (process.env.CODEHERE_DISABLE_NARRATOR === '1')
762
- return;
763
- this.narrationStepsAtLastFire.set(session.sessionId, 0);
764
- const intervalMs = Number(process.env.CODEHERE_NARRATOR_INTERVAL_MS ?? 30_000);
765
- const minStepDelta = Number(process.env.CODEHERE_NARRATOR_MIN_STEPS ?? 3);
766
- const timer = setInterval(() => {
767
- // Skip if session has settled.
768
- if (session.status !== 'running')
769
- return;
770
- const steps = session.steps ?? [];
771
- const lastFireSteps = this.narrationStepsAtLastFire.get(session.sessionId) ?? 0;
772
- if (steps.length - lastFireSteps < minStepDelta)
773
- return;
774
- // Mark BEFORE the async call to prevent double-firing if the LLM
775
- // is slow.
776
- this.narrationStepsAtLastFire.set(session.sessionId, steps.length);
777
- const elapsedMs = Date.now() - session.startedAt;
778
- void generateNarration({
779
- sessionId: session.sessionId,
780
- prompt: session.prompt,
781
- agent: session.agent,
782
- elapsedMs,
783
- steps: [...steps],
784
- })
785
- .then((out) => {
786
- if (out)
787
- this.narrationBySession.set(session.sessionId, out);
788
- })
789
- .catch(() => {
790
- /* defensive — generateNarration never throws */
791
- });
792
- }, intervalMs);
793
- this.narrationTimers.set(session.sessionId, timer);
794
- }
795
- /** Stall watchdog (dogfood 2026-05-27 #1). Polls a running session; if no
796
- * event has arrived within the stall window, kills it and records an honest
797
- * reason. Cross-agent — opencode hung, but any adapter can. Timings are
798
- * env-overridable so the mock harness + tests can drive it fast. Disable
799
- * entirely with CODEHERE_DISABLE_WATCHDOG=1. */
800
- startStallWatchdog(session) {
801
- if (process.env.CODEHERE_DISABLE_WATCHDOG === '1')
802
- return;
803
- const stallMs = Number(process.env.CODEHERE_STALL_TIMEOUT_MS ?? 180_000);
804
- const pollMs = Number(process.env.CODEHERE_STALL_POLL_MS ?? 15_000);
805
- if (!(stallMs > 0) || !(pollMs > 0))
806
- return;
807
- const timer = setInterval(() => {
808
- if (session.status !== 'running') {
809
- this.clearStallTimer(session.sessionId);
810
- return;
811
- }
812
- const last = this.lastActivityBySession.get(session.sessionId) ?? session.startedAt;
813
- const silentMs = Date.now() - last;
814
- if (silentMs < stallMs)
815
- return;
816
- const reason = `Stopped after no response for ${Math.round(silentMs / 1000)}s — the agent hung without finishing.`;
817
- this.stalledBySession.set(session.sessionId, { reason, stalledAt: Date.now() });
818
- this.clearStallTimer(session.sessionId);
819
- try {
820
- session.kill();
821
- }
822
- catch {
823
- // kill is best-effort; the recorded stall reason + 'killed' status is
824
- // what the UI reads, so a kill that throws still surfaces honestly.
825
- }
826
- }, pollMs);
827
- this.stallTimers.set(session.sessionId, timer);
828
- }
829
- clearStallTimer(sessionId) {
830
- const t = this.stallTimers.get(sessionId);
831
- if (t) {
832
- clearInterval(t);
833
- this.stallTimers.delete(sessionId);
834
- }
835
- }
836
- /** Watchdog verdict for a session — the honest "why it stopped" reason when
837
- * the stall watchdog killed it, or undefined if it settled on its own.
838
- * The API layer surfaces this so the dashboard never shows a hung task as a
839
- * normal row. */
840
- getStallReason(sessionId) {
841
- return this.stalledBySession.get(sessionId)?.reason;
842
- }
843
- /** v0.6 M3 — latest narration for a session, or undefined. */
844
- getNarration(sessionId) {
845
- return this.narrationBySession.get(sessionId);
846
- }
847
- /** Persisted (read-only) sessions from prior daemon runs. Cached at boot. */
848
- listPersisted() {
849
- return this.persistedCache;
850
- }
851
- /** Completion timestamp for an in-memory session, if it has exited.
852
- * Returns undefined if the session is still running OR was never spawned
853
- * through this manager. Bug 2 sister-fix (May 2): TaskSession itself has
854
- * no completedAt field; this method lets the API layer compute durations. */
855
- getCompletedAt(sessionId) {
856
- return this.completedAtBySession.get(sessionId);
857
- }
858
- /** v0.6.x Slack-shape spike — return cwd for an in-memory session. */
859
- getCwd(sessionId) {
860
- return this.cwdBySession.get(sessionId);
861
- }
862
- /** Session threading — which session did this one continue from (after failover)? */
863
- getContinuedFrom(sessionId) {
864
- return this.continuedFromBySession.get(sessionId);
865
- }
866
- /** Session threading — which session did this one continue as (after failover)? */
867
- getContinuedAs(sessionId) {
868
- return this.continuedAsBySession.get(sessionId);
869
- }
870
- /** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
871
- * Returns undefined while generation is in flight OR if the provider chain
872
- * failed (no claude-free, no claude, no credit, etc). The web UI reads this
873
- * through the /api/tasks serializer to render the prose summary inline. */
874
- getFinalReport(sessionId) {
875
- return this.finalReportBySession.get(sessionId);
876
- }
877
- /** Returns true if Final Report generation explicitly failed for this session.
878
- * Lets the API distinguish "not yet" from "never coming". */
879
- hasFailedReport(sessionId) {
880
- return this.failedReportBySession.has(sessionId);
881
- }
882
- get(sessionId) {
883
- return this.sessions.get(sessionId);
884
- }
885
- /** Evict the oldest completed sessions when the in-memory cap is reached.
886
- * Prevents unbounded memory growth in the internal tracking maps. The
887
- * data survives on disk via sessions.jsonl + thread.jsonl. */
888
- evictOldCompleted() {
889
- const completed = [];
890
- for (const id of this.spawnOrder) {
891
- const at = this.completedAtBySession.get(id);
892
- if (at !== undefined)
893
- completed.push({ id, completedAt: at });
894
- }
895
- if (completed.length <= SessionManager.MAX_COMPLETED_SESSIONS)
896
- return;
897
- const toEvict = completed.slice(0, completed.length - SessionManager.MAX_COMPLETED_SESSIONS);
898
- const evictSet = new Set(toEvict.map((e) => e.id));
899
- for (const id of evictSet) {
900
- const t = this.narrationTimers.get(id);
901
- if (t) {
902
- clearInterval(t);
903
- this.narrationTimers.delete(id);
904
- }
905
- this.clearStallTimer(id);
906
- this.lastActivityBySession.delete(id);
907
- this.stalledBySession.delete(id);
908
- this.sessions.delete(id);
909
- this.completedAtBySession.delete(id);
910
- this.cwdBySession.delete(id);
911
- this.finalReportBySession.delete(id);
912
- this.narrationBySession.delete(id);
913
- this.failoverReasonBySession.delete(id);
914
- this.continuedAsBySession.delete(id);
915
- this.continuedFromBySession.delete(id);
916
- this.failedReportBySession.delete(id);
917
- this.failoverTriedBySession.delete(id);
918
- }
919
- }
920
- list() {
921
- return Array.from(this.sessions.values());
922
- }
923
- kill(sessionId) {
924
- const session = this.sessions.get(sessionId);
925
- if (!session)
926
- return false;
927
- session.kill();
928
- return true;
929
- }
930
- /** Graceful shutdown: kill all running sessions and clear all timers. */
931
- shutdown() {
932
- for (const [, timer] of this.narrationTimers) {
933
- clearInterval(timer);
934
- }
935
- this.narrationTimers.clear();
936
- for (const [, timer] of this.stallTimers) {
937
- clearInterval(timer);
938
- }
939
- this.stallTimers.clear();
940
- for (const session of this.sessions.values()) {
941
- if (session.status === 'running')
942
- session.kill();
943
- }
944
- }
945
- /**
946
- * Active Claude Code sessions on this machine, derived from the audit log.
947
- * A session is "active" if any tool call from it was recorded within the
948
- * last `maxAgeMs` milliseconds. Returns most-recent first.
949
- *
950
- * Note: codehere-spawned sessions may also appear here because they produce
951
- * audit entries just like any other Claude Code session. The UI distinguishes
952
- * by provenance.
953
- */
954
- listObserved(maxAgeMs = DEFAULT_OBSERVED_WINDOW_MS) {
955
- if (!existsSync(AUDIT_FILE))
956
- return [];
957
- const now = Date.now();
958
- const cutoff = now - maxAgeMs;
959
- const byId = new Map();
960
- let content;
961
- try {
962
- content = readFileSync(AUDIT_FILE, 'utf-8');
963
- }
964
- catch {
965
- return [];
966
- }
967
- const lines = content.split('\n');
968
- // Walk backwards — most recent lines first — and stop early when we
969
- // pass the window. Audit log appends chronologically.
970
- for (let i = lines.length - 1; i >= 0; i--) {
971
- const line = lines[i].trim();
972
- if (!line)
973
- continue;
974
- let entry;
975
- try {
976
- entry = JSON.parse(line);
977
- }
978
- catch {
979
- continue;
980
- }
981
- if (!entry.sessionId)
982
- continue;
983
- const ts = typeof entry.timestamp === 'number'
984
- ? entry.timestamp
985
- : entry.timestamp
986
- ? Date.parse(String(entry.timestamp))
987
- : 0;
988
- if (!ts)
989
- continue;
990
- if (ts < cutoff)
991
- break; // past the window; older entries can't qualify
992
- const existing = byId.get(entry.sessionId);
993
- if (existing) {
994
- existing.eventCount += 1;
995
- if (ts < existing.firstEventAt)
996
- existing.firstEventAt = ts;
997
- }
998
- else {
999
- byId.set(entry.sessionId, {
1000
- sessionId: entry.sessionId,
1001
- firstEventAt: ts,
1002
- lastEventAt: ts,
1003
- eventCount: 1,
1004
- recentTool: entry.toolName,
1005
- provenance: 'observed',
1006
- });
1007
- }
1008
- }
1009
- return Array.from(byId.values()).sort((a, b) => b.lastEventAt - a.lastEventAt);
1010
- }
1011
- /**
1012
- * Agent processes running on this machine right now.
1013
- * Enumerates via `ps`, filters to known agent executables, returns most
1014
- * recently started first. This is the primary F1d surface — it reflects
1015
- * "what AI agents are alive on your machine" independent of whether
1016
- * they've produced audit events yet.
1017
- */
1018
- listActiveAgents() {
1019
- let out;
1020
- try {
1021
- out = execFileSync('ps', ['-A', '-o', 'pid,tty,etime,args'], {
1022
- encoding: 'utf-8',
1023
- timeout: 2000,
1024
- });
1025
- }
1026
- catch {
1027
- return [];
1028
- }
1029
- const agents = [];
1030
- const lines = out.trim().split('\n').slice(1); // drop header
1031
- for (const line of lines) {
1032
- const m = line.trim().match(/^(\d+)\s+(\S+)\s+(\S+)\s+(.+)$/);
1033
- if (!m)
1034
- continue;
1035
- const [, pidStr, tty, etime, args] = m;
1036
- // Skip ourselves — the codehere daemon would otherwise show up repeatedly
1037
- if (/\bcodehere\b|\bch\s+start\b/.test(args))
1038
- continue;
1039
- // Skip the audit tailer from other codehere commands
1040
- if (/scan-tool|codehere-audit/.test(args))
1041
- continue;
1042
- let matched = null;
1043
- for (const p of agentProcessPatterns()) {
1044
- if (p.regex.test(args)) {
1045
- matched = { agent: p.agent, label: p.label };
1046
- break;
1047
- }
1048
- }
1049
- if (!matched)
1050
- continue;
1051
- const uptimeSeconds = parseEtime(etime);
1052
- const startedAt = Date.now() - uptimeSeconds * 1000;
1053
- agents.push({
1054
- pid: parseInt(pidStr, 10),
1055
- tty,
1056
- agent: matched.agent,
1057
- label: matched.label,
1058
- args: args.length > 200 ? args.slice(0, 200) + '…' : args,
1059
- startedAt,
1060
- uptimeSeconds,
1061
- provenance: 'detected',
1062
- });
1063
- }
1064
- return agents.sort((a, b) => b.startedAt - a.startedAt);
1065
- }
1066
- static _instance;
1067
- static getInstance() {
1068
- return (this._instance ??= new SessionManager());
1069
- }
1070
- /** @internal Resets the singleton for test isolation. */
1071
- static __testOnlyReset() {
1072
- this._instance = undefined;
1073
- }
1074
- }
1075
- // ── Test seam ─────────────────────────────────────────────────────────────────
1076
- // Avoids refactoring the module-level REGISTRY const to DI. Usage:
1077
- // import { __testOnlyRegisterAdapter, SessionManager } from '...';
1078
- // __testOnlyRegisterAdapter(mockAdapter);
1079
- // SessionManager.__testOnlyReset();
1080
- // const sm = SessionManager.getInstance();
1081
- /** @internal Exported for testing only — allows mock adapter injection. */
1082
- export function __testOnlyRegisterAdapter(adapter) {
1083
- REGISTRY.push(adapter);
1084
- }
1085
- /** @internal Resets REGISTRY to BUILTIN_REGISTRY for test isolation.
1086
- * Call before __testOnlyRegisterAdapter to avoid adapter accumulation. */
1087
- export function __testOnlyResetRegistry() {
1088
- REGISTRY.length = 0;
1089
- REGISTRY.push(...BUILTIN_REGISTRY);
1090
- }
1091
- //# sourceMappingURL=session-manager.js.map