codehere 1.0.1 → 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 (163) hide show
  1. package/README.md +36 -192
  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 +110 -7
  5. package/dist/index.js +38 -1212
  6. package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -323
  7. package/dist/presentation/cli/commands/check-command.js +178 -23
  8. package/dist/presentation/cli/commands/help-command.js +21 -285
  9. package/dist/ui.js +5 -236
  10. package/dist/utils/version.js +0 -6
  11. package/package.json +9 -38
  12. package/dist/application/services/verdict-service.js +0 -154
  13. package/dist/application/use-cases/assess-cross-vendor.js +0 -126
  14. package/dist/application/use-cases/build-cross-vendor-verdict.js +0 -118
  15. package/dist/application/use-cases/domain-trust.js +0 -110
  16. package/dist/application/use-cases/export-aat.js +0 -107
  17. package/dist/application/use-cases/generate-acceptance-criteria.js +0 -223
  18. package/dist/application/use-cases/generate-final-report.js +0 -259
  19. package/dist/application/use-cases/generate-narration.js +0 -130
  20. package/dist/application/use-cases/generate-thread-title.js +0 -181
  21. package/dist/application/use-cases/narrator-prompt-template.js +0 -158
  22. package/dist/application/use-cases/plan-auto-review.js +0 -57
  23. package/dist/application/use-cases/reconcile-audit.js +0 -100
  24. package/dist/application/use-cases/search-sessions.js +0 -70
  25. package/dist/application/use-cases/should-auto-review.js +0 -71
  26. package/dist/application/use-cases/thread-export.js +0 -245
  27. package/dist/application/use-cases/verify-claim.js +0 -456
  28. package/dist/application/use-cases/write-pass.js +0 -277
  29. package/dist/domain/audit.js +0 -11
  30. package/dist/domain/services/codebase-detector.js +0 -85
  31. package/dist/domain/task.js +0 -91
  32. package/dist/error-handler.js +0 -139
  33. package/dist/infrastructure/completion/bash-completion.js +0 -29
  34. package/dist/infrastructure/completion/fish-completion.js +0 -17
  35. package/dist/infrastructure/completion/zsh-completion.js +0 -25
  36. package/dist/infrastructure/config/config-manager.js +0 -174
  37. package/dist/infrastructure/config/product-context.js +0 -49
  38. package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
  39. package/dist/infrastructure/context/adapters/figma-source.js +0 -198
  40. package/dist/infrastructure/context/adapters/github-issue-source.js +0 -160
  41. package/dist/infrastructure/context/adapters/linear-source.js +0 -190
  42. package/dist/infrastructure/context/context-resolver.js +0 -144
  43. package/dist/infrastructure/context/context-source.js +0 -114
  44. package/dist/infrastructure/context/github-api.js +0 -110
  45. package/dist/infrastructure/context/github-device-flow.js +0 -65
  46. package/dist/infrastructure/context/oauth-flow.js +0 -207
  47. package/dist/infrastructure/context/oauth-store.js +0 -122
  48. package/dist/infrastructure/context/registry-bootstrap.js +0 -95
  49. package/dist/infrastructure/context/write-pass-resolver.js +0 -106
  50. package/dist/infrastructure/cost/budget-config.js +0 -114
  51. package/dist/infrastructure/cost/cost-router.js +0 -97
  52. package/dist/infrastructure/cost/spend-aggregator.js +0 -222
  53. package/dist/infrastructure/cost/team-ledger.js +0 -355
  54. package/dist/infrastructure/discovery/discovery-log.js +0 -85
  55. package/dist/infrastructure/hooks/audit-tip-signature.js +0 -336
  56. package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -1018
  57. package/dist/infrastructure/hooks/hook-mode.js +0 -87
  58. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -49
  59. package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
  60. package/dist/infrastructure/hooks/session-analyzer.js +0 -177
  61. package/dist/infrastructure/observability/execution-tracer.js +0 -227
  62. package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
  63. package/dist/infrastructure/orchestration/agent-installer.js +0 -101
  64. package/dist/infrastructure/orchestration/agents-config-seed.js +0 -68
  65. package/dist/infrastructure/orchestration/aider-adapter.js +0 -611
  66. package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
  67. package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -592
  68. package/dist/infrastructure/orchestration/codex-adapter.js +0 -574
  69. package/dist/infrastructure/orchestration/conductor-adapter.js +0 -416
  70. package/dist/infrastructure/orchestration/context-handoff.js +0 -162
  71. package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
  72. package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
  73. package/dist/infrastructure/orchestration/detect-ollama.js +0 -75
  74. package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
  75. package/dist/infrastructure/orchestration/failover-config.js +0 -67
  76. package/dist/infrastructure/orchestration/failover-policy.js +0 -106
  77. package/dist/infrastructure/orchestration/gemini-adapter.js +0 -663
  78. package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -689
  79. package/dist/infrastructure/orchestration/graphify-context.js +0 -209
  80. package/dist/infrastructure/orchestration/memory-injector.js +0 -380
  81. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -388
  82. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
  83. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
  84. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
  85. package/dist/infrastructure/orchestration/opencode-adapter.js +0 -580
  86. package/dist/infrastructure/orchestration/progress-aggregator.js +0 -906
  87. package/dist/infrastructure/orchestration/readiness.js +0 -103
  88. package/dist/infrastructure/orchestration/session-manager.js +0 -1590
  89. package/dist/infrastructure/orchestration/session-persistence.js +0 -394
  90. package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
  91. package/dist/infrastructure/orchestration/write-pass-store.js +0 -38
  92. package/dist/infrastructure/protocols/cloud-sync.js +0 -207
  93. package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
  94. package/dist/infrastructure/protocols/mcp-server-impl.js +0 -3935
  95. package/dist/infrastructure/protocols/mcp-tools.js +0 -401
  96. package/dist/infrastructure/protocols/probe-port.js +0 -36
  97. package/dist/infrastructure/protocols/thread-phase.js +0 -105
  98. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +0 -121
  99. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +0 -44
  100. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +0 -56
  101. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +0 -588
  102. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +0 -168
  103. package/dist/infrastructure/protocols/ui/app-script-conversation.js +0 -67
  104. package/dist/infrastructure/protocols/ui/app-script-drawer.js +0 -115
  105. package/dist/infrastructure/protocols/ui/app-script-failover.js +0 -133
  106. package/dist/infrastructure/protocols/ui/app-script-github.js +0 -113
  107. package/dist/infrastructure/protocols/ui/app-script-observer.js +0 -104
  108. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +0 -80
  109. package/dist/infrastructure/protocols/ui/app-script-overlay.js +0 -203
  110. package/dist/infrastructure/protocols/ui/app-script-recovery.js +0 -136
  111. package/dist/infrastructure/protocols/ui/app-script-render.js +0 -166
  112. package/dist/infrastructure/protocols/ui/app-script-share.js +0 -78
  113. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +0 -194
  114. package/dist/infrastructure/protocols/ui/app-script-tour.js +0 -78
  115. package/dist/infrastructure/protocols/ui/app-script-wpass.js +0 -312
  116. package/dist/infrastructure/protocols/ui/app-script.js +0 -2998
  117. package/dist/infrastructure/protocols/ui/font-faces.js +0 -7
  118. package/dist/infrastructure/protocols/ui/styles.js +0 -2147
  119. package/dist/infrastructure/protocols/ui/tokens.js +0 -96
  120. package/dist/infrastructure/protocols/ui-page-v2.js +0 -320
  121. package/dist/infrastructure/protocols/verify-page.js +0 -1677
  122. package/dist/infrastructure/security/file-collection.js +0 -118
  123. package/dist/infrastructure/security/fix-code.js +0 -144
  124. package/dist/infrastructure/security/fix-secret.js +0 -89
  125. package/dist/infrastructure/security/permissions-config.js +0 -235
  126. package/dist/infrastructure/security/scan-types.js +0 -10
  127. package/dist/infrastructure/security/ship-scan.js +0 -195
  128. package/dist/infrastructure/security/trust-scorer.js +0 -724
  129. package/dist/infrastructure/security/vibe-checks.js +0 -99
  130. package/dist/infrastructure/storage/atomic-write.js +0 -86
  131. package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
  132. package/dist/infrastructure/storage/project-thread-migration.js +0 -237
  133. package/dist/infrastructure/storage/project-thread-mirror.js +0 -671
  134. package/dist/infrastructure/storage/project-thread-reader.js +0 -409
  135. package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
  136. package/dist/monitoring.js +0 -212
  137. package/dist/presentation/cli/commands/audit-command.js +0 -201
  138. package/dist/presentation/cli/commands/budget-command.js +0 -79
  139. package/dist/presentation/cli/commands/config-command.js +0 -125
  140. package/dist/presentation/cli/commands/context-source-command.js +0 -217
  141. package/dist/presentation/cli/commands/hook-command.js +0 -265
  142. package/dist/presentation/cli/commands/init-command.js +0 -590
  143. package/dist/presentation/cli/commands/memory-command.js +0 -1031
  144. package/dist/presentation/cli/commands/narrator-command.js +0 -101
  145. package/dist/presentation/cli/commands/permission-command.js +0 -105
  146. package/dist/presentation/cli/commands/protocols-builder.js +0 -254
  147. package/dist/presentation/cli/commands/protocols-command.js +0 -84
  148. package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
  149. package/dist/presentation/cli/commands/report-command.js +0 -403
  150. package/dist/presentation/cli/commands/scan-command.js +0 -52
  151. package/dist/presentation/cli/commands/scan-tool-command.js +0 -110
  152. package/dist/presentation/cli/commands/search-command.js +0 -51
  153. package/dist/presentation/cli/commands/setup-command.js +0 -330
  154. package/dist/presentation/cli/commands/status-command.js +0 -614
  155. package/dist/presentation/cli/commands/team-command.js +0 -256
  156. package/dist/presentation/cli/commands/thread-command.js +0 -370
  157. package/dist/presentation/cli/commands/trust-command.js +0 -328
  158. package/dist/presentation/cli/commands/verify-command.js +0 -298
  159. package/dist/presentation/cli/error-display.js +0 -328
  160. package/dist/presentation/cli/repl.js +0 -805
  161. package/dist/presentation/cli/task-deliverable.js +0 -153
  162. package/dist/testing/cassette.js +0 -125
  163. package/dist/testing/replay-adapter.js +0 -147
@@ -1,1590 +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, realpathSync } from 'fs';
15
- import { execFileSync } from 'child_process';
16
- import { EventEmitter } from 'node:events';
17
- import { ClaudeCodeAdapter } from './claude-code-adapter.js';
18
- import { CodexAdapter } from './codex-adapter.js';
19
- import { AiderAdapter } from './aider-adapter.js';
20
- import { OpenCodeAdapter } from './opencode-adapter.js';
21
- import { GeminiAdapter } from './gemini-adapter.js';
22
- import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js';
23
- import { buildConductorAdapter } from './conductor-adapter.js';
24
- import { recordTurnToWorkingMemory } from './memory-injector.js';
25
- import { seedAgentsConfig } from './agents-config-seed.js';
26
- import { applyTestObservation } from '../../domain/task.js';
27
- import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
28
- import { signAuditTip } from '../hooks/audit-tip-signature.js';
29
- import { resumeClaudeMessage } from './claude-code-adapter.js';
30
- import { appendSpawned, appendCompleted, appendFinalReport, appendAuditRecon, loadRecent, loadSessionById, } from './session-persistence.js';
31
- import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
32
- import { generateAcceptanceCriteria, gradeAcceptanceCriteria, isTrivialRequest, } from '../../application/use-cases/generate-acceptance-criteria.js';
33
- import { mirrorSpawnedToThread, mirrorCompletedToThread, mirrorFinalReportToThread, } from '../storage/project-thread-mirror.js';
34
- import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
35
- import { generateNarration } from '../../application/use-cases/generate-narration.js';
36
- import { randomUUID } from 'crypto';
37
- import { classifyFailoverEligibility } from './failover-classifier.js';
38
- import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
39
- import { loadFailoverConfig } from './failover-config.js';
40
- import { loadBudget, isOverBudget } from '../cost/budget-config.js';
41
- import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
42
- import { planAutoReview } from '../../application/use-cases/plan-auto-review.js';
43
- import { rerunTests, detectTestCommand } from '../../application/use-cases/rerun-tests.js';
44
- import { reconcileAudit } from '../../application/use-cases/reconcile-audit.js';
45
- import { collectAuditEntriesForSession } from '../../application/use-cases/thread-export.js';
46
- import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
47
- import { buildFailoverHandoff, enrichPromptWithHandoff, enrichPromptWithThreadHandoff } from './context-handoff.js';
48
- import { readMergedProjectThread, listProjects } from '../storage/project-thread-reader.js';
49
- import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
50
- // ── Agent registry ─────────────────────────────────────────────────────────────
51
- // All backends codehere can spawn. Order here drives the default UI dropdown
52
- // order. Built-in adapters first; user-config-driven ones appended from
53
- // ~/.codehere/agents.yaml (generic-cli-adapter). isInstalled() gates visibility
54
- // so an adapter registering for an un-installed CLI is harmless.
55
- const BUILTIN_REGISTRY = [
56
- ClaudeCodeAdapter,
57
- CodexAdapter,
58
- AiderAdapter,
59
- OpenCodeAdapter,
60
- GeminiAdapter,
61
- ];
62
- // Seed the default agents.yaml if this user has never configured one, so Gemini
63
- // / Cursor / Pi / Antigravity / Goose appear in the registry pre-wired. They
64
- // stay hidden in the UI until isInstalled() flips to true. Idempotent.
65
- seedAgentsConfig();
66
- const REGISTRY = [
67
- ...BUILTIN_REGISTRY,
68
- // User-configurable adapters — "plug any CLI" escape hatch, seeded with
69
- // popular CLI agents above. Users can add/edit/remove entries freely.
70
- ...loadAgentsConfig().map(buildGenericAdapter),
71
- ];
72
- // F6 Conductor — the meta-agent. Registered LAST so it sees the full registry
73
- // at lookup time. The lookup function is closed over `REGISTRY` so it always
74
- // returns the current populated array (including any future user adapters).
75
- // Conductor is appended to the same array so UIs that iterate REGISTRY see it.
76
- REGISTRY.push(buildConductorAdapter(() => REGISTRY));
77
- /**
78
- * The stall watchdog window, per agent. Most adapters STREAM events as they
79
- * work (claude/codex/gemini/aider), so 180s of silence is a real hang. OpenCode
80
- * in `--format json` is SILENT BY DESIGN — it buffers all stdout until the run
81
- * completes (verified: `opencode run --format json` emits 0 bytes until done),
82
- * so it never resets the stall clock mid-run. On a slow local model that easily
83
- * exceeds 180s, the watchdog killed a working opencode mid-edit (real-mode
84
- * dogfood 2026-06-13). Silent-buffering adapters get a longer leash; a truly
85
- * hung opencode is still caught, just later. CODEHERE_STALL_TIMEOUT_MS overrides
86
- * the default for every agent.
87
- */
88
- const SILENT_BUFFERING_AGENTS = new Set(['opencode']);
89
- export function stallWindowFor(agent) {
90
- const override = process.env.CODEHERE_STALL_TIMEOUT_MS;
91
- if (override !== undefined && override !== '')
92
- return Number(override);
93
- return SILENT_BUFFERING_AGENTS.has(agent) ? 600_000 : 180_000;
94
- }
95
- export function availableAgents() {
96
- return REGISTRY.map((a) => ({
97
- id: a.id,
98
- label: a.label,
99
- installed: a.isInstalled(),
100
- ...(a.description ? { description: a.description } : {}),
101
- }));
102
- }
103
- /** Per-agent readiness across the full registry. Powers the setup-aware UI. */
104
- export function readinessForAllAgents() {
105
- return REGISTRY.map((a) => {
106
- const base = a.checkReadiness
107
- ? a.checkReadiness()
108
- : a.isInstalled()
109
- ? { id: a.id, label: a.label, state: 'ready', installed: true }
110
- : { id: a.id, label: a.label, state: 'needs_install', installed: false };
111
- // v0.6 S5 — mirror the adapter's "when to use me" description into the
112
- // readiness payload so the dashboard dropdown can surface it as a
113
- // plain-English hover tooltip without a separate API call.
114
- return a.description ? { ...base, description: a.description } : base;
115
- });
116
- }
117
- export function getAdapter(id) {
118
- return REGISTRY.find((a) => a.id === id);
119
- }
120
- export function allAdapters() {
121
- return REGISTRY;
122
- }
123
- export function agentProcessPatterns() {
124
- return REGISTRY.map((a) => ({
125
- regex: a.processPattern,
126
- agent: a.id,
127
- label: a.label,
128
- }));
129
- }
130
- const DEFAULT_OBSERVED_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
131
- function parseEtime(s) {
132
- // ps etime formats: "MM:SS", "HH:MM:SS", "D-HH:MM:SS"
133
- let rest = s;
134
- let days = 0;
135
- if (rest.includes('-')) {
136
- const [d, r] = rest.split('-');
137
- days = parseInt(d, 10) || 0;
138
- rest = r;
139
- }
140
- const parts = rest.split(':').map((p) => parseInt(p, 10) || 0);
141
- if (parts.length === 2)
142
- return parts[0] * 60 + parts[1];
143
- if (parts.length === 3)
144
- return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
145
- return 0;
146
- }
147
- export class SessionManager {
148
- /** Max completed sessions kept in memory. Older sessions are evicted to
149
- * prevent unbounded memory growth in the internal tracking maps. The data
150
- * survives on disk via sessions.jsonl + thread.jsonl. */
151
- static MAX_COMPLETED_SESSIONS = 100;
152
- /** Live-progress push. The /api/events/sessions SSE stream watches
153
- * sessions.jsonl for changes, but that file is only written on spawn +
154
- * completion — so during a run the browser got NO updates and the room
155
- * sat at "0 steps" until the agent finished, then jumped straight to the
156
- * outcome (user-#1, 2026-06-07). Steps DO update in memory; this emitter
157
- * lets the SSE layer push a "change" the instant they do, so the room
158
- * actually watches the agent work. */
159
- liveChanges = new EventEmitter();
160
- sessions = new Map();
161
- /** Spawn order FIFO — used to evict oldest completed sessions when the cap
162
- * is reached. Pushed on spawn, referenced by evictOldCompleted(). */
163
- spawnOrder = [];
164
- /** Read-only sessions replayed from ~/.codehere/sessions.jsonl on daemon start.
165
- * These subprocesses are long-dead; we surface them so the user doesn't lose
166
- * their Final reports across restarts. */
167
- persistedCache = loadRecent(20);
168
- /**
169
- * R13 / M3 — THE typed per-session state record, replacing the ~17
170
- * `*BySession` maps one field at a time (each map deleted in the commit its
171
- * field moves here — move-not-add). One id, one record, one lifecycle:
172
- * created on first write, dropped whole at eviction. When the migration
173
- * completes this becomes the single source for everything the manager
174
- * tracks about a session beyond the adapter's live TaskSession handle,
175
- * and `CompletedSession` (the completeness invariant: tests + files +
176
- * audit + report, or an explicit honest reason for each absence) types the
177
- * terminal state.
178
- */
179
- records = new Map();
180
- /** The session's state record, created on first write. Reads that must not
181
- * create should use `this.records.get(id)?.field`. */
182
- rec(id) {
183
- let r = this.records.get(id);
184
- if (!r) {
185
- r = {};
186
- this.records.set(id, r);
187
- }
188
- return r;
189
- }
190
- /** v0.6 M3 · PM Narrator. Mid-task plain-English summaries cached per
191
- * session so the API can serve the latest narration without re-running
192
- * the LLM call on every frontend poll. Latest-only — older narrations
193
- * are overwritten when a new one fires. Per claim_user_persona HIGH:
194
- * non-technical users read narration, not tool calls. */
195
- narrationTimers = new Map();
196
- narrationStepsAtLastFire = new Map();
197
- /** Stall watchdog (dogfood 2026-05-27 #1): a spawned agent can hang with no
198
- * output and sit "running" forever (opencode did exactly this). We track the
199
- * timestamp of the last event from each session and, if a running session
200
- * goes silent past the stall window, kill it and record an honest reason so
201
- * the UI shows "stopped — no response", never a fake "running" or a normal row. */
202
- stallTimers = new Map();
203
- /** Stores active failover offers keyed by sessionId+target. API layer
204
- * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
205
- failoverOffers;
206
- /**
207
- * Resolve the turn a continuation hands off from, into the prior agent +
208
- * prompt + Final Report summary. Reads the durable project thread (so it
209
- * survives a daemon restart and unifies clones) via the same merged reader
210
- * the room renders from — agent and human see one truth. Best-effort: any
211
- * failure (no project record yet, parent not found) returns null, and the
212
- * spawn proceeds without a preamble rather than breaking.
213
- */
214
- lookupThreadHandoffParent(parentSessionId, cwd) {
215
- try {
216
- const proj = listProjects().find((p) => (p.localPaths || []).includes(cwd) || p.cwd === cwd);
217
- if (!proj)
218
- return null;
219
- const thread = readMergedProjectThread(proj.slug);
220
- const parent = thread?.tasks.find((t) => t.sessionId === parentSessionId);
221
- if (!parent)
222
- return null;
223
- return {
224
- agent: String(parent.agent || 'an agent'),
225
- prompt: parent.prompt,
226
- summary: parent.finalReport?.summary || undefined,
227
- };
228
- }
229
- catch {
230
- return null;
231
- }
232
- }
233
- spawn(prompt, agentId = 'claude-code', options) {
234
- const adapter = getAdapter(agentId);
235
- if (!adapter) {
236
- throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
237
- }
238
- // Canonicalize the cwd ONCE at the spawn boundary so every downstream
239
- // consumer (pre-scan, adapter, persistence, thread mirror, room grouping)
240
- // sees one spelling. Browser dogfood 2026-06-10 caught the live break:
241
- // /tmp/x and /private/tmp/x (macOS /tmp is a symlink) rendered as TWO
242
- // separate goals/rooms for the SAME repo — splitting the shared-context
243
- // story the room exists to tell. Best-effort: a non-existent cwd passes
244
- // through unchanged (the adapter will surface that failure honestly).
245
- if (options?.cwd) {
246
- try {
247
- options = { ...options, cwd: realpathSync(options.cwd) };
248
- }
249
- catch {
250
- /* keep the caller's path */
251
- }
252
- }
253
- // CODEHERE_MOCK spawns a synthetic agent (mock REPL), so the real CLI need
254
- // not be installed — bypass the readiness gate. Makes the mock harness truly
255
- // zero-dependency (dogfood/dev without any agent CLI installed).
256
- const mockMode = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
257
- if (!mockMode && !adapter.isInstalled()) {
258
- throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
259
- }
260
- // Fail-fast on an unusable agent. An explicit pick that's installed-but-not-
261
- // signed-in used to spawn and HANG at "running" forever (gemini: no auth → no
262
- // output → the watchdog only fires after minutes). Reject it in milliseconds
263
- // with the real reason instead. Skipped for the conductor (it resolves a
264
- // specialist internally) and in mock mode. Honesty-at-the-surface: a task that
265
- // can't start must say why, not sit spinning.
266
- // Skipped under VITEST: scenario/cassette tests replay recorded vendor
267
- // streams through a MOCKED child_process, so no real binary spawns and the
268
- // auth gate would only reject a replay that can't hang. This is a runtime
269
- // UX protection, not domain logic.
270
- if (!mockMode && !process.env.VITEST && agentId !== 'conductor' && adapter.checkReadiness) {
271
- const r = adapter.checkReadiness();
272
- if (r.state !== 'ready') {
273
- const why = r.authHint?.detail || r.installHint?.command || `${adapter.label} is not ready (${r.state})`;
274
- throw new Error(`${adapter.label} can't run yet — ${why}`);
275
- }
276
- }
277
- // Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
278
- // the FAQ now claims as "v0.5.x → v0.6 track": every adapter — not
279
- // just claude-code — gets a safety scan on the user's prompt before
280
- // spawn. Catches API-key paste, prompt-injection patterns, etc.
281
- // Always writes one audit entry (synthetic toolName 'pre_prompt');
282
- // refuses spawn ONLY when getHookMode() === 'block' AND the prompt
283
- // has critical/high findings. Other modes (advisory/log-only/off)
284
- // log + proceed.
285
- const preScanSessionId = randomUUID();
286
- const scan = scanPromptBeforeSpawn({
287
- sessionId: preScanSessionId,
288
- agent: agentId,
289
- prompt,
290
- cwd: options?.cwd,
291
- });
292
- if (!scan.allowed) {
293
- throw new Error(scan.blockReason || 'pre-prompt scan blocked the spawn');
294
- }
295
- // P1 — graphify-ts structural memory: enrich prompt with compact context
296
- // pack so the agent starts with a verified knowledge graph of the workspace.
297
- // Respects CODEHERE_GRAPHIFY env var (auto|force|off, default=auto).
298
- // Synchronous call — degrades to original prompt if graphify fails.
299
- const cwd = options?.cwd ?? process.cwd();
300
- let enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
301
- // Phase 3 — cross-agent thread hand-off. When this spawn CONTINUES a turn
302
- // (parentSessionId, set by the room composer), inject what the prior agent
303
- // was asked + what it did, so a DIFFERENT agent picks up the work instead of
304
- // restarting blind. The injected summary is the SAME one the room shows, so
305
- // agent + human read one truth. The displayed prompt stays clean (the mirror
306
- // records `prompt`, not this enriched one — same split graphify already uses).
307
- if (options?.parentSessionId) {
308
- const parent = this.lookupThreadHandoffParent(options.parentSessionId, cwd);
309
- if (parent)
310
- enrichedPrompt = enrichPromptWithThreadHandoff(enrichedPrompt, parent);
311
- }
312
- const session = adapter.spawn(enrichedPrompt, options);
313
- this.sessions.set(session.sessionId, session);
314
- this.spawnOrder.push(session.sessionId);
315
- // Persistence: write the spawned record immediately so a daemon crash still
316
- // leaves an audit trail of what was attempted. Subscribe to exit so we can
317
- // capture the final status + steps + artifacts when the session settles.
318
- this.rec(session.sessionId).cwd = cwd;
319
- this.rec(session.sessionId).displayPrompt = prompt; // clean user ask (not the enriched input)
320
- if (options?.batchId)
321
- this.rec(session.sessionId).batchId = options.batchId;
322
- // REQUEST_METHOD §2 — for a non-trivial request, derive the acceptance
323
- // contract (async, non-blocking like the title agent) and store it on the
324
- // record; the room shows "Done means …" once it lands. Trivial asks skip it
325
- // (the scaling principle — no ceremony tax, no per-request LLM call).
326
- if (!isTrivialRequest(prompt)) {
327
- const _sid = session.sessionId;
328
- generateAcceptanceCriteria(prompt, cwd || '')
329
- .then((criteria) => {
330
- if (criteria.length && this.records.has(_sid))
331
- this.rec(_sid).acceptanceCriteria = criteria;
332
- })
333
- .catch(() => { });
334
- }
335
- appendSpawned({
336
- id: session.sessionId,
337
- agent: session.agent,
338
- prompt,
339
- cwd,
340
- startedAt: session.startedAt,
341
- ...(options?.batchId ? { batchId: options.batchId } : {}),
342
- ...(options?.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
343
- ...(options?.isReview ? { isReview: true } : {}),
344
- });
345
- // v0.6.0 M1+M7 — forward-mirror to per-project thread.jsonl alongside
346
- // sessions.jsonl. Best-effort; mirror failures don't break the spawn path.
347
- mirrorSpawnedToThread({
348
- sessionId: session.sessionId,
349
- agent: session.agent,
350
- prompt,
351
- cwd,
352
- startedAt: session.startedAt,
353
- ...(options?.batchId ? { batchId: options.batchId } : {}),
354
- ...(options?.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
355
- });
356
- this.rec(session.sessionId).lastActivityAt = Date.now();
357
- const unsubscribe = session.subscribe((event) => {
358
- // Any event = the agent is alive. Reset the stall clock. (watchdog)
359
- this.rec(session.sessionId).lastActivityAt = Date.now();
360
- // Capture which specialist the Conductor routed to, so the UI can name
361
- // the agent that actually ran (not just "Auto").
362
- if (event.type === 'codehere:conductor-routing') {
363
- const d = event.data?.delegatedTo;
364
- if (d)
365
- this.rec(session.sessionId).delegatedTo = d;
366
- }
367
- // Push a live-progress tick so an open room/detail refetches mid-run
368
- // instead of waiting for the completion disk-write. Steps stream into
369
- // memory; this is how the browser hears about them in real time.
370
- if (event.type === 'codehere:steps' ||
371
- event.type === 'codehere:artifacts' ||
372
- event.type === 'codehere:conductor-routing' ||
373
- event.type === 'exit') {
374
- // conductor-routing included so "routed to <agent>" surfaces during the
375
- // spin-up gap instead of the user staring at "starting…" with nothing.
376
- this.liveChanges.emit('change');
377
- }
378
- // ── Failover check — intercept rate-limit / quota errors before exit ──
379
- // Adapters emit 'codehere:error' (structured: { code, message, recovery })
380
- // or 'error' (spawn-level: { message }). Both need classification.
381
- if (event.type === 'error' || event.type === 'codehere:error') {
382
- const err = event.data;
383
- // Remember the specific error text so the Final Report can name it
384
- // (the report reads session.steps, which don't carry this event).
385
- if (err?.message) {
386
- this.rec(session.sessionId).errorText = [err.message, err.recovery].filter(Boolean).join(' ');
387
- }
388
- if (err?.code && err?.message) {
389
- const signal = classifyFailoverEligibility(session.agent, err.code, err.message);
390
- if (signal) {
391
- const newSession = this.handleFailover(session, signal);
392
- if (newSession)
393
- return; // auto-spawned — keep listening on new session
394
- }
395
- }
396
- }
397
- if (event.type !== 'exit')
398
- return;
399
- // Tier 1.1 · OBSERVE the tests instead of trusting the agent's scrape.
400
- // Fire-and-forget (never block the daemon): re-run the repo's own test
401
- // command on the real on-disk state and replace the scraped count with the
402
- // ground-truth result. The live verdict (mcp-server recomputes verifyClaim
403
- // from session.artifacts each poll) refreshes automatically once it lands,
404
- // so a "tests pass" claim that fails on re-run flips the ✓ to broken.
405
- void this.observeTests(session, cwd);
406
- // Tier 1.2 · reconcile the audit's declared file-writes against the real
407
- // git-diff effect (read-only; never mutates the hash-chained audit).
408
- // Deferred a tick so the synchronous exit path + any final mirror writes
409
- // settle before we read the audit file.
410
- void Promise.resolve().then(() => this.reconcileSessionAudit(session));
411
- // Tier 2.1 · auto cross-vendor adversarial review (flag-gated, default
412
- // OFF). When enabled, a DIFFERENT vendor independently reviews this work
413
- // in refute-mode so the cross-vendor proof becomes automatic, not a manual
414
- // two-task workflow. Deferred a tick so artifacts/status have settled.
415
- void Promise.resolve().then(() => this.maybeAutoReview(session, cwd));
416
- const data = event.data;
417
- const completedAt = Date.now();
418
- // Surface completedAt to API consumers reading the still-in-memory
419
- // session (Bug 2 sister-fix, May 2). Without this, /api/tasks shows
420
- // status=completed but no completedAt → NaN duration in the UI.
421
- this.rec(session.sessionId).completedAt = completedAt;
422
- appendCompleted({
423
- id: session.sessionId,
424
- nativeSessionId: session.nativeSessionId,
425
- delegateSessionId: session.delegateSessionId,
426
- status: session.status,
427
- durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
428
- completedAt,
429
- steps: session.steps ? [...session.steps] : undefined,
430
- artifacts: session.artifacts,
431
- stalledReason: this.records.get(session.sessionId)?.stalled?.reason,
432
- delegatedTo: this.records.get(session.sessionId)?.delegatedTo,
433
- });
434
- // v0.6.0 M1+M7 — forward-mirror completion to project thread.
435
- mirrorCompletedToThread({
436
- sessionId: session.sessionId,
437
- cwd,
438
- status: session.status,
439
- durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
440
- completedAt,
441
- steps: session.steps ? [...session.steps] : undefined,
442
- artifacts: session.artifacts,
443
- });
444
- // PM-UX Layer 3 · Final Report MVP. Fire-and-forget; the report appears
445
- // in the terminal a few seconds after exit. Failure modes return null
446
- // silently (claude CLI not installed, timeout, non-zero exit). No SSE,
447
- // no UI integration in this MVP — terminal output only so the dogfood
448
- // loop sees the artifact immediately. See `generate-final-report.ts`.
449
- const reportInput = {
450
- sessionId: session.sessionId,
451
- prompt: session.prompt,
452
- agent: session.agent,
453
- status: session.status,
454
- durationMs: completedAt - session.startedAt,
455
- steps: session.steps ? [...session.steps] : [],
456
- artifacts: session.artifacts,
457
- errorText: this.records.get(session.sessionId)?.errorText,
458
- delegatedTo: this.records.get(session.sessionId)?.delegatedTo,
459
- };
460
- generateFinalReport(reportInput)
461
- .then((report) => {
462
- if (report) {
463
- // Cache for web UI consumption via /api/tasks (iter 12 — non-technical
464
- // users live in the web UI, not the terminal).
465
- this.rec(session.sessionId).finalReport = report;
466
- // REQUEST_METHOD stage 5 — grade the contract against the result.
467
- // The assessment answers MET/UNMET per criterion; never marks met
468
- // without evidence (no fake green). Runs only if a contract exists.
469
- const _ac = this.rec(session.sessionId).acceptanceCriteria;
470
- if (_ac && _ac.length) {
471
- const _sid2 = session.sessionId;
472
- const _files = (session.artifacts?.files || [])
473
- .map((f) => typeof f === 'string'
474
- ? f
475
- : (f?.path ?? f?.file))
476
- .filter((x) => !!x);
477
- const _ok = session.status === 'completed' || session.status === 'done';
478
- gradeAcceptanceCriteria(_ac, {
479
- prompt: this.rec(_sid2).displayPrompt || '',
480
- reportSummary: report.summary,
481
- files: _files,
482
- verdictGreen: _ok, // gate met-grading on TASK success, never report-gen success (no fake green on a failed run)
483
- })
484
- .then((graded) => {
485
- if (this.records.has(_sid2))
486
- this.rec(_sid2).acceptanceCriteria = graded;
487
- })
488
- .catch(() => { });
489
- }
490
- // v0.6.x — persist to disk so the report survives daemon restart.
491
- // Pre-fix: founder's Mac restart 2026-05-03 left a session card
492
- // showing "Generating report…" forever because reports lived only
493
- // in this in-memory Map. Append-only finalReport record paired
494
- // with the existing spawned/completed records by sessionId.
495
- appendFinalReport({
496
- id: session.sessionId,
497
- summary: report.summary,
498
- generatedAt: report.generatedAt,
499
- durationMs: report.durationMs,
500
- source: report.source,
501
- });
502
- // Persist the summary to the project thread too (Step C) — the report
503
- // is async, so the earlier mirrorCompletedToThread ran without it. This
504
- // is what a cross-agent hand-off reads, and what the conversation view
505
- // shows after a daemon restart. Best-effort; never breaks the run.
506
- mirrorFinalReportToThread({
507
- sessionId: session.sessionId,
508
- cwd,
509
- summary: report.summary,
510
- source: report.source,
511
- });
512
- // Shared-room hand-off: record this turn's outcome to the project's
513
- // working memory so the NEXT agent spawned here reads it (closes the
514
- // loop with applyMemoryPrefix). Best-effort; only initialized projects.
515
- try {
516
- recordTurnToWorkingMemory(cwd, {
517
- agent: session.agent,
518
- prompt: session.prompt,
519
- summary: report.summary,
520
- files: session.artifacts?.files?.length,
521
- });
522
- }
523
- catch {
524
- /* memory write must never break the run */
525
- }
526
- try {
527
- printFinalReportToTerminal(reportInput, report);
528
- }
529
- catch {
530
- // Don't let a print failure crash the daemon.
531
- }
532
- }
533
- })
534
- .catch(() => {
535
- // generateFinalReport rejected — record it so the UI can show
536
- // "Report unavailable" instead of "Generating report…" forever.
537
- this.rec(session.sessionId).failedReport = true;
538
- });
539
- // P1 — persist context pack at session exit for cross-session handoff (P2).
540
- // Fire-and-forget: graphify-ts may not be installed, graph may be empty,
541
- // or pack compilation may fail. All degrade silently.
542
- void Promise.resolve().then(() => {
543
- try {
544
- const stepsStr = session.steps
545
- ?.map((s) => `${s.label}: ${s.output || s.state}`)
546
- .filter(Boolean)
547
- .join('\n') ?? '';
548
- const pack = compileSessionPackSync(cwd, {
549
- prompt: session.prompt,
550
- artifacts: JSON.stringify(session.artifacts, null, 2),
551
- steps: stepsStr,
552
- agent: session.agent,
553
- });
554
- if (pack)
555
- persistContextPack(pack, cwd);
556
- }
557
- catch {
558
- // Silent degradation — context pack is enrichment, not the primary path.
559
- }
560
- });
561
- // v0.6 M3 — clean up the narration timer for this session.
562
- const t = this.narrationTimers.get(session.sessionId);
563
- if (t) {
564
- clearInterval(t);
565
- this.narrationTimers.delete(session.sessionId);
566
- }
567
- // Watchdog: session settled on its own — stop watching it.
568
- this.clearStallTimer(session.sessionId);
569
- this.evictOldCompleted();
570
- unsubscribe();
571
- });
572
- // v0.6 M3 — PM Narrator. Mid-task plain-English summaries fired
573
- // every 30s while the session is running. Heuristic: only fire if
574
- // the step count grew by ≥3 since the last narration (otherwise
575
- // we'd narrate the same state with new words). Fire-and-forget;
576
- // failures (no claude binary, timeout, malformed output) silently
577
- // skip — narration is enrichment, not the primary surface.
578
- this.startNarrationTimer(session);
579
- this.startStallWatchdog(session);
580
- return session;
581
- }
582
- /** v0.6.x failover — emit a failover offer when an adapter hits a
583
- * rate-limit / quota error. The offer is surfaced via the API layer
584
- * (Slice E) and optionally auto-spawned (Slice D). Returns the new
585
- * session if auto-spawn was triggered, undefined otherwise. */
586
- handleFailover(session, signal) {
587
- const config = loadFailoverConfig();
588
- const tried = this.records.get(session.sessionId)?.failoverTried ?? new Set();
589
- tried.add(signal.fromAgent);
590
- this.rec(session.sessionId).failoverTried = tried;
591
- // Cost-bounded fail-over (refuter 2026-06-08). A stall/error fail-over spawns
592
- // a REAL replacement agent — so it must respect the daily budget the same way
593
- // conductor routing does, or the "multi-agent layer that doesn't waste money"
594
- // thesis breaks (a hung agent could otherwise cascade paid spawns across the
595
- // whole chain). When over budget, only FREE agents (estimateCostUsd === 0) are
596
- // eligible; no free agent ready → no target → honest kill, never an
597
- // over-budget paid spawn.
598
- // NOTE (fail-open boundedness): estimateCostUsd returns 0 for an id absent
599
- // from the cost table, so an UNKNOWN paid adapter could read as free here.
600
- // That path is unreachable today — the failover chain is validated against
601
- // KNOWN_AGENT_IDS (validateFailoverChain), and all five are priced. If you
602
- // add a paid adapter, add its cost-table row in the SAME change.
603
- const budgetCfg = loadBudget();
604
- const budgetBounded = budgetCfg.dailyUsd !== null && isOverBudget(computeSpendToday().totalUsd, budgetCfg);
605
- const adapters = allAdapters();
606
- const available = toAvailability(adapters, (a) => {
607
- if (a.id === signal.fromAgent || !a.isInstalled())
608
- return false;
609
- if (budgetBounded && estimateCostUsd(a.id, { inputTokens: 1, outputTokens: 1 }) !== 0)
610
- return false;
611
- return true;
612
- });
613
- const target = resolveFailoverTarget({
614
- failedAgent: signal.fromAgent,
615
- alreadyTried: Array.from(tried),
616
- available,
617
- preferredChain: config?.chain,
618
- });
619
- if (!target)
620
- return undefined;
621
- const targetAdapter = getAdapter(target);
622
- if (!targetAdapter || !targetAdapter.isInstalled())
623
- return undefined;
624
- // Auto-failover: spawn on the next agent in the chain
625
- const autoFailover = config?.autoFailover ?? true;
626
- if (autoFailover) {
627
- // Spawn replacement session on the next agent, preserving prompt + cwd
628
- const cwd = this.records.get(session.sessionId)?.cwd ?? process.cwd();
629
- // P2 — build handoff context for continuity across agent swap
630
- const handoff = buildFailoverHandoff(cwd, {
631
- failedAgent: session.agent,
632
- failedPrompt: session.prompt,
633
- });
634
- // Enrich prompt with handoff if available (P2)
635
- const promptWithHandoff = handoff
636
- ? enrichPromptWithHandoff(session.prompt, {
637
- packId: handoff.packId,
638
- agent: session.agent,
639
- prompt: session.prompt,
640
- status: 'failed',
641
- timestamp: handoff.timestamp,
642
- nodeCount: handoff.nodeCount,
643
- edgeCount: handoff.edgeCount,
644
- task: handoff.task,
645
- payloadPreview: handoff.payloadPreview,
646
- })
647
- : session.prompt;
648
- try {
649
- const newSession = targetAdapter.spawn(promptWithHandoff, {
650
- cwd,
651
- env: process.env,
652
- preferSubscription: true,
653
- });
654
- // Track this new session in the same way
655
- this.sessions.set(newSession.sessionId, newSession);
656
- this.spawnOrder.push(newSession.sessionId);
657
- this.rec(newSession.sessionId).cwd = cwd;
658
- // The failover continues the SAME user task, so display the SAME clean ask —
659
- // not the hand-off-enriched prompt the adapter stores as session.prompt (the
660
- // "[Continuing a thread …]" leak on the failover path).
661
- this.rec(newSession.sessionId).displayPrompt = this.getDisplayPrompt(session.sessionId) ?? session.prompt;
662
- this.rec(newSession.sessionId).lastActivityAt = Date.now();
663
- // Subscribe BEFORE persistence so exit events are never lost if the
664
- // adapter exits synchronously (command not found, instant crash, etc).
665
- const unsubscribe = newSession.subscribe((event) => {
666
- this.rec(newSession.sessionId).lastActivityAt = Date.now();
667
- if (event.type !== 'exit')
668
- return;
669
- this.clearStallTimer(newSession.sessionId);
670
- const data = event.data;
671
- const completedAt = Date.now();
672
- this.rec(newSession.sessionId).completedAt = completedAt;
673
- appendCompleted({
674
- id: newSession.sessionId,
675
- nativeSessionId: newSession.nativeSessionId,
676
- delegateSessionId: newSession.delegateSessionId,
677
- status: newSession.status,
678
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
679
- completedAt,
680
- steps: newSession.steps ? [...newSession.steps] : undefined,
681
- artifacts: newSession.artifacts,
682
- stalledReason: this.records.get(newSession.sessionId)?.stalled?.reason,
683
- });
684
- mirrorCompletedToThread({
685
- sessionId: newSession.sessionId,
686
- cwd,
687
- status: newSession.status,
688
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
689
- completedAt,
690
- steps: newSession.steps ? [...newSession.steps] : undefined,
691
- artifacts: newSession.artifacts,
692
- });
693
- const reportInput = {
694
- sessionId: newSession.sessionId,
695
- prompt: newSession.prompt,
696
- agent: newSession.agent,
697
- status: newSession.status,
698
- durationMs: completedAt - newSession.startedAt,
699
- steps: newSession.steps ? [...newSession.steps] : [],
700
- artifacts: newSession.artifacts,
701
- errorText: this.records.get(newSession.sessionId)?.errorText,
702
- delegatedTo: this.records.get(newSession.sessionId)?.delegatedTo,
703
- };
704
- generateFinalReport(reportInput)
705
- .then((report) => {
706
- if (report) {
707
- this.rec(newSession.sessionId).finalReport = report;
708
- appendFinalReport({
709
- id: newSession.sessionId,
710
- summary: report.summary,
711
- generatedAt: report.generatedAt,
712
- durationMs: report.durationMs,
713
- source: report.source,
714
- });
715
- mirrorFinalReportToThread({
716
- sessionId: newSession.sessionId,
717
- cwd,
718
- summary: report.summary,
719
- source: report.source,
720
- });
721
- try {
722
- printFinalReportToTerminal(reportInput, report);
723
- }
724
- catch {
725
- /* defensive */
726
- }
727
- }
728
- })
729
- .catch(() => {
730
- /* defensive */
731
- });
732
- // P1 — persist context pack at failover-replacement session exit.
733
- void Promise.resolve().then(() => {
734
- try {
735
- const stepsStr = newSession.steps
736
- ?.map((s) => `${s.label}: ${s.output || s.state}`)
737
- .filter(Boolean)
738
- .join('\n') ?? '';
739
- const pack = compileSessionPackSync(cwd, {
740
- prompt: newSession.prompt,
741
- artifacts: JSON.stringify(newSession.artifacts, null, 2),
742
- steps: stepsStr,
743
- agent: newSession.agent,
744
- });
745
- if (pack)
746
- persistContextPack(pack, cwd);
747
- }
748
- catch {
749
- // Silent degradation — context pack is enrichment, not primary path.
750
- }
751
- });
752
- const t = this.narrationTimers.get(newSession.sessionId);
753
- if (t) {
754
- clearInterval(t);
755
- this.narrationTimers.delete(newSession.sessionId);
756
- }
757
- this.evictOldCompleted();
758
- unsubscribe();
759
- });
760
- // Persistence + threading — safe after subscribe because the exit
761
- // handler is already registered. These can run even if the session
762
- // already exited (spawned record still useful, exit handler handles
763
- // the completion).
764
- appendSpawned({
765
- id: newSession.sessionId,
766
- agent: newSession.agent,
767
- prompt: promptWithHandoff,
768
- cwd,
769
- startedAt: newSession.startedAt,
770
- });
771
- mirrorSpawnedToThread({
772
- sessionId: newSession.sessionId,
773
- agent: newSession.agent,
774
- prompt: promptWithHandoff,
775
- cwd,
776
- startedAt: newSession.startedAt,
777
- });
778
- mirrorFailoverToThread({
779
- sessionId: newSession.sessionId,
780
- fromAgent: signal.fromAgent,
781
- toAgent: target,
782
- reason: signal.errorCode,
783
- cwd,
784
- });
785
- this.rec(session.sessionId).continuedAs = newSession.sessionId;
786
- this.rec(newSession.sessionId).continuedFrom = session.sessionId;
787
- // Record the recovery lineage on the replacement so the dashboard can
788
- // surface "recovered from <fromAgent>" — the resilience must be VISIBLE,
789
- // not buried in a killed row (the S3 dogfood gap, 2026-06-08).
790
- this.rec(newSession.sessionId).recoveredFrom = {
791
- agent: signal.fromAgent,
792
- reason: signal.errorCode,
793
- };
794
- // Thread the already-tried set FORWARD onto the replacement session.
795
- // Without this, a multi-hop cascade (A hangs → B hangs → …) loses its
796
- // history: B's failover reads a fresh empty set and re-picks A (first
797
- // ready in the chain), looping A↔B and never reaching C. Seeding the
798
- // replacement with the accumulated `tried` set (already includes the
799
- // just-failed agent) makes the chain walk strictly forward and exhaust
800
- // honestly. (Caught by stall-failover-stress.test.ts, 2026-06-08.)
801
- this.rec(newSession.sessionId).failoverTried = new Set(tried);
802
- this.startNarrationTimer(newSession);
803
- this.startStallWatchdog(newSession);
804
- return newSession;
805
- }
806
- catch {
807
- // Spawn failed — fall through to offer-only mode
808
- return undefined;
809
- }
810
- }
811
- // Offer-only mode: signal the API layer to present a failover banner
812
- // (Slice E handles the actual UI surfacing)
813
- this.emitFailoverOffer({ session, signal, target });
814
- this.rec(session.sessionId).failoverReason = signal.errorCode;
815
- return undefined;
816
- }
817
- /** Emitted when a failover offer is generated. The API layer listens
818
- * to this to surface the banner in the web UI. */
819
- emitFailoverOffer(offer) {
820
- if (!this.failoverOffers) {
821
- this.failoverOffers = new Map();
822
- }
823
- const key = `${offer.session.sessionId}-${offer.target}`;
824
- this.failoverOffers.set(key, { offer, createdAt: Date.now() });
825
- // Auto-expire offers after 60 seconds to keep the map bounded
826
- setTimeout(() => {
827
- this.failoverOffers?.delete(key);
828
- }, 60_000);
829
- }
830
- /** Get all active failover offers for the API layer. */
831
- getFailoverOffers() {
832
- if (!this.failoverOffers)
833
- return [];
834
- const offers = [];
835
- for (const [, { offer, createdAt }] of this.failoverOffers) {
836
- offers.push({
837
- sessionId: offer.session.sessionId,
838
- signal: offer.signal,
839
- target: offer.target,
840
- expiresAt: createdAt + 60_000,
841
- });
842
- }
843
- return offers;
844
- }
845
- /** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
846
- * session on the target agent, preserving the original prompt + cwd.
847
- * P2 — injects handoff context for cross-agent continuity.
848
- * Returns the new session or undefined if the target is unavailable. */
849
- acceptFailover(sessionId, targetAgent) {
850
- const session = this.sessions.get(sessionId);
851
- if (!session)
852
- return undefined;
853
- const targetAdapter = getAdapter(targetAgent);
854
- if (!targetAdapter || !targetAdapter.isInstalled())
855
- return undefined;
856
- const cwd = this.records.get(sessionId)?.cwd ?? process.cwd();
857
- // P2 — build handoff context for continuity across agent swap
858
- const handoff = buildFailoverHandoff(cwd, {
859
- failedAgent: session.agent,
860
- failedPrompt: session.prompt,
861
- });
862
- const promptWithHandoff = handoff
863
- ? enrichPromptWithHandoff(session.prompt, {
864
- packId: handoff.packId,
865
- agent: session.agent,
866
- prompt: session.prompt,
867
- status: 'failed',
868
- timestamp: handoff.timestamp,
869
- nodeCount: handoff.nodeCount,
870
- edgeCount: handoff.edgeCount,
871
- task: handoff.task,
872
- payloadPreview: handoff.payloadPreview,
873
- })
874
- : session.prompt;
875
- const newSession = targetAdapter.spawn(promptWithHandoff, {
876
- cwd,
877
- env: process.env,
878
- preferSubscription: true,
879
- });
880
- // Track the new session
881
- this.sessions.set(newSession.sessionId, newSession);
882
- this.spawnOrder.push(newSession.sessionId);
883
- this.rec(newSession.sessionId).cwd = cwd;
884
- // Failover continues the SAME ask — display the clean prompt, not the enriched input.
885
- this.rec(newSession.sessionId).displayPrompt = this.getDisplayPrompt(session.sessionId) ?? session.prompt;
886
- this.rec(newSession.sessionId).lastActivityAt = Date.now();
887
- // Subscribe BEFORE persistence so exit events are never lost if the
888
- // adapter exits synchronously.
889
- const unsubscribe = newSession.subscribe((event) => {
890
- this.rec(newSession.sessionId).lastActivityAt = Date.now();
891
- if (event.type !== 'exit')
892
- return;
893
- this.clearStallTimer(newSession.sessionId);
894
- const data = event.data;
895
- const completedAt = Date.now();
896
- this.rec(newSession.sessionId).completedAt = completedAt;
897
- appendCompleted({
898
- id: newSession.sessionId,
899
- nativeSessionId: newSession.nativeSessionId,
900
- delegateSessionId: newSession.delegateSessionId,
901
- status: newSession.status,
902
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
903
- completedAt,
904
- steps: newSession.steps ? [...newSession.steps] : undefined,
905
- artifacts: newSession.artifacts,
906
- stalledReason: this.records.get(newSession.sessionId)?.stalled?.reason,
907
- });
908
- mirrorCompletedToThread({
909
- sessionId: newSession.sessionId,
910
- cwd,
911
- status: newSession.status,
912
- durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
913
- completedAt,
914
- steps: newSession.steps ? [...newSession.steps] : undefined,
915
- artifacts: newSession.artifacts,
916
- });
917
- const reportInput = {
918
- sessionId: newSession.sessionId,
919
- prompt: newSession.prompt,
920
- agent: newSession.agent,
921
- status: newSession.status,
922
- durationMs: completedAt - newSession.startedAt,
923
- steps: newSession.steps ? [...newSession.steps] : [],
924
- artifacts: newSession.artifacts,
925
- errorText: this.records.get(newSession.sessionId)?.errorText,
926
- delegatedTo: this.records.get(newSession.sessionId)?.delegatedTo,
927
- };
928
- generateFinalReport(reportInput)
929
- .then((report) => {
930
- if (report) {
931
- this.rec(newSession.sessionId).finalReport = report;
932
- appendFinalReport({
933
- id: newSession.sessionId,
934
- summary: report.summary,
935
- generatedAt: report.generatedAt,
936
- durationMs: report.durationMs,
937
- source: report.source,
938
- });
939
- try {
940
- printFinalReportToTerminal(reportInput, report);
941
- }
942
- catch {
943
- /* defensive */
944
- }
945
- }
946
- })
947
- .catch(() => {
948
- /* defensive */
949
- });
950
- // P1 — persist context pack at accept-failover replacement session exit.
951
- void Promise.resolve().then(() => {
952
- try {
953
- const stepsStr = newSession.steps
954
- ?.map((s) => `${s.label}: ${s.output || s.state}`)
955
- .filter(Boolean)
956
- .join('\n') ?? '';
957
- const pack = compileSessionPackSync(cwd, {
958
- prompt: newSession.prompt,
959
- artifacts: JSON.stringify(newSession.artifacts, null, 2),
960
- steps: stepsStr,
961
- agent: newSession.agent,
962
- });
963
- if (pack)
964
- persistContextPack(pack, cwd);
965
- }
966
- catch {
967
- // Silent degradation — context pack is enrichment, not primary path.
968
- }
969
- });
970
- const t = this.narrationTimers.get(newSession.sessionId);
971
- if (t) {
972
- clearInterval(t);
973
- this.narrationTimers.delete(newSession.sessionId);
974
- }
975
- this.evictOldCompleted();
976
- unsubscribe();
977
- });
978
- // Persistence + threading — safe after subscribe because the exit
979
- // handler is already registered.
980
- this.rec(sessionId).continuedAs = newSession.sessionId;
981
- this.rec(newSession.sessionId).continuedFrom = sessionId;
982
- appendSpawned({
983
- id: newSession.sessionId,
984
- agent: newSession.agent,
985
- prompt: promptWithHandoff,
986
- cwd,
987
- startedAt: newSession.startedAt,
988
- });
989
- mirrorSpawnedToThread({
990
- sessionId: newSession.sessionId,
991
- agent: newSession.agent,
992
- prompt: promptWithHandoff,
993
- cwd,
994
- startedAt: newSession.startedAt,
995
- });
996
- mirrorFailoverToThread({
997
- sessionId: newSession.sessionId,
998
- fromAgent: session.agent,
999
- toAgent: targetAgent,
1000
- reason: this.records.get(session.sessionId)?.failoverReason ?? 'error',
1001
- cwd,
1002
- });
1003
- this.startNarrationTimer(newSession);
1004
- this.startStallWatchdog(newSession);
1005
- return newSession;
1006
- }
1007
- /** v0.6 M3 — periodic narration loop per session. */
1008
- startNarrationTimer(session) {
1009
- if (process.env.CODEHERE_DISABLE_NARRATOR === '1')
1010
- return;
1011
- this.narrationStepsAtLastFire.set(session.sessionId, 0);
1012
- const intervalMs = Number(process.env.CODEHERE_NARRATOR_INTERVAL_MS ?? 30_000);
1013
- const minStepDelta = Number(process.env.CODEHERE_NARRATOR_MIN_STEPS ?? 3);
1014
- const timer = setInterval(() => {
1015
- // Skip if session has settled.
1016
- if (session.status !== 'running')
1017
- return;
1018
- const steps = session.steps ?? [];
1019
- const lastFireSteps = this.narrationStepsAtLastFire.get(session.sessionId) ?? 0;
1020
- if (steps.length - lastFireSteps < minStepDelta)
1021
- return;
1022
- // Mark BEFORE the async call to prevent double-firing if the LLM
1023
- // is slow.
1024
- this.narrationStepsAtLastFire.set(session.sessionId, steps.length);
1025
- const elapsedMs = Date.now() - session.startedAt;
1026
- void generateNarration({
1027
- sessionId: session.sessionId,
1028
- prompt: session.prompt,
1029
- agent: session.agent,
1030
- elapsedMs,
1031
- steps: [...steps],
1032
- })
1033
- .then((out) => {
1034
- if (out)
1035
- this.rec(session.sessionId).narration = out;
1036
- })
1037
- .catch(() => {
1038
- /* defensive — generateNarration never throws */
1039
- });
1040
- }, intervalMs);
1041
- this.narrationTimers.set(session.sessionId, timer);
1042
- }
1043
- /** Tier 1.1 · re-run the project's tests on the real on-disk state and replace
1044
- * the agent's SCRAPED test count with the observed ground truth, so the verdict
1045
- * checks reality, not the agent's narration. Async + best-effort: skips mock
1046
- * runs (no real suite), sessions that made no test claim, and any re-run that
1047
- * can't run; mutates the in-memory session so the live verdict refreshes. */
1048
- async observeTests(session, cwd) {
1049
- const mockMode = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
1050
- if (mockMode)
1051
- return;
1052
- if (session.status !== 'completed')
1053
- return;
1054
- const claimed = session.artifacts?.tests;
1055
- // R5 — re-run when the task is TEST-RELEVANT, not only when the agent happened
1056
- // to print a parseable count. The old `if (!claimed) return` was the root cause
1057
- // of "tests came back null on a real run": real test work whose stdout had no
1058
- // scrapeable count was never re-verified. We now re-run when there's a claim,
1059
- // OR the task changed test files, OR a step mentions tests — closing that gap
1060
- // without running the whole suite after every trivial (non-test) task.
1061
- // Test-file shapes across ecosystems: tests|spec|__tests__ dirs · *.test.* /
1062
- // *.spec.* / *_test.* suffixes (JS, Go) · pytest's canonical test_*.py PREFIX
1063
- // (devil's-advocate audit 2026-06-09: the suffix-only pattern missed exactly
1064
- // the ecosystem R4 added).
1065
- const touchedTestFiles = (session.artifacts?.files ?? []).some((f) => /(^|\/)(tests?|spec|__tests__)\//i.test(f.path) ||
1066
- /\.(test|spec)\.\w+$|_test\.\w+$/i.test(f.path) ||
1067
- /(^|\/)test_[^/]+\.py$/i.test(f.path));
1068
- // Tool-action steps only ('say'/'think' carry the agent's full prose, where a
1069
- // stray "no tests needed" would over-fire a whole suite run). \btest misses
1070
- // pytest/vitest/jest (no word boundary mid-word) — name the runners.
1071
- const stepsMentionTests = (session.steps ?? []).some((s) => s.kind !== 'say' && s.kind !== 'think' && /\btests?\b|pytest|vitest|jest/i.test(s.label));
1072
- // The verifier's whole premise is "don't trust the agent's narration." So
1073
- // also re-run when the agent CHANGED FILES and the repo has a runnable suite
1074
- // — even if the agent never mentioned tests. That's exactly the case a
1075
- // verifier must catch: an agent silently breaks the suite and says nothing
1076
- // (2026-06-14 — a real run implemented code, broke its tests, and codehere
1077
- // stayed blind because no trigger fired). Bounded: a detectable command must
1078
- // exist; the re-run is timeout-bound and opt-out via CODEHERE_RERUN_TESTS=0.
1079
- const changedFilesWithSuite = (session.artifacts?.files ?? []).length > 0 && detectTestCommand(cwd) !== null;
1080
- if (!claimed && !touchedTestFiles && !stepsMentionTests && !changedFilesWithSuite)
1081
- return;
1082
- if (this.records.get(session.sessionId)?.rerunStarted)
1083
- return;
1084
- this.rec(session.sessionId).rerunStarted = true;
1085
- try {
1086
- const result = await rerunTests(cwd);
1087
- if (!session.artifacts)
1088
- return;
1089
- // R13/M3 — applyTestObservation (domain/task.ts) is THE one owned writer
1090
- // of the post-exit tests artifact: the exit-code-authoritative merge, the
1091
- // claim-preservation rule, and (new) the explicit couldn't-observe reason
1092
- // landing in testsUnobserved instead of a silent blank (the R5 leftover —
1093
- // this test-RELEVANT session deserved a verdict input or an honest why).
1094
- applyTestObservation(session.artifacts, result);
1095
- // Re-persist so the OBSERVED verdict (or the honest couldn't-observe
1096
- // reason) survives a daemon restart. The exit handler already wrote a
1097
- // completed record from the SCRAPED artifact (before this async re-run
1098
- // landed); loadRecent dedups completed records by id newest-first, so
1099
- // this later write wins — without it, a caught failure would silently
1100
- // revert to the agent's "pass" scrape on restart.
1101
- const completedAt = this.records.get(session.sessionId)?.completedAt ?? Date.now();
1102
- appendCompleted({
1103
- id: session.sessionId,
1104
- nativeSessionId: session.nativeSessionId,
1105
- delegateSessionId: session.delegateSessionId,
1106
- status: session.status,
1107
- durationMs: completedAt - session.startedAt,
1108
- completedAt,
1109
- steps: session.steps ? [...session.steps] : undefined,
1110
- artifacts: session.artifacts,
1111
- stalledReason: this.records.get(session.sessionId)?.stalled?.reason,
1112
- delegatedTo: this.records.get(session.sessionId)?.delegatedTo,
1113
- });
1114
- }
1115
- catch {
1116
- /* re-run is enrichment; never let it break completion */
1117
- }
1118
- }
1119
- /** Tier 1.2 · reconcile the audit's declared file-writes against the real
1120
- * git-diff effect, once at completion. Read-only over the append-only audit;
1121
- * best-effort (never breaks completion). The verdict is surfaced so the audit
1122
- * isn't just "what the agent claimed" but "what codehere could confirm". */
1123
- reconcileSessionAudit(session) {
1124
- try {
1125
- // 1.3 join · include the delegate's runtime sid so a conductor-routed
1126
- // task's real writes are reconciled (else Auto tasks read 'no-writes').
1127
- const entries = collectAuditEntriesForSession(session.sessionId, undefined, session.delegateSessionId ? [session.delegateSessionId] : []);
1128
- const recon = reconcileAudit(entries, session.artifacts);
1129
- this.rec(session.sessionId).auditRecon = recon;
1130
- // 2.2 · persist the OBSERVED signal so it survives a daemon restart (the
1131
- // composite grade reads it back via PersistedSession.auditRecon).
1132
- appendAuditRecon(session.sessionId, recon);
1133
- }
1134
- catch {
1135
- /* reconciliation is enrichment; never let it break completion */
1136
- }
1137
- // R2 · sign the live chain tip now that this session's audit entries have
1138
- // landed — so a later on-disk edit/truncation is detectable by anyone
1139
- // verifying with the public key (best-effort; never blocks completion).
1140
- signAuditTip(AUDIT_FILE);
1141
- }
1142
- /**
1143
- * Tier 2.1 · auto cross-vendor adversarial review. On a successful completion,
1144
- * spawn a DIFFERENT vendor in refute-mode to independently review this work,
1145
- * so the cross-vendor proof fires automatically instead of waiting for a human
1146
- * to run a second task. Flag-gated (CODEHERE_AUTO_REVIEW=1, default OFF) — it
1147
- * spawns a real agent and costs money, so it never surprises a user.
1148
- *
1149
- * Safety: the loop guard (planAutoReview returns null for a reviewer's own
1150
- * completion via rec.isAutoReview) + a one-shot guard (autoReviewStarted) +
1151
- * the cost-bounded eligibility gate. Best-effort; never breaks completion.
1152
- */
1153
- maybeAutoReview(session, cwd) {
1154
- if (process.env.CODEHERE_AUTO_REVIEW !== '1')
1155
- return; // opt-in only
1156
- try {
1157
- const rec = this.records.get(session.sessionId);
1158
- if (rec?.autoReviewStarted)
1159
- return; // one-shot — exit can fire twice
1160
- // Only review work that actually succeeded; failures have nothing proven to refute.
1161
- if (session.status !== 'completed')
1162
- return;
1163
- // Claim the one-shot NOW — before computing the plan or spawning — so a
1164
- // second exit event (the path can fire twice) can't race past the guard
1165
- // above and spawn a duplicate reviewer. Marking an ineligible writer
1166
- // (plan === null below) is fine: eligibility is deterministic, so there's
1167
- // nothing to retry.
1168
- this.rec(session.sessionId).autoReviewStarted = true;
1169
- const writerFiles = (session.artifacts?.files ?? []).map((f) => f.path);
1170
- const installedAgents = REGISTRY.filter((a) => a.id !== 'conductor' && a.isInstalled()).map((a) => a.id);
1171
- const budgetCfg = loadBudget();
1172
- const budgetRemainingUsd = budgetCfg.dailyUsd === null
1173
- ? Number.POSITIVE_INFINITY
1174
- : Math.max(0, budgetCfg.dailyUsd - computeSpendToday().totalUsd);
1175
- const plan = planAutoReview({
1176
- writerAgent: session.agent,
1177
- writerFiles,
1178
- cwd,
1179
- installedAgents,
1180
- budgetRemainingUsd,
1181
- isAutoReviewSession: rec?.isAutoReview === true,
1182
- });
1183
- if (!plan)
1184
- return;
1185
- // Spawn the reviewer in the SAME cwd (so buildCrossVendorVerdict pairs it
1186
- // with this writer) and TAG it so its own completion can't recurse.
1187
- const reviewer = this.spawn(plan.prompt, plan.reviewerVendor, {
1188
- cwd: plan.cwd,
1189
- parentSessionId: session.sessionId,
1190
- isReview: true, // persisted → cross-vendor pairing observes the link, never infers it
1191
- });
1192
- this.rec(reviewer.sessionId).isAutoReview = true;
1193
- }
1194
- catch {
1195
- /* auto-review is enrichment; never let it break the completion path */
1196
- }
1197
- }
1198
- /** Tier 1.2 · the audit↔disk reconciliation verdict for a session, or undefined
1199
- * if not computed (still running / not reconciled). */
1200
- getAuditRecon(sessionId) {
1201
- return this.records.get(sessionId)?.auditRecon;
1202
- }
1203
- /** Stall watchdog (dogfood 2026-05-27 #1). Polls a running session; if no
1204
- * event has arrived within the stall window, kills it and records an honest
1205
- * reason. Cross-agent — opencode hung, but any adapter can. Timings are
1206
- * env-overridable so the mock harness + tests can drive it fast. Disable
1207
- * entirely with CODEHERE_DISABLE_WATCHDOG=1. */
1208
- startStallWatchdog(session) {
1209
- if (process.env.CODEHERE_DISABLE_WATCHDOG === '1')
1210
- return;
1211
- const stallMs = stallWindowFor(session.agent);
1212
- const pollMs = Number(process.env.CODEHERE_STALL_POLL_MS ?? 15_000);
1213
- if (!(stallMs > 0) || !(pollMs > 0))
1214
- return;
1215
- const timer = setInterval(() => {
1216
- if (session.status !== 'running') {
1217
- this.clearStallTimer(session.sessionId);
1218
- return;
1219
- }
1220
- const last = this.records.get(session.sessionId)?.lastActivityAt ?? session.startedAt;
1221
- const silentMs = Date.now() - last;
1222
- if (silentMs < stallMs)
1223
- return;
1224
- const reason = `Stopped after no response for ${Math.round(silentMs / 1000)}s — the agent hung without finishing.`;
1225
- this.clearStallTimer(session.sessionId);
1226
- // Stall → FAIL OVER (Ega 2026-06-08). A full-stall-window silence is NOT a
1227
- // transient timeout (the error classifier rightly skips those) — the agent
1228
- // is genuinely stuck. Hand the task to the next vendor WITH context handoff
1229
- // (handleFailover → buildFailoverHandoff) so a hung free agent (e.g. gemini
1230
- // on the free tier) RECOVERS instead of dead-ending. Falls back to the
1231
- // honest kill + stalledReason when no failover target is available.
1232
- let recovered;
1233
- try {
1234
- recovered = this.handleFailover(session, { fromAgent: session.agent, errorCode: 'stall', reason });
1235
- }
1236
- catch {
1237
- recovered = undefined;
1238
- }
1239
- this.rec(session.sessionId).stalled = {
1240
- reason: recovered ? `${reason} Handed off to ${recovered.agent}.` : reason,
1241
- stalledAt: Date.now(),
1242
- };
1243
- try {
1244
- session.kill();
1245
- }
1246
- catch {
1247
- // kill is best-effort; the recorded stall reason + 'killed' status is
1248
- // what the UI reads, so a kill that throws still surfaces honestly.
1249
- }
1250
- }, pollMs);
1251
- this.stallTimers.set(session.sessionId, timer);
1252
- }
1253
- clearStallTimer(sessionId) {
1254
- const t = this.stallTimers.get(sessionId);
1255
- if (t) {
1256
- clearInterval(t);
1257
- this.stallTimers.delete(sessionId);
1258
- }
1259
- }
1260
- /** Watchdog verdict for a session — the honest "why it stopped" reason when
1261
- * the stall watchdog killed it, or undefined if it settled on its own.
1262
- * The API layer surfaces this so the dashboard never shows a hung task as a
1263
- * normal row. */
1264
- getStallReason(sessionId) {
1265
- return this.records.get(sessionId)?.stalled?.reason;
1266
- }
1267
- /** The clean user prompt for display — strips the graphify/hand-off enrichment
1268
- * that lives on session.prompt (the agent's input). Undefined for sessions
1269
- * not spawned this process-lifetime (their persisted prompt is already clean). */
1270
- getDisplayPrompt(sessionId) {
1271
- return this.records.get(sessionId)?.displayPrompt;
1272
- }
1273
- /** v0.6 M3 — latest narration for a session, or undefined. */
1274
- getNarration(sessionId) {
1275
- return this.records.get(sessionId)?.narration;
1276
- }
1277
- /** Persisted (read-only) sessions from prior daemon runs. Cached at boot. */
1278
- listPersisted() {
1279
- return this.persistedCache;
1280
- }
1281
- /** S3 · mirror a just-written 'resolved' record into the boot cache, so the
1282
- * acknowledged badge survives a page reload WITHOUT a daemon restart. The
1283
- * acceptance walk caught the gap: appendResolved hit disk, but /api/tasks
1284
- * serves this cache — the UI wiped its own ack on the next poll. */
1285
- markPersistedResolved(sessionId, resolvedAt) {
1286
- const rec = this.persistedCache.find((p) => p.sessionId === sessionId);
1287
- if (rec)
1288
- rec.resolvedAt = resolvedAt;
1289
- }
1290
- /** S13 refuter · mirror a zombie-record bookkeeping kill into the boot
1291
- * cache (same reload-vs-restart gap as markPersistedResolved). */
1292
- markPersistedKilled(sessionId) {
1293
- const rec = this.persistedCache.find((p) => p.sessionId === sessionId);
1294
- if (rec) {
1295
- rec.status = 'killed';
1296
- rec.completedAt = Date.now();
1297
- }
1298
- }
1299
- /** Completion timestamp for an in-memory session, if it has exited.
1300
- * Returns undefined if the session is still running OR was never spawned
1301
- * through this manager. Bug 2 sister-fix (May 2): TaskSession itself has
1302
- * no completedAt field; this method lets the API layer compute durations. */
1303
- getCompletedAt(sessionId) {
1304
- return this.records.get(sessionId)?.completedAt;
1305
- }
1306
- /** v0.6.x Slack-shape spike — return cwd for an in-memory session. */
1307
- getCwd(sessionId) {
1308
- return this.records.get(sessionId)?.cwd;
1309
- }
1310
- /** The specialist the Conductor routed this session to, if it was an Auto/
1311
- * conductor task — so the UI can name the agent that actually ran. */
1312
- getDelegatedTo(sessionId) {
1313
- return this.records.get(sessionId)?.delegatedTo;
1314
- }
1315
- /** It-4 V1-MULTITASK — batchId for an in-memory session, if it was spawned as
1316
- * part of a batch (@all fanout / multi-task project). Lets /api/tasks expose
1317
- * batchId on LIVE sessions so the room rollup row derives before any restart. */
1318
- getBatchId(sessionId) {
1319
- return this.records.get(sessionId)?.batchId;
1320
- }
1321
- /** Session threading — which session did this one continue from (after failover)? */
1322
- getContinuedFrom(sessionId) {
1323
- return this.records.get(sessionId)?.continuedFrom;
1324
- }
1325
- /** Session threading — which session did this one continue as (after failover)? */
1326
- getContinuedAs(sessionId) {
1327
- return this.records.get(sessionId)?.continuedAs;
1328
- }
1329
- /** Recovery lineage — if this session is a failover REPLACEMENT, which agent it
1330
- * took over from + why. Drives the dashboard "recovered from <vendor>" badge.
1331
- * undefined for a normal (non-recovered) session. */
1332
- getRecoveredFrom(sessionId) {
1333
- return this.records.get(sessionId)?.recoveredFrom;
1334
- }
1335
- /** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
1336
- * Returns undefined while generation is in flight OR if the provider chain
1337
- * failed (no claude-free, no claude, no credit, etc). The web UI reads this
1338
- * through the /api/tasks serializer to render the prose summary inline. */
1339
- getFinalReport(sessionId) {
1340
- return this.records.get(sessionId)?.finalReport;
1341
- }
1342
- /** REQUEST_METHOD §2 — the acceptance contract for a session, served through
1343
- * /api/tasks so the room can render "Done means …". */
1344
- getAcceptanceCriteria(sessionId) {
1345
- return this.records.get(sessionId)?.acceptanceCriteria;
1346
- }
1347
- /** Returns true if Final Report generation explicitly failed for this session.
1348
- * Lets the API distinguish "not yet" from "never coming". */
1349
- hasFailedReport(sessionId) {
1350
- return this.records.get(sessionId)?.failedReport === true;
1351
- }
1352
- get(sessionId) {
1353
- return this.sessions.get(sessionId);
1354
- }
1355
- /**
1356
- * Reconstruct an APPENDABLE handle for a completed session no longer in memory
1357
- * (e.g. after a daemon restart), from its persisted record. claude keeps its
1358
- * own session store, so `claude --resume <nativeSessionId>` in the original cwd
1359
- * continues the goal with full context — no live session object needed. Only
1360
- * claude-code today (it has a stable native resume token); undefined otherwise.
1361
- * This is what makes `codehere reply <id>` survive a daemon restart.
1362
- */
1363
- reconstructAppendable(sessionId) {
1364
- const rec = loadSessionById(sessionId);
1365
- if (!rec || rec.agent !== 'claude-code' || !rec.nativeSessionId || !rec.cwd)
1366
- return undefined;
1367
- const native = rec.nativeSessionId;
1368
- const cwd = rec.cwd;
1369
- return {
1370
- sessionId: rec.sessionId,
1371
- startedAt: rec.startedAt,
1372
- prompt: rec.prompt,
1373
- agent: rec.agent,
1374
- status: rec.status,
1375
- cwd,
1376
- canAppendMessage: true,
1377
- subscribe: () => () => { },
1378
- kill: () => { },
1379
- appendMessage: (text) => resumeClaudeMessage(native, cwd, text),
1380
- };
1381
- }
1382
- /** Evict the oldest completed sessions when the in-memory cap is reached.
1383
- * Prevents unbounded memory growth in the internal tracking maps. The
1384
- * data survives on disk via sessions.jsonl + thread.jsonl. */
1385
- evictOldCompleted() {
1386
- const completed = [];
1387
- for (const id of this.spawnOrder) {
1388
- const at = this.records.get(id)?.completedAt;
1389
- if (at !== undefined)
1390
- completed.push({ id, completedAt: at });
1391
- }
1392
- if (completed.length <= SessionManager.MAX_COMPLETED_SESSIONS)
1393
- return;
1394
- const toEvict = completed.slice(0, completed.length - SessionManager.MAX_COMPLETED_SESSIONS);
1395
- const evictSet = new Set(toEvict.map((e) => e.id));
1396
- // MED #10 (atomic audit) · spawnOrder was never trimmed — it grew for the
1397
- // daemon's life and the eviction scan degraded with it.
1398
- this.spawnOrder = this.spawnOrder.filter((id) => !evictSet.has(id));
1399
- for (const id of evictSet) {
1400
- const t = this.narrationTimers.get(id);
1401
- if (t) {
1402
- clearInterval(t);
1403
- this.narrationTimers.delete(id);
1404
- }
1405
- this.clearStallTimer(id);
1406
- this.sessions.delete(id);
1407
- this.records.delete(id);
1408
- }
1409
- }
1410
- list() {
1411
- return Array.from(this.sessions.values());
1412
- }
1413
- kill(sessionId) {
1414
- const session = this.sessions.get(sessionId);
1415
- if (!session)
1416
- return false;
1417
- session.kill();
1418
- return true;
1419
- }
1420
- /** Graceful shutdown: kill all running sessions and clear all timers. */
1421
- shutdown() {
1422
- for (const [, timer] of this.narrationTimers) {
1423
- clearInterval(timer);
1424
- }
1425
- this.narrationTimers.clear();
1426
- for (const [, timer] of this.stallTimers) {
1427
- clearInterval(timer);
1428
- }
1429
- this.stallTimers.clear();
1430
- for (const session of this.sessions.values()) {
1431
- if (session.status === 'running')
1432
- session.kill();
1433
- }
1434
- }
1435
- /**
1436
- * Active Claude Code sessions on this machine, derived from the audit log.
1437
- * A session is "active" if any tool call from it was recorded within the
1438
- * last `maxAgeMs` milliseconds. Returns most-recent first.
1439
- *
1440
- * Note: codehere-spawned sessions may also appear here because they produce
1441
- * audit entries just like any other Claude Code session. The UI distinguishes
1442
- * by provenance.
1443
- */
1444
- listObserved(maxAgeMs = DEFAULT_OBSERVED_WINDOW_MS) {
1445
- if (!existsSync(AUDIT_FILE))
1446
- return [];
1447
- const now = Date.now();
1448
- const cutoff = now - maxAgeMs;
1449
- const byId = new Map();
1450
- let content;
1451
- try {
1452
- content = readFileSync(AUDIT_FILE, 'utf-8');
1453
- }
1454
- catch {
1455
- return [];
1456
- }
1457
- const lines = content.split('\n');
1458
- // Walk backwards — most recent lines first — and stop early when we
1459
- // pass the window. Audit log appends chronologically.
1460
- for (let i = lines.length - 1; i >= 0; i--) {
1461
- const line = lines[i].trim();
1462
- if (!line)
1463
- continue;
1464
- let entry;
1465
- try {
1466
- entry = JSON.parse(line);
1467
- }
1468
- catch {
1469
- continue;
1470
- }
1471
- if (!entry.sessionId)
1472
- continue;
1473
- const ts = typeof entry.timestamp === 'number'
1474
- ? entry.timestamp
1475
- : entry.timestamp
1476
- ? Date.parse(String(entry.timestamp))
1477
- : 0;
1478
- if (!ts)
1479
- continue;
1480
- if (ts < cutoff)
1481
- break; // past the window; older entries can't qualify
1482
- const existing = byId.get(entry.sessionId);
1483
- if (existing) {
1484
- existing.eventCount += 1;
1485
- if (ts < existing.firstEventAt)
1486
- existing.firstEventAt = ts;
1487
- }
1488
- else {
1489
- byId.set(entry.sessionId, {
1490
- sessionId: entry.sessionId,
1491
- firstEventAt: ts,
1492
- lastEventAt: ts,
1493
- eventCount: 1,
1494
- recentTool: entry.toolName,
1495
- provenance: 'observed',
1496
- });
1497
- }
1498
- }
1499
- return Array.from(byId.values()).sort((a, b) => b.lastEventAt - a.lastEventAt);
1500
- }
1501
- /**
1502
- * Agent processes running on this machine right now.
1503
- * Enumerates via `ps`, filters to known agent executables, returns most
1504
- * recently started first. This is the primary F1d surface — it reflects
1505
- * "what AI agents are alive on your machine" independent of whether
1506
- * they've produced audit events yet.
1507
- */
1508
- listActiveAgents() {
1509
- let out;
1510
- try {
1511
- out = execFileSync('ps', ['-A', '-o', 'pid,tty,etime,args'], {
1512
- encoding: 'utf-8',
1513
- timeout: 2000,
1514
- });
1515
- }
1516
- catch {
1517
- return [];
1518
- }
1519
- const agents = [];
1520
- const lines = out.trim().split('\n').slice(1); // drop header
1521
- for (const line of lines) {
1522
- const m = line.trim().match(/^(\d+)\s+(\S+)\s+(\S+)\s+(.+)$/);
1523
- if (!m)
1524
- continue;
1525
- const [, pidStr, tty, etime, args] = m;
1526
- // Skip ourselves — the codehere daemon would otherwise show up repeatedly
1527
- if (/\bcodehere\b|\bch\s+start\b/.test(args))
1528
- continue;
1529
- // Skip the audit tailer from other codehere commands
1530
- if (/scan-tool|codehere-audit/.test(args))
1531
- continue;
1532
- let matched = null;
1533
- for (const p of agentProcessPatterns()) {
1534
- if (p.regex.test(args)) {
1535
- matched = { agent: p.agent, label: p.label };
1536
- break;
1537
- }
1538
- }
1539
- if (!matched)
1540
- continue;
1541
- const uptimeSeconds = parseEtime(etime);
1542
- const startedAt = Date.now() - uptimeSeconds * 1000;
1543
- agents.push({
1544
- pid: parseInt(pidStr, 10),
1545
- tty,
1546
- agent: matched.agent,
1547
- label: matched.label,
1548
- args: args.length > 200 ? args.slice(0, 200) + '…' : args,
1549
- startedAt,
1550
- uptimeSeconds,
1551
- provenance: 'detected',
1552
- });
1553
- }
1554
- return agents.sort((a, b) => b.startedAt - a.startedAt);
1555
- }
1556
- /** Subscribe to live-progress ticks (steps/say/exit). Returns an unsubscribe
1557
- * fn. The SSE endpoint uses this to push 'change' mid-run, not just when
1558
- * sessions.jsonl grows on disk. Listener cap is lifted because each
1559
- * connected browser tab adds one. */
1560
- onLiveChange(cb) {
1561
- this.liveChanges.setMaxListeners(0);
1562
- this.liveChanges.on('change', cb);
1563
- return () => this.liveChanges.off('change', cb);
1564
- }
1565
- static _instance;
1566
- static getInstance() {
1567
- return (this._instance ??= new SessionManager());
1568
- }
1569
- /** @internal Resets the singleton for test isolation. */
1570
- static __testOnlyReset() {
1571
- this._instance = undefined;
1572
- }
1573
- }
1574
- // ── Test seam ─────────────────────────────────────────────────────────────────
1575
- // Avoids refactoring the module-level REGISTRY const to DI. Usage:
1576
- // import { __testOnlyRegisterAdapter, SessionManager } from '...';
1577
- // __testOnlyRegisterAdapter(mockAdapter);
1578
- // SessionManager.__testOnlyReset();
1579
- // const sm = SessionManager.getInstance();
1580
- /** @internal Exported for testing only — allows mock adapter injection. */
1581
- export function __testOnlyRegisterAdapter(adapter) {
1582
- REGISTRY.push(adapter);
1583
- }
1584
- /** @internal Resets REGISTRY to BUILTIN_REGISTRY for test isolation.
1585
- * Call before __testOnlyRegisterAdapter to avoid adapter accumulation. */
1586
- export function __testOnlyResetRegistry() {
1587
- REGISTRY.length = 0;
1588
- REGISTRY.push(...BUILTIN_REGISTRY);
1589
- }
1590
- //# sourceMappingURL=session-manager.js.map