codehere 0.6.2 → 0.7.0

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 (70) 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 +28 -4
  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 +45 -17
  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 +33 -4
  29. package/dist/infrastructure/orchestration/codex-adapter.js +146 -13
  30. package/dist/infrastructure/orchestration/context-handoff.js +3 -2
  31. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  32. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  33. package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
  34. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  35. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  36. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  37. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  38. package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
  39. package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
  40. package/dist/infrastructure/orchestration/session-manager.js +94 -6
  41. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  42. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  43. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  44. package/dist/infrastructure/protocols/cloud-sync.js +1 -6
  45. package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
  46. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  47. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  48. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  49. package/dist/infrastructure/protocols/verify-page.js +941 -0
  50. package/dist/infrastructure/security/trust-scorer.js +76 -1
  51. package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
  52. package/dist/infrastructure/storage/project-thread-migration.js +2 -2
  53. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  54. package/dist/monitoring.js +1 -1
  55. package/dist/presentation/cli/commands/audit-command.js +126 -0
  56. package/dist/presentation/cli/commands/config-command.js +2 -2
  57. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  58. package/dist/presentation/cli/commands/help-command.js +51 -1
  59. package/dist/presentation/cli/commands/report-command.js +6 -4
  60. package/dist/presentation/cli/commands/setup-command.js +71 -18
  61. package/dist/presentation/cli/commands/status-command.js +0 -4
  62. package/dist/presentation/cli/commands/thread-command.js +288 -0
  63. package/dist/presentation/cli/commands/verify-command.js +233 -0
  64. package/dist/presentation/cli/error-display.js +1 -1
  65. package/dist/presentation/cli/repl.js +32 -1
  66. package/dist/presentation/cli/task-deliverable.js +127 -0
  67. package/dist/testing/cassette.js +125 -0
  68. package/dist/testing/replay-adapter.js +166 -0
  69. package/dist/ui.js +18 -1
  70. package/package.json +4 -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
  }
@@ -30,7 +30,7 @@ import { generateNarration } from '../../application/use-cases/generate-narratio
30
30
  import { randomUUID } from 'crypto';
31
31
  import { classifyFailoverEligibility } from './failover-classifier.js';
32
32
  import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
33
- import { loadFailoverConfig, resolveChain } from './failover-config.js';
33
+ import { loadFailoverConfig } from './failover-config.js';
34
34
  import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
35
35
  import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
36
36
  import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
@@ -151,6 +151,14 @@ export class SessionManager {
151
151
  narrationBySession = new Map();
152
152
  narrationTimers = new Map();
153
153
  narrationStepsAtLastFire = new Map();
154
+ /** Stall watchdog (dogfood 2026-05-27 #1): a spawned agent can hang with no
155
+ * output and sit "running" forever (opencode did exactly this). We track the
156
+ * timestamp of the last event from each session and, if a running session
157
+ * goes silent past the stall window, kill it and record an honest reason so
158
+ * the UI shows "stopped — no response", never a fake "running" or a normal row. */
159
+ stallTimers = new Map();
160
+ lastActivityBySession = new Map();
161
+ stalledBySession = new Map();
154
162
  /** Stores active failover offers keyed by sessionId+target. API layer
155
163
  * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
156
164
  failoverOffers;
@@ -171,7 +179,11 @@ export class SessionManager {
171
179
  if (!adapter) {
172
180
  throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
173
181
  }
174
- if (!adapter.isInstalled()) {
182
+ // CODEHERE_MOCK spawns a synthetic agent (mock REPL), so the real CLI need
183
+ // not be installed — bypass the readiness gate. Makes the mock harness truly
184
+ // zero-dependency (dogfood/dev without any agent CLI installed).
185
+ const mockMode = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
186
+ if (!mockMode && !adapter.isInstalled()) {
175
187
  throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
176
188
  }
177
189
  // Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
@@ -221,7 +233,10 @@ export class SessionManager {
221
233
  cwd,
222
234
  startedAt: session.startedAt,
223
235
  });
236
+ this.lastActivityBySession.set(session.sessionId, Date.now());
224
237
  const unsubscribe = session.subscribe((event) => {
238
+ // Any event = the agent is alive. Reset the stall clock. (watchdog)
239
+ this.lastActivityBySession.set(session.sessionId, Date.now());
225
240
  // ── Failover check — intercept rate-limit / quota errors before exit ──
226
241
  // Adapters emit 'codehere:error' (structured: { code, message, recovery })
227
242
  // or 'error' (spawn-level: { message }). Both need classification.
@@ -251,6 +266,7 @@ export class SessionManager {
251
266
  completedAt,
252
267
  steps: session.steps ? [...session.steps] : undefined,
253
268
  artifacts: session.artifacts,
269
+ stalledReason: this.stalledBySession.get(session.sessionId)?.reason,
254
270
  });
255
271
  // v0.6.0 M1+M7 — forward-mirror completion to project thread.
256
272
  mirrorCompletedToThread({
@@ -334,6 +350,8 @@ export class SessionManager {
334
350
  clearInterval(t);
335
351
  this.narrationTimers.delete(session.sessionId);
336
352
  }
353
+ // Watchdog: session settled on its own — stop watching it.
354
+ this.clearStallTimer(session.sessionId);
337
355
  this.evictOldCompleted();
338
356
  unsubscribe();
339
357
  });
@@ -344,6 +362,7 @@ export class SessionManager {
344
362
  // failures (no claude binary, timeout, malformed output) silently
345
363
  // skip — narration is enrichment, not the primary surface.
346
364
  this.startNarrationTimer(session);
365
+ this.startStallWatchdog(session);
347
366
  return session;
348
367
  }
349
368
  /** Tracks which agents have already been tried for a given prompt during
@@ -355,7 +374,6 @@ export class SessionManager {
355
374
  * session if auto-spawn was triggered, undefined otherwise. */
356
375
  handleFailover(session, signal) {
357
376
  const config = loadFailoverConfig();
358
- const chain = resolveChain(config?.chain);
359
377
  const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
360
378
  tried.add(signal.fromAgent);
361
379
  this.failoverTriedBySession.set(session.sessionId, tried);
@@ -408,11 +426,14 @@ export class SessionManager {
408
426
  this.sessions.set(newSession.sessionId, newSession);
409
427
  this.spawnOrder.push(newSession.sessionId);
410
428
  this.cwdBySession.set(newSession.sessionId, cwd);
429
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
411
430
  // Subscribe BEFORE persistence so exit events are never lost if the
412
431
  // adapter exits synchronously (command not found, instant crash, etc).
413
432
  const unsubscribe = newSession.subscribe((event) => {
433
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
414
434
  if (event.type !== 'exit')
415
435
  return;
436
+ this.clearStallTimer(newSession.sessionId);
416
437
  const data = event.data;
417
438
  const completedAt = Date.now();
418
439
  this.completedAtBySession.set(newSession.sessionId, completedAt);
@@ -423,6 +444,7 @@ export class SessionManager {
423
444
  completedAt,
424
445
  steps: newSession.steps ? [...newSession.steps] : undefined,
425
446
  artifacts: newSession.artifacts,
447
+ stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
426
448
  });
427
449
  mirrorCompletedToThread({
428
450
  sessionId: newSession.sessionId,
@@ -519,6 +541,7 @@ export class SessionManager {
519
541
  this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
520
542
  this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
521
543
  this.startNarrationTimer(newSession);
544
+ this.startStallWatchdog(newSession);
522
545
  return newSession;
523
546
  }
524
547
  catch {
@@ -539,7 +562,6 @@ export class SessionManager {
539
562
  this.failoverOffers = new Map();
540
563
  }
541
564
  const key = `${offer.session.sessionId}-${offer.target}`;
542
- const expiresAt = Date.now() + 60_000;
543
565
  this.failoverOffers.set(key, { offer, createdAt: Date.now() });
544
566
  // Auto-expire offers after 60 seconds to keep the map bounded
545
567
  setTimeout(() => {
@@ -551,7 +573,7 @@ export class SessionManager {
551
573
  if (!this.failoverOffers)
552
574
  return [];
553
575
  const offers = [];
554
- for (const [key, { offer, createdAt }] of this.failoverOffers) {
576
+ for (const [, { offer, createdAt }] of this.failoverOffers) {
555
577
  offers.push({
556
578
  sessionId: offer.session.sessionId,
557
579
  signal: offer.signal,
@@ -600,11 +622,14 @@ export class SessionManager {
600
622
  this.sessions.set(newSession.sessionId, newSession);
601
623
  this.spawnOrder.push(newSession.sessionId);
602
624
  this.cwdBySession.set(newSession.sessionId, cwd);
625
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
603
626
  // Subscribe BEFORE persistence so exit events are never lost if the
604
627
  // adapter exits synchronously.
605
628
  const unsubscribe = newSession.subscribe((event) => {
629
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
606
630
  if (event.type !== 'exit')
607
631
  return;
632
+ this.clearStallTimer(newSession.sessionId);
608
633
  const data = event.data;
609
634
  const completedAt = Date.now();
610
635
  this.completedAtBySession.set(newSession.sessionId, completedAt);
@@ -615,6 +640,7 @@ export class SessionManager {
615
640
  completedAt,
616
641
  steps: newSession.steps ? [...newSession.steps] : undefined,
617
642
  artifacts: newSession.artifacts,
643
+ stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
618
644
  });
619
645
  mirrorCompletedToThread({
620
646
  sessionId: newSession.sessionId,
@@ -709,6 +735,7 @@ export class SessionManager {
709
735
  cwd,
710
736
  });
711
737
  this.startNarrationTimer(newSession);
738
+ this.startStallWatchdog(newSession);
712
739
  return newSession;
713
740
  }
714
741
  /** v0.6 M3 — periodic narration loop per session. */
@@ -747,6 +774,54 @@ export class SessionManager {
747
774
  }, intervalMs);
748
775
  this.narrationTimers.set(session.sessionId, timer);
749
776
  }
777
+ /** Stall watchdog (dogfood 2026-05-27 #1). Polls a running session; if no
778
+ * event has arrived within the stall window, kills it and records an honest
779
+ * reason. Cross-agent — opencode hung, but any adapter can. Timings are
780
+ * env-overridable so the mock harness + tests can drive it fast. Disable
781
+ * entirely with CODEHERE_DISABLE_WATCHDOG=1. */
782
+ startStallWatchdog(session) {
783
+ if (process.env.CODEHERE_DISABLE_WATCHDOG === '1')
784
+ return;
785
+ const stallMs = Number(process.env.CODEHERE_STALL_TIMEOUT_MS ?? 180_000);
786
+ const pollMs = Number(process.env.CODEHERE_STALL_POLL_MS ?? 15_000);
787
+ if (!(stallMs > 0) || !(pollMs > 0))
788
+ return;
789
+ const timer = setInterval(() => {
790
+ if (session.status !== 'running') {
791
+ this.clearStallTimer(session.sessionId);
792
+ return;
793
+ }
794
+ const last = this.lastActivityBySession.get(session.sessionId) ?? session.startedAt;
795
+ const silentMs = Date.now() - last;
796
+ if (silentMs < stallMs)
797
+ return;
798
+ const reason = `Stopped after no response for ${Math.round(silentMs / 1000)}s — the agent hung without finishing.`;
799
+ this.stalledBySession.set(session.sessionId, { reason, stalledAt: Date.now() });
800
+ this.clearStallTimer(session.sessionId);
801
+ try {
802
+ session.kill();
803
+ }
804
+ catch {
805
+ // kill is best-effort; the recorded stall reason + 'killed' status is
806
+ // what the UI reads, so a kill that throws still surfaces honestly.
807
+ }
808
+ }, pollMs);
809
+ this.stallTimers.set(session.sessionId, timer);
810
+ }
811
+ clearStallTimer(sessionId) {
812
+ const t = this.stallTimers.get(sessionId);
813
+ if (t) {
814
+ clearInterval(t);
815
+ this.stallTimers.delete(sessionId);
816
+ }
817
+ }
818
+ /** Watchdog verdict for a session — the honest "why it stopped" reason when
819
+ * the stall watchdog killed it, or undefined if it settled on its own.
820
+ * The API layer surfaces this so the dashboard never shows a hung task as a
821
+ * normal row. */
822
+ getStallReason(sessionId) {
823
+ return this.stalledBySession.get(sessionId)?.reason;
824
+ }
750
825
  /** v0.6 M3 — latest narration for a session, or undefined. */
751
826
  getNarration(sessionId) {
752
827
  return this.narrationBySession.get(sessionId);
@@ -809,6 +884,9 @@ export class SessionManager {
809
884
  clearInterval(t);
810
885
  this.narrationTimers.delete(id);
811
886
  }
887
+ this.clearStallTimer(id);
888
+ this.lastActivityBySession.delete(id);
889
+ this.stalledBySession.delete(id);
812
890
  this.sessions.delete(id);
813
891
  this.completedAtBySession.delete(id);
814
892
  this.cwdBySession.delete(id);
@@ -833,10 +911,14 @@ export class SessionManager {
833
911
  }
834
912
  /** Graceful shutdown: kill all running sessions and clear all timers. */
835
913
  shutdown() {
836
- for (const [id, timer] of this.narrationTimers) {
914
+ for (const [, timer] of this.narrationTimers) {
837
915
  clearInterval(timer);
838
916
  }
839
917
  this.narrationTimers.clear();
918
+ for (const [, timer] of this.stallTimers) {
919
+ clearInterval(timer);
920
+ }
921
+ this.stallTimers.clear();
840
922
  for (const session of this.sessions.values()) {
841
923
  if (session.status === 'running')
842
924
  session.kill();
@@ -982,4 +1064,10 @@ export class SessionManager {
982
1064
  export function __testOnlyRegisterAdapter(adapter) {
983
1065
  REGISTRY.push(adapter);
984
1066
  }
1067
+ /** @internal Resets REGISTRY to BUILTIN_REGISTRY for test isolation.
1068
+ * Call before __testOnlyRegisterAdapter to avoid adapter accumulation. */
1069
+ export function __testOnlyResetRegistry() {
1070
+ REGISTRY.length = 0;
1071
+ REGISTRY.push(...BUILTIN_REGISTRY);
1072
+ }
985
1073
  //# sourceMappingURL=session-manager.js.map