codehere 0.6.1 → 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 (81) 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 +84 -5
  4. package/dist/application/use-cases/generate-narration.js +20 -3
  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 +218 -19
  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/cost/budget-config.js +1 -1
  23. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  24. package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
  25. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  26. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  27. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  28. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  29. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  30. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  31. package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
  32. package/dist/infrastructure/orchestration/context-handoff.js +4 -3
  33. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  34. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  35. package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
  36. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  37. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  38. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  39. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  40. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  41. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  42. package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
  43. package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
  44. package/dist/infrastructure/orchestration/session-manager.js +142 -29
  45. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  46. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  47. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  48. package/dist/infrastructure/protocols/cloud-sync.js +10 -11
  49. package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
  50. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  51. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  52. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  53. package/dist/infrastructure/protocols/verify-page.js +941 -0
  54. package/dist/infrastructure/security/trust-scorer.js +76 -1
  55. package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
  56. package/dist/infrastructure/storage/project-thread-migration.js +14 -9
  57. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  58. package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
  59. package/dist/monitoring.js +1 -1
  60. package/dist/presentation/cli/commands/audit-command.js +126 -0
  61. package/dist/presentation/cli/commands/budget-command.js +2 -2
  62. package/dist/presentation/cli/commands/config-command.js +2 -2
  63. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  64. package/dist/presentation/cli/commands/help-command.js +51 -1
  65. package/dist/presentation/cli/commands/init-command.js +5 -4
  66. package/dist/presentation/cli/commands/memory-command.js +29 -15
  67. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  68. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  69. package/dist/presentation/cli/commands/report-command.js +6 -4
  70. package/dist/presentation/cli/commands/setup-command.js +71 -18
  71. package/dist/presentation/cli/commands/status-command.js +0 -4
  72. package/dist/presentation/cli/commands/thread-command.js +288 -0
  73. package/dist/presentation/cli/commands/verify-command.js +233 -0
  74. package/dist/presentation/cli/error-display.js +1 -1
  75. package/dist/presentation/cli/repl.js +52 -2
  76. package/dist/presentation/cli/task-deliverable.js +127 -0
  77. package/dist/testing/cassette.js +125 -0
  78. package/dist/testing/replay-adapter.js +166 -0
  79. package/dist/ui.js +54 -29
  80. package/dist/utils/typo-guard.js +1 -1
  81. 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) {
@@ -464,8 +583,7 @@ export class ProgressAggregator {
464
583
  if (itemType === 'tool_call' || itemType === 'local_shell_call') {
465
584
  const name = String(item.tool_name ?? item.name ?? 'tool');
466
585
  // Tool-call items may carry a `result` or `output` field too.
467
- const result = (typeof item.result === 'string' ? item.result : '') ||
468
- (typeof item.output === 'string' ? item.output : '');
586
+ const result = (typeof item.result === 'string' ? item.result : '') || (typeof item.output === 'string' ? item.output : '');
469
587
  this._steps.push({
470
588
  label: truncate(name),
471
589
  state: 'done',
@@ -475,6 +593,61 @@ export class ProgressAggregator {
475
593
  });
476
594
  return true;
477
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.
478
651
  return false;
479
652
  }
480
653
  }
@@ -22,15 +22,15 @@ import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js'
22
22
  import { buildConductorAdapter } from './conductor-adapter.js';
23
23
  import { seedAgentsConfig } from './agents-config-seed.js';
24
24
  import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
25
- import { appendSpawned, appendCompleted, appendFinalReport, loadRecent } from './session-persistence.js';
25
+ import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
26
26
  import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
27
27
  import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/project-thread-mirror.js';
28
28
  import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
29
29
  import { generateNarration } from '../../application/use-cases/generate-narration.js';
30
30
  import { randomUUID } from 'crypto';
31
31
  import { classifyFailoverEligibility } from './failover-classifier.js';
32
- import { resolveFailoverTarget, toAvailability, } from './failover-policy.js';
33
- import { loadFailoverConfig, resolveChain } from './failover-config.js';
32
+ import { resolveFailoverTarget, toAvailability } from './failover-policy.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';
@@ -39,7 +39,13 @@ import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
39
39
  // order. Built-in adapters first; user-config-driven ones appended from
40
40
  // ~/.codehere/agents.yaml (generic-cli-adapter). isInstalled() gates visibility
41
41
  // so an adapter registering for an un-installed CLI is harmless.
42
- const BUILTIN_REGISTRY = [ClaudeCodeAdapter, CodexAdapter, AiderAdapter, OpenCodeAdapter, GeminiAdapter];
42
+ const BUILTIN_REGISTRY = [
43
+ ClaudeCodeAdapter,
44
+ CodexAdapter,
45
+ AiderAdapter,
46
+ OpenCodeAdapter,
47
+ GeminiAdapter,
48
+ ];
43
49
  // Seed the default agents.yaml if this user has never configured one, so Gemini
44
50
  // / Cursor / Pi / Antigravity / Goose appear in the registry pre-wired. They
45
51
  // stay hidden in the UI until isInstalled() flips to true. Idempotent.
@@ -145,27 +151,39 @@ export class SessionManager {
145
151
  narrationBySession = new Map();
146
152
  narrationTimers = new Map();
147
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();
148
162
  /** Stores active failover offers keyed by sessionId+target. API layer
149
163
  * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
150
164
  failoverOffers;
151
165
  /** Tracks the failover error code per session so acceptFailover can emit
152
- * the correct reason in the thread event. Populated by handleFailover. */
166
+ * the correct reason in the thread event. Populated by handleFailover. */
153
167
  failoverReasonBySession = new Map();
154
168
  /** Session threading — tracks which session continued as which after failover.
155
- * continuedAsBySession: original → new session ID
156
- * continuedFromBySession: new → original session ID */
169
+ * continuedAsBySession: original → new session ID
170
+ * continuedFromBySession: new → original session ID */
157
171
  continuedAsBySession = new Map();
158
172
  continuedFromBySession = new Map();
159
173
  /** Final Report failure tracking — when generateFinalReport rejects, we
160
- * record it so the API can return a definitive "failed" status instead
161
- * of leaving the UI showing "Generating report…" forever. */
174
+ * record it so the API can return a definitive "failed" status instead
175
+ * of leaving the UI showing "Generating report…" forever. */
162
176
  failedReportBySession = new Set();
163
177
  spawn(prompt, agentId = 'claude-code', options) {
164
178
  const adapter = getAdapter(agentId);
165
179
  if (!adapter) {
166
180
  throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
167
181
  }
168
- 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()) {
169
187
  throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
170
188
  }
171
189
  // Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
@@ -215,7 +233,10 @@ export class SessionManager {
215
233
  cwd,
216
234
  startedAt: session.startedAt,
217
235
  });
236
+ this.lastActivityBySession.set(session.sessionId, Date.now());
218
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());
219
240
  // ── Failover check — intercept rate-limit / quota errors before exit ──
220
241
  // Adapters emit 'codehere:error' (structured: { code, message, recovery })
221
242
  // or 'error' (spawn-level: { message }). Both need classification.
@@ -245,6 +266,7 @@ export class SessionManager {
245
266
  completedAt,
246
267
  steps: session.steps ? [...session.steps] : undefined,
247
268
  artifacts: session.artifacts,
269
+ stalledReason: this.stalledBySession.get(session.sessionId)?.reason,
248
270
  });
249
271
  // v0.6.0 M1+M7 — forward-mirror completion to project thread.
250
272
  mirrorCompletedToThread({
@@ -305,7 +327,10 @@ export class SessionManager {
305
327
  // or pack compilation may fail. All degrade silently.
306
328
  void Promise.resolve().then(() => {
307
329
  try {
308
- const stepsStr = session.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
330
+ const stepsStr = session.steps
331
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
332
+ .filter(Boolean)
333
+ .join('\n') ?? '';
309
334
  const pack = compileSessionPackSync(cwd, {
310
335
  prompt: session.prompt,
311
336
  artifacts: JSON.stringify(session.artifacts, null, 2),
@@ -325,6 +350,8 @@ export class SessionManager {
325
350
  clearInterval(t);
326
351
  this.narrationTimers.delete(session.sessionId);
327
352
  }
353
+ // Watchdog: session settled on its own — stop watching it.
354
+ this.clearStallTimer(session.sessionId);
328
355
  this.evictOldCompleted();
329
356
  unsubscribe();
330
357
  });
@@ -335,6 +362,7 @@ export class SessionManager {
335
362
  // failures (no claude binary, timeout, malformed output) silently
336
363
  // skip — narration is enrichment, not the primary surface.
337
364
  this.startNarrationTimer(session);
365
+ this.startStallWatchdog(session);
338
366
  return session;
339
367
  }
340
368
  /** Tracks which agents have already been tried for a given prompt during
@@ -346,7 +374,6 @@ export class SessionManager {
346
374
  * session if auto-spawn was triggered, undefined otherwise. */
347
375
  handleFailover(session, signal) {
348
376
  const config = loadFailoverConfig();
349
- const chain = resolveChain(config?.chain);
350
377
  const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
351
378
  tried.add(signal.fromAgent);
352
379
  this.failoverTriedBySession.set(session.sessionId, tried);
@@ -399,11 +426,14 @@ export class SessionManager {
399
426
  this.sessions.set(newSession.sessionId, newSession);
400
427
  this.spawnOrder.push(newSession.sessionId);
401
428
  this.cwdBySession.set(newSession.sessionId, cwd);
429
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
402
430
  // Subscribe BEFORE persistence so exit events are never lost if the
403
431
  // adapter exits synchronously (command not found, instant crash, etc).
404
432
  const unsubscribe = newSession.subscribe((event) => {
433
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
405
434
  if (event.type !== 'exit')
406
435
  return;
436
+ this.clearStallTimer(newSession.sessionId);
407
437
  const data = event.data;
408
438
  const completedAt = Date.now();
409
439
  this.completedAtBySession.set(newSession.sessionId, completedAt);
@@ -414,6 +444,7 @@ export class SessionManager {
414
444
  completedAt,
415
445
  steps: newSession.steps ? [...newSession.steps] : undefined,
416
446
  artifacts: newSession.artifacts,
447
+ stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
417
448
  });
418
449
  mirrorCompletedToThread({
419
450
  sessionId: newSession.sessionId,
@@ -446,14 +477,21 @@ export class SessionManager {
446
477
  try {
447
478
  printFinalReportToTerminal(reportInput, report);
448
479
  }
449
- catch { /* defensive */ }
480
+ catch {
481
+ /* defensive */
482
+ }
450
483
  }
451
484
  })
452
- .catch(() => { });
485
+ .catch(() => {
486
+ /* defensive */
487
+ });
453
488
  // P1 — persist context pack at failover-replacement session exit.
454
489
  void Promise.resolve().then(() => {
455
490
  try {
456
- const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
491
+ const stepsStr = newSession.steps
492
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
493
+ .filter(Boolean)
494
+ .join('\n') ?? '';
457
495
  const pack = compileSessionPackSync(cwd, {
458
496
  prompt: newSession.prompt,
459
497
  artifacts: JSON.stringify(newSession.artifacts, null, 2),
@@ -503,6 +541,7 @@ export class SessionManager {
503
541
  this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
504
542
  this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
505
543
  this.startNarrationTimer(newSession);
544
+ this.startStallWatchdog(newSession);
506
545
  return newSession;
507
546
  }
508
547
  catch {
@@ -523,7 +562,6 @@ export class SessionManager {
523
562
  this.failoverOffers = new Map();
524
563
  }
525
564
  const key = `${offer.session.sessionId}-${offer.target}`;
526
- const expiresAt = Date.now() + 60_000;
527
565
  this.failoverOffers.set(key, { offer, createdAt: Date.now() });
528
566
  // Auto-expire offers after 60 seconds to keep the map bounded
529
567
  setTimeout(() => {
@@ -535,7 +573,7 @@ export class SessionManager {
535
573
  if (!this.failoverOffers)
536
574
  return [];
537
575
  const offers = [];
538
- for (const [key, { offer, createdAt }] of this.failoverOffers) {
576
+ for (const [, { offer, createdAt }] of this.failoverOffers) {
539
577
  offers.push({
540
578
  sessionId: offer.session.sessionId,
541
579
  signal: offer.signal,
@@ -546,9 +584,9 @@ export class SessionManager {
546
584
  return offers;
547
585
  }
548
586
  /** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
549
- * session on the target agent, preserving the original prompt + cwd.
550
- * P2 — injects handoff context for cross-agent continuity.
551
- * Returns the new session or undefined if the target is unavailable. */
587
+ * session on the target agent, preserving the original prompt + cwd.
588
+ * P2 — injects handoff context for cross-agent continuity.
589
+ * Returns the new session or undefined if the target is unavailable. */
552
590
  acceptFailover(sessionId, targetAgent) {
553
591
  const session = this.sessions.get(sessionId);
554
592
  if (!session)
@@ -584,11 +622,14 @@ export class SessionManager {
584
622
  this.sessions.set(newSession.sessionId, newSession);
585
623
  this.spawnOrder.push(newSession.sessionId);
586
624
  this.cwdBySession.set(newSession.sessionId, cwd);
625
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
587
626
  // Subscribe BEFORE persistence so exit events are never lost if the
588
627
  // adapter exits synchronously.
589
628
  const unsubscribe = newSession.subscribe((event) => {
629
+ this.lastActivityBySession.set(newSession.sessionId, Date.now());
590
630
  if (event.type !== 'exit')
591
631
  return;
632
+ this.clearStallTimer(newSession.sessionId);
592
633
  const data = event.data;
593
634
  const completedAt = Date.now();
594
635
  this.completedAtBySession.set(newSession.sessionId, completedAt);
@@ -599,6 +640,7 @@ export class SessionManager {
599
640
  completedAt,
600
641
  steps: newSession.steps ? [...newSession.steps] : undefined,
601
642
  artifacts: newSession.artifacts,
643
+ stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
602
644
  });
603
645
  mirrorCompletedToThread({
604
646
  sessionId: newSession.sessionId,
@@ -631,14 +673,21 @@ export class SessionManager {
631
673
  try {
632
674
  printFinalReportToTerminal(reportInput, report);
633
675
  }
634
- catch { /* defensive */ }
676
+ catch {
677
+ /* defensive */
678
+ }
635
679
  }
636
680
  })
637
- .catch(() => { });
681
+ .catch(() => {
682
+ /* defensive */
683
+ });
638
684
  // P1 — persist context pack at accept-failover replacement session exit.
639
685
  void Promise.resolve().then(() => {
640
686
  try {
641
- const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
687
+ const stepsStr = newSession.steps
688
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
689
+ .filter(Boolean)
690
+ .join('\n') ?? '';
642
691
  const pack = compileSessionPackSync(cwd, {
643
692
  prompt: newSession.prompt,
644
693
  artifacts: JSON.stringify(newSession.artifacts, null, 2),
@@ -686,6 +735,7 @@ export class SessionManager {
686
735
  cwd,
687
736
  });
688
737
  this.startNarrationTimer(newSession);
738
+ this.startStallWatchdog(newSession);
689
739
  return newSession;
690
740
  }
691
741
  /** v0.6 M3 — periodic narration loop per session. */
@@ -718,10 +768,60 @@ export class SessionManager {
718
768
  if (out)
719
769
  this.narrationBySession.set(session.sessionId, out);
720
770
  })
721
- .catch(() => { });
771
+ .catch(() => {
772
+ /* defensive — generateNarration never throws */
773
+ });
722
774
  }, intervalMs);
723
775
  this.narrationTimers.set(session.sessionId, timer);
724
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
+ }
725
825
  /** v0.6 M3 — latest narration for a session, or undefined. */
726
826
  getNarration(sessionId) {
727
827
  return this.narrationBySession.get(sessionId);
@@ -750,14 +850,14 @@ export class SessionManager {
750
850
  return this.continuedAsBySession.get(sessionId);
751
851
  }
752
852
  /** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
753
- * Returns undefined while generation is in flight OR if the provider chain
754
- * failed (no claude-free, no claude, no credit, etc). The web UI reads this
755
- * through the /api/tasks serializer to render the prose summary inline. */
853
+ * Returns undefined while generation is in flight OR if the provider chain
854
+ * failed (no claude-free, no claude, no credit, etc). The web UI reads this
855
+ * through the /api/tasks serializer to render the prose summary inline. */
756
856
  getFinalReport(sessionId) {
757
857
  return this.finalReportBySession.get(sessionId);
758
858
  }
759
859
  /** Returns true if Final Report generation explicitly failed for this session.
760
- * Lets the API distinguish "not yet" from "never coming". */
860
+ * Lets the API distinguish "not yet" from "never coming". */
761
861
  hasFailedReport(sessionId) {
762
862
  return this.failedReportBySession.has(sessionId);
763
863
  }
@@ -784,6 +884,9 @@ export class SessionManager {
784
884
  clearInterval(t);
785
885
  this.narrationTimers.delete(id);
786
886
  }
887
+ this.clearStallTimer(id);
888
+ this.lastActivityBySession.delete(id);
889
+ this.stalledBySession.delete(id);
787
890
  this.sessions.delete(id);
788
891
  this.completedAtBySession.delete(id);
789
892
  this.cwdBySession.delete(id);
@@ -808,10 +911,14 @@ export class SessionManager {
808
911
  }
809
912
  /** Graceful shutdown: kill all running sessions and clear all timers. */
810
913
  shutdown() {
811
- for (const [id, timer] of this.narrationTimers) {
914
+ for (const [, timer] of this.narrationTimers) {
812
915
  clearInterval(timer);
813
916
  }
814
917
  this.narrationTimers.clear();
918
+ for (const [, timer] of this.stallTimers) {
919
+ clearInterval(timer);
920
+ }
921
+ this.stallTimers.clear();
815
922
  for (const session of this.sessions.values()) {
816
923
  if (session.status === 'running')
817
924
  session.kill();
@@ -957,4 +1064,10 @@ export class SessionManager {
957
1064
  export function __testOnlyRegisterAdapter(adapter) {
958
1065
  REGISTRY.push(adapter);
959
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
+ }
960
1073
  //# sourceMappingURL=session-manager.js.map