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.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +84 -5
- package/dist/application/use-cases/generate-narration.js +20 -3
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +218 -19
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/cost/budget-config.js +1 -1
- package/dist/infrastructure/cost/spend-aggregator.js +5 -5
- package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
- package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
- package/dist/infrastructure/orchestration/context-handoff.js +4 -3
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
- package/dist/infrastructure/orchestration/graphify-context.js +6 -13
- package/dist/infrastructure/orchestration/memory-injector.js +11 -16
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
- package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
- package/dist/infrastructure/orchestration/session-manager.js +142 -29
- package/dist/infrastructure/orchestration/session-persistence.js +139 -30
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +10 -11
- package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
- package/dist/infrastructure/storage/project-thread-migration.js +14 -9
- package/dist/infrastructure/storage/project-thread-reader.js +11 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/init-command.js +5 -4
- package/dist/presentation/cli/commands/memory-command.js +29 -15
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +52 -2
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +54 -29
- package/dist/utils/typo-guard.js +1 -1
- 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
|
-
|
|
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 === '
|
|
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
|
|
33
|
-
import { loadFailoverConfig
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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 [
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|