codehere 0.6.2 → 0.7.1

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 (73) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +87 -168
  4. package/dist/application/use-cases/generate-narration.js +11 -0
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +211 -16
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
  23. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  24. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  25. package/dist/infrastructure/orchestration/aider-adapter.js +49 -18
  26. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  27. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  28. package/dist/infrastructure/orchestration/claude-code-adapter.js +38 -5
  29. package/dist/infrastructure/orchestration/codex-adapter.js +150 -22
  30. package/dist/infrastructure/orchestration/conductor-adapter.js +9 -3
  31. package/dist/infrastructure/orchestration/context-handoff.js +3 -2
  32. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  33. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  34. package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
  35. package/dist/infrastructure/orchestration/graphify-context.js +18 -13
  36. package/dist/infrastructure/orchestration/memory-injector.js +46 -1
  37. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +230 -0
  38. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  39. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  40. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  41. package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
  42. package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
  43. package/dist/infrastructure/orchestration/session-manager.js +112 -6
  44. package/dist/infrastructure/orchestration/session-persistence.js +140 -31
  45. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  46. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  47. package/dist/infrastructure/protocols/cloud-sync.js +1 -6
  48. package/dist/infrastructure/protocols/mcp-server-impl.js +1283 -13
  49. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  50. package/dist/infrastructure/protocols/ui-page-v2.js +3913 -88
  51. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  52. package/dist/infrastructure/protocols/verify-page.js +941 -0
  53. package/dist/infrastructure/security/trust-scorer.js +76 -1
  54. package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
  55. package/dist/infrastructure/storage/project-thread-migration.js +2 -2
  56. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  57. package/dist/monitoring.js +1 -1
  58. package/dist/presentation/cli/commands/audit-command.js +126 -0
  59. package/dist/presentation/cli/commands/config-command.js +2 -2
  60. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  61. package/dist/presentation/cli/commands/help-command.js +51 -1
  62. package/dist/presentation/cli/commands/report-command.js +6 -4
  63. package/dist/presentation/cli/commands/setup-command.js +71 -18
  64. package/dist/presentation/cli/commands/status-command.js +0 -4
  65. package/dist/presentation/cli/commands/thread-command.js +288 -0
  66. package/dist/presentation/cli/commands/verify-command.js +233 -0
  67. package/dist/presentation/cli/error-display.js +1 -1
  68. package/dist/presentation/cli/repl.js +32 -1
  69. package/dist/presentation/cli/task-deliverable.js +127 -0
  70. package/dist/testing/cassette.js +125 -0
  71. package/dist/testing/replay-adapter.js +166 -0
  72. package/dist/ui.js +18 -1
  73. package/package.json +5 -2
@@ -159,11 +159,47 @@ export class ProgressAggregator {
159
159
  return this.ingestGemini(type, data);
160
160
  if (this.agentId === 'aider')
161
161
  return this.ingestAider(type, data);
162
+ if (this.agentId === 'opencode')
163
+ return this.ingestOpencode(type, data);
162
164
  return false;
163
165
  }
166
+ // OpenCode · `run --format json` emits tool_use with shape
167
+ // { type:'tool_use', part:{ tool, state:{ input, output, status } } }. Was
168
+ // never wired into the aggregator (opencode showed ZERO timeline steps — a
169
+ // real gap surfaced by the mock harness). Build a step on the terminal
170
+ // (status==='completed') event so opencode is a first-class agent.
171
+ ingestOpencode(type, data) {
172
+ if (type !== 'tool_use')
173
+ return false;
174
+ const part = (data.part ?? {});
175
+ if (part.state?.status && part.state.status !== 'completed')
176
+ return false; // skip in-flight
177
+ const map = {
178
+ bash: 'Bash',
179
+ read: 'Read',
180
+ write: 'Write',
181
+ edit: 'Edit',
182
+ grep: 'Grep',
183
+ glob: 'Glob',
184
+ webfetch: 'WebFetch',
185
+ websearch: 'WebSearch',
186
+ };
187
+ const tool = map[String(part.tool ?? '').toLowerCase()] || 'Bash';
188
+ const label = labelForClaudeToolUse(tool, part.state?.input);
189
+ const output = typeof part.state?.output === 'string' ? part.state.output : '';
190
+ this._steps.push({
191
+ label,
192
+ state: 'done',
193
+ startedAt: Date.now(),
194
+ completedAt: Date.now(),
195
+ ...(output ? { output: truncateOutput(output) } : {}),
196
+ });
197
+ return true;
198
+ }
164
199
  /** Flush: mark every still-active step as failed (called on abnormal exit). */
165
200
  finalize(status) {
166
- let changed = false;
201
+ // Surface any trailing aider reasoning that had no following action.
202
+ let changed = this.flushAiderSay();
167
203
  for (const s of this._steps) {
168
204
  if (s.state === 'active' || s.state === 'pending') {
169
205
  s.state = status === 'completed' ? 'done' : 'failed';
@@ -184,7 +220,22 @@ export class ProgressAggregator {
184
220
  let changed = false;
185
221
  for (const blk of content) {
186
222
  const b = (blk && typeof blk === 'object' ? blk : {});
187
- if (b.type === 'tool_use') {
223
+ if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) {
224
+ // The agent's own words/reasoning. In claude's stream the text block
225
+ // precedes the tool_use in the same message — capture it first so the
226
+ // timeline reads "explains → acts". This is the "watch it work" signal
227
+ // the agent already produces; we were dropping it.
228
+ const said = b.text.trim();
229
+ this._steps.push({
230
+ kind: 'say',
231
+ label: said.length > 500 ? said.slice(0, 500) + '\\u2026' : said,
232
+ state: 'done',
233
+ startedAt: Date.now(),
234
+ completedAt: Date.now(),
235
+ });
236
+ changed = true;
237
+ }
238
+ else if (b.type === 'tool_use') {
188
239
  const id = String(b.id ?? '');
189
240
  const label = labelForClaudeToolUse(String(b.name ?? ''), b.input);
190
241
  const idx = this.addStep(label, 'active');
@@ -316,7 +367,6 @@ export class ProgressAggregator {
316
367
  * blocking parity. v0.5 ships observability; v0.5.x or v0.6 ships blocking
317
368
  * once Path A merges.
318
369
  */
319
- // eslint-disable-next-line no-control-regex
320
370
  static ANSI_RE = /\x1b\[[0-9;]*m/g;
321
371
  /**
322
372
  * Per-instance set of file paths aider just created via "Creating empty file ...".
@@ -341,6 +391,23 @@ export class ProgressAggregator {
341
391
  */
342
392
  aiderRunningStepIdx = null;
343
393
  aiderOutputBuffer = [];
394
+ /**
395
+ * A1 (PRODUCT_STATE §0 NEXT #1) — Aider's natural-language reasoning.
396
+ * Aider streams its plain-English explanation to stdout BEFORE the
397
+ * SEARCH/REPLACE blocks and "Applied edit" lines. We were dropping every
398
+ * such line. Mirror ingestClaude's text-block handling: collect consecutive
399
+ * prose lines (skipping diff fences + SEARCH/REPLACE markers + aider's own
400
+ * status noise) and flush them as ONE kind:'say' step before the next
401
+ * action — the "watch it work" signal across aider, not just claude.
402
+ */
403
+ aiderSayBuffer = [];
404
+ aiderInFence = false;
405
+ aiderInDiff = false;
406
+ /** Aider stdout lines that are status/noise, never reasoning prose. The
407
+ * "must contain a space + length >= 4" guard in collectAiderSay already
408
+ * drops bare filenames, fence langs and spinner rules; this catches the
409
+ * noisy lines that DO contain spaces. */
410
+ static AIDER_NONPROSE_RE = /^(=======\s*$|Tokens:|Cost:|Commit [0-9a-f]|Applied edit to |Running |Creating empty file |Did not |Added \S+ to the chat|Removed \S+ from the chat|No changes made|Warning: |Scanning repo|Initial repo scan|Repo-map:|Main model:|Weak model:|Model:|Git repo:|Use \/|aider |Committing )/;
344
411
  aiderFlushOutput() {
345
412
  if (this.aiderRunningStepIdx === null)
346
413
  return;
@@ -356,6 +423,50 @@ export class ProgressAggregator {
356
423
  this.aiderRunningStepIdx = null;
357
424
  this.aiderOutputBuffer = [];
358
425
  }
426
+ /** Flush buffered aider prose as one kind:'say' step. Returns true if a step
427
+ * was pushed (so finalize can fold it into its changed flag). */
428
+ flushAiderSay() {
429
+ if (this.aiderSayBuffer.length === 0)
430
+ return false;
431
+ const said = this.aiderSayBuffer.join(' ').replace(/\s+/g, ' ').trim();
432
+ this.aiderSayBuffer = [];
433
+ if (!said)
434
+ return false;
435
+ this._steps.push({
436
+ kind: 'say',
437
+ label: said.length > 500 ? said.slice(0, 500) + '…' : said,
438
+ state: 'done',
439
+ startedAt: Date.now(),
440
+ completedAt: Date.now(),
441
+ });
442
+ return true;
443
+ }
444
+ /** Collect one aider stdout line as reasoning prose unless it's a fence, a
445
+ * SEARCH/REPLACE marker/body, status noise, or a bare token. */
446
+ collectAiderSay(line) {
447
+ if (line.startsWith('```')) {
448
+ this.aiderInFence = !this.aiderInFence;
449
+ return;
450
+ }
451
+ if (this.aiderInFence)
452
+ return;
453
+ if (/^<<<<<<< SEARCH/.test(line)) {
454
+ this.aiderInDiff = true;
455
+ return;
456
+ }
457
+ if (/^>>>>>>> REPLACE/.test(line)) {
458
+ this.aiderInDiff = false;
459
+ return;
460
+ }
461
+ if (this.aiderInDiff)
462
+ return;
463
+ if (ProgressAggregator.AIDER_NONPROSE_RE.test(line))
464
+ return;
465
+ // Require a space + minimum length so filenames/code tokens don't leak in.
466
+ if (line.length < 4 || !line.includes(' '))
467
+ return;
468
+ this.aiderSayBuffer.push(line);
469
+ }
359
470
  ingestAider(type, data) {
360
471
  if (type !== 'stdout')
361
472
  return false;
@@ -377,6 +488,7 @@ export class ProgressAggregator {
377
488
  // P22 slice 4 — flush the previous Running step's buffer before
378
489
  // starting the new one. New step is the active output target.
379
490
  this.aiderFlushOutput();
491
+ this.flushAiderSay(); // emit any reasoning prose that preceded this run
380
492
  const idx = this._steps.length;
381
493
  this._steps.push({
382
494
  label: truncate('Run ' + m[1]),
@@ -394,6 +506,7 @@ export class ProgressAggregator {
394
506
  // P22 slice 4 — flush any in-flight running-step output before
395
507
  // recording the edit (which is a new aider-pattern signal).
396
508
  this.aiderFlushOutput();
509
+ this.flushAiderSay(); // emit reasoning prose that preceded this edit
397
510
  // If aider JUST created this file, the Write step already represents the
398
511
  // action; skip the Edit step that aider emits for the initial content.
399
512
  // Clear from the set after consuming so subsequent edits to the same
@@ -415,6 +528,7 @@ export class ProgressAggregator {
415
528
  if (m) {
416
529
  const path = m[1];
417
530
  this.aiderFlushOutput();
531
+ this.flushAiderSay(); // emit reasoning prose that preceded this write
418
532
  this.aiderJustCreated.add(path);
419
533
  // Use "Write" to align with Claude/Gemini's net-new-file label vocabulary.
420
534
  this._steps.push({
@@ -439,6 +553,11 @@ export class ProgressAggregator {
439
553
  step.output = truncateOutput(this.aiderOutputBuffer.join('\n'));
440
554
  return true; // mark as "step changed" so the SSE re-emits codehere:steps
441
555
  }
556
+ // Outside a Running window: aider's natural-language reasoning. Buffer it
557
+ // (skipping diff fences + markers + status noise); flushed as one kind:'say'
558
+ // step before the next action or at finalize. (PRODUCT_STATE §0 NEXT #1)
559
+ if (this.aiderRunningStepIdx === null)
560
+ this.collectAiderSay(line);
442
561
  return false;
443
562
  }
444
563
  ingestCodex(type, data) {
@@ -474,6 +593,61 @@ export class ProgressAggregator {
474
593
  });
475
594
  return true;
476
595
  }
596
+ // file_change — codex emits one item per Edit/Write/Delete batch. The audit
597
+ // mirror (codex-adapter.ts) already handles these; the timeline-step path was
598
+ // dropping them, so real codex file edits were invisible in "watch it work"
599
+ // (mock-currency audit 2026-06-02 caught this — the mock emits the real shape).
600
+ if (itemType === 'file_change') {
601
+ const changes = Array.isArray(item.changes)
602
+ ? item.changes
603
+ : [];
604
+ let changed = false;
605
+ for (const ch of changes) {
606
+ const path = String(ch.path ?? '');
607
+ const kind = String(ch.kind ?? 'update');
608
+ const toolName = kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit';
609
+ // Label directly so the path always shows (labelForClaudeToolUse only
610
+ // knows Edit/Write/Read — it drops the path for Delete).
611
+ this._steps.push({
612
+ label: path ? toolName + ' ' + path : toolName,
613
+ state: 'done',
614
+ startedAt: Date.now(),
615
+ completedAt: Date.now(),
616
+ });
617
+ changed = true;
618
+ }
619
+ return changed;
620
+ }
621
+ // mcp_tool_call + web_search — real visible actions the codex-adapter mirrors
622
+ // to audit; surface them as timeline steps too (2026-06-02 follow-up, Ega-approved
623
+ // class: "watch it work"). Labels mirror codex-adapter conventions.
624
+ if (itemType === 'mcp_tool_call') {
625
+ const server = String(item.server ?? '');
626
+ const tool = String(item.tool ?? 'mcp');
627
+ this._steps.push({
628
+ label: 'MCP · ' + (server ? server + '.' : '') + tool,
629
+ state: 'done',
630
+ startedAt: Date.now(),
631
+ completedAt: Date.now(),
632
+ });
633
+ return true;
634
+ }
635
+ if (itemType === 'web_search') {
636
+ const q = String(item.query ?? '').trim().slice(0, 80);
637
+ this._steps.push({
638
+ label: q ? 'Web search · ' + q : 'Web search',
639
+ state: 'done',
640
+ startedAt: Date.now(),
641
+ completedAt: Date.now(),
642
+ });
643
+ return true;
644
+ }
645
+ // NOTE (mock-currency 2026-06-02): codex `agent_message` is intentionally NOT
646
+ // captured here — a prior decision (pm-layers.test.ts "just chatter, not a
647
+ // step") drops it. This is INCONSISTENT with ingestClaude/ingestAider, which
648
+ // DO capture agent narration as kind:'say' (per #45 + A1 "watch it work").
649
+ // Resolving that inconsistency is a product taste call → flagged for Ega in
650
+ // mock-currency-2026-06-02.md, not changed unilaterally by the autonomous loop.
477
651
  return false;
478
652
  }
479
653
  }
@@ -20,6 +20,7 @@ import { OpenCodeAdapter } from './opencode-adapter.js';
20
20
  import { GeminiAdapter } from './gemini-adapter.js';
21
21
  import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js';
22
22
  import { buildConductorAdapter } from './conductor-adapter.js';
23
+ import { recordTurnToWorkingMemory } from './memory-injector.js';
23
24
  import { seedAgentsConfig } from './agents-config-seed.js';
24
25
  import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
25
26
  import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
@@ -30,7 +31,7 @@ import { generateNarration } from '../../application/use-cases/generate-narratio
30
31
  import { randomUUID } from 'crypto';
31
32
  import { classifyFailoverEligibility } from './failover-classifier.js';
32
33
  import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
33
- import { loadFailoverConfig, resolveChain } from './failover-config.js';
34
+ import { loadFailoverConfig } from './failover-config.js';
34
35
  import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
35
36
  import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
36
37
  import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
@@ -151,6 +152,14 @@ export class SessionManager {
151
152
  narrationBySession = new Map();
152
153
  narrationTimers = new Map();
153
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();
154
163
  /** Stores active failover offers keyed by sessionId+target. API layer
155
164
  * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
156
165
  failoverOffers;
@@ -171,7 +180,11 @@ export class SessionManager {
171
180
  if (!adapter) {
172
181
  throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
173
182
  }
174
- if (!adapter.isInstalled()) {
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()) {
175
188
  throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
176
189
  }
177
190
  // Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
@@ -221,7 +234,10 @@ export class SessionManager {
221
234
  cwd,
222
235
  startedAt: session.startedAt,
223
236
  });
237
+ this.lastActivityBySession.set(session.sessionId, Date.now());
224
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());
225
241
  // ── Failover check — intercept rate-limit / quota errors before exit ──
226
242
  // Adapters emit 'codehere:error' (structured: { code, message, recovery })
227
243
  // or 'error' (spawn-level: { message }). Both need classification.
@@ -251,6 +267,7 @@ export class SessionManager {
251
267
  completedAt,
252
268
  steps: session.steps ? [...session.steps] : undefined,
253
269
  artifacts: session.artifacts,
270
+ stalledReason: this.stalledBySession.get(session.sessionId)?.reason,
254
271
  });
255
272
  // v0.6.0 M1+M7 — forward-mirror completion to project thread.
256
273
  mirrorCompletedToThread({
@@ -292,7 +309,22 @@ export class SessionManager {
292
309
  summary: report.summary,
293
310
  generatedAt: report.generatedAt,
294
311
  durationMs: report.durationMs,
312
+ source: report.source,
295
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
+ }
296
328
  try {
297
329
  printFinalReportToTerminal(reportInput, report);
298
330
  }
@@ -334,6 +366,8 @@ export class SessionManager {
334
366
  clearInterval(t);
335
367
  this.narrationTimers.delete(session.sessionId);
336
368
  }
369
+ // Watchdog: session settled on its own — stop watching it.
370
+ this.clearStallTimer(session.sessionId);
337
371
  this.evictOldCompleted();
338
372
  unsubscribe();
339
373
  });
@@ -344,6 +378,7 @@ export class SessionManager {
344
378
  // failures (no claude binary, timeout, malformed output) silently
345
379
  // skip — narration is enrichment, not the primary surface.
346
380
  this.startNarrationTimer(session);
381
+ this.startStallWatchdog(session);
347
382
  return session;
348
383
  }
349
384
  /** Tracks which agents have already been tried for a given prompt during
@@ -355,7 +390,6 @@ export class SessionManager {
355
390
  * session if auto-spawn was triggered, undefined otherwise. */
356
391
  handleFailover(session, signal) {
357
392
  const config = loadFailoverConfig();
358
- const chain = resolveChain(config?.chain);
359
393
  const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
360
394
  tried.add(signal.fromAgent);
361
395
  this.failoverTriedBySession.set(session.sessionId, tried);
@@ -408,11 +442,14 @@ export class SessionManager {
408
442
  this.sessions.set(newSession.sessionId, newSession);
409
443
  this.spawnOrder.push(newSession.sessionId);
410
444
  this.cwdBySession.set(newSession.sessionId, cwd);
445
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
411
446
  // Subscribe BEFORE persistence so exit events are never lost if the
412
447
  // adapter exits synchronously (command not found, instant crash, etc).
413
448
  const unsubscribe = newSession.subscribe((event) => {
449
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
414
450
  if (event.type !== 'exit')
415
451
  return;
452
+ this.clearStallTimer(newSession.sessionId);
416
453
  const data = event.data;
417
454
  const completedAt = Date.now();
418
455
  this.completedAtBySession.set(newSession.sessionId, completedAt);
@@ -423,6 +460,7 @@ export class SessionManager {
423
460
  completedAt,
424
461
  steps: newSession.steps ? [...newSession.steps] : undefined,
425
462
  artifacts: newSession.artifacts,
463
+ stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
426
464
  });
427
465
  mirrorCompletedToThread({
428
466
  sessionId: newSession.sessionId,
@@ -451,6 +489,7 @@ export class SessionManager {
451
489
  summary: report.summary,
452
490
  generatedAt: report.generatedAt,
453
491
  durationMs: report.durationMs,
492
+ source: report.source,
454
493
  });
455
494
  try {
456
495
  printFinalReportToTerminal(reportInput, report);
@@ -519,6 +558,7 @@ export class SessionManager {
519
558
  this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
520
559
  this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
521
560
  this.startNarrationTimer(newSession);
561
+ this.startStallWatchdog(newSession);
522
562
  return newSession;
523
563
  }
524
564
  catch {
@@ -539,7 +579,6 @@ export class SessionManager {
539
579
  this.failoverOffers = new Map();
540
580
  }
541
581
  const key = `${offer.session.sessionId}-${offer.target}`;
542
- const expiresAt = Date.now() + 60_000;
543
582
  this.failoverOffers.set(key, { offer, createdAt: Date.now() });
544
583
  // Auto-expire offers after 60 seconds to keep the map bounded
545
584
  setTimeout(() => {
@@ -551,7 +590,7 @@ export class SessionManager {
551
590
  if (!this.failoverOffers)
552
591
  return [];
553
592
  const offers = [];
554
- for (const [key, { offer, createdAt }] of this.failoverOffers) {
593
+ for (const [, { offer, createdAt }] of this.failoverOffers) {
555
594
  offers.push({
556
595
  sessionId: offer.session.sessionId,
557
596
  signal: offer.signal,
@@ -600,11 +639,14 @@ export class SessionManager {
600
639
  this.sessions.set(newSession.sessionId, newSession);
601
640
  this.spawnOrder.push(newSession.sessionId);
602
641
  this.cwdBySession.set(newSession.sessionId, cwd);
642
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
603
643
  // Subscribe BEFORE persistence so exit events are never lost if the
604
644
  // adapter exits synchronously.
605
645
  const unsubscribe = newSession.subscribe((event) => {
646
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
606
647
  if (event.type !== 'exit')
607
648
  return;
649
+ this.clearStallTimer(newSession.sessionId);
608
650
  const data = event.data;
609
651
  const completedAt = Date.now();
610
652
  this.completedAtBySession.set(newSession.sessionId, completedAt);
@@ -615,6 +657,7 @@ export class SessionManager {
615
657
  completedAt,
616
658
  steps: newSession.steps ? [...newSession.steps] : undefined,
617
659
  artifacts: newSession.artifacts,
660
+ stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
618
661
  });
619
662
  mirrorCompletedToThread({
620
663
  sessionId: newSession.sessionId,
@@ -643,6 +686,7 @@ export class SessionManager {
643
686
  summary: report.summary,
644
687
  generatedAt: report.generatedAt,
645
688
  durationMs: report.durationMs,
689
+ source: report.source,
646
690
  });
647
691
  try {
648
692
  printFinalReportToTerminal(reportInput, report);
@@ -709,6 +753,7 @@ export class SessionManager {
709
753
  cwd,
710
754
  });
711
755
  this.startNarrationTimer(newSession);
756
+ this.startStallWatchdog(newSession);
712
757
  return newSession;
713
758
  }
714
759
  /** v0.6 M3 — periodic narration loop per session. */
@@ -747,6 +792,54 @@ export class SessionManager {
747
792
  }, intervalMs);
748
793
  this.narrationTimers.set(session.sessionId, timer);
749
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
+ }
750
843
  /** v0.6 M3 — latest narration for a session, or undefined. */
751
844
  getNarration(sessionId) {
752
845
  return this.narrationBySession.get(sessionId);
@@ -809,6 +902,9 @@ export class SessionManager {
809
902
  clearInterval(t);
810
903
  this.narrationTimers.delete(id);
811
904
  }
905
+ this.clearStallTimer(id);
906
+ this.lastActivityBySession.delete(id);
907
+ this.stalledBySession.delete(id);
812
908
  this.sessions.delete(id);
813
909
  this.completedAtBySession.delete(id);
814
910
  this.cwdBySession.delete(id);
@@ -833,10 +929,14 @@ export class SessionManager {
833
929
  }
834
930
  /** Graceful shutdown: kill all running sessions and clear all timers. */
835
931
  shutdown() {
836
- for (const [id, timer] of this.narrationTimers) {
932
+ for (const [, timer] of this.narrationTimers) {
837
933
  clearInterval(timer);
838
934
  }
839
935
  this.narrationTimers.clear();
936
+ for (const [, timer] of this.stallTimers) {
937
+ clearInterval(timer);
938
+ }
939
+ this.stallTimers.clear();
840
940
  for (const session of this.sessions.values()) {
841
941
  if (session.status === 'running')
842
942
  session.kill();
@@ -982,4 +1082,10 @@ export class SessionManager {
982
1082
  export function __testOnlyRegisterAdapter(adapter) {
983
1083
  REGISTRY.push(adapter);
984
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
+ }
985
1091
  //# sourceMappingURL=session-manager.js.map