codehere 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +87 -168
- package/dist/application/use-cases/generate-narration.js +11 -0
- 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 +211 -16
- 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/hooks/claude-sdk-hook.js +73 -57
- 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 +49 -18
- 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 +38 -5
- package/dist/infrastructure/orchestration/codex-adapter.js +150 -22
- package/dist/infrastructure/orchestration/conductor-adapter.js +9 -3
- package/dist/infrastructure/orchestration/context-handoff.js +3 -2
- 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 +12 -2
- package/dist/infrastructure/orchestration/graphify-context.js +18 -13
- package/dist/infrastructure/orchestration/memory-injector.js +46 -1
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +230 -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 +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +112 -6
- package/dist/infrastructure/orchestration/session-persistence.js +140 -31
- 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 +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1283 -13
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3913 -88
- 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 +1 -1
- package/dist/infrastructure/storage/project-thread-migration.js +2 -2
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- 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/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 +32 -1
- 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 +18 -1
- package/package.json +5 -2
|
@@ -159,11 +159,47 @@ export class ProgressAggregator {
|
|
|
159
159
|
return this.ingestGemini(type, data);
|
|
160
160
|
if (this.agentId === 'aider')
|
|
161
161
|
return this.ingestAider(type, data);
|
|
162
|
+
if (this.agentId === 'opencode')
|
|
163
|
+
return this.ingestOpencode(type, data);
|
|
162
164
|
return false;
|
|
163
165
|
}
|
|
166
|
+
// OpenCode · `run --format json` emits tool_use with shape
|
|
167
|
+
// { type:'tool_use', part:{ tool, state:{ input, output, status } } }. Was
|
|
168
|
+
// never wired into the aggregator (opencode showed ZERO timeline steps — a
|
|
169
|
+
// real gap surfaced by the mock harness). Build a step on the terminal
|
|
170
|
+
// (status==='completed') event so opencode is a first-class agent.
|
|
171
|
+
ingestOpencode(type, data) {
|
|
172
|
+
if (type !== 'tool_use')
|
|
173
|
+
return false;
|
|
174
|
+
const part = (data.part ?? {});
|
|
175
|
+
if (part.state?.status && part.state.status !== 'completed')
|
|
176
|
+
return false; // skip in-flight
|
|
177
|
+
const map = {
|
|
178
|
+
bash: 'Bash',
|
|
179
|
+
read: 'Read',
|
|
180
|
+
write: 'Write',
|
|
181
|
+
edit: 'Edit',
|
|
182
|
+
grep: 'Grep',
|
|
183
|
+
glob: 'Glob',
|
|
184
|
+
webfetch: 'WebFetch',
|
|
185
|
+
websearch: 'WebSearch',
|
|
186
|
+
};
|
|
187
|
+
const tool = map[String(part.tool ?? '').toLowerCase()] || 'Bash';
|
|
188
|
+
const label = labelForClaudeToolUse(tool, part.state?.input);
|
|
189
|
+
const output = typeof part.state?.output === 'string' ? part.state.output : '';
|
|
190
|
+
this._steps.push({
|
|
191
|
+
label,
|
|
192
|
+
state: 'done',
|
|
193
|
+
startedAt: Date.now(),
|
|
194
|
+
completedAt: Date.now(),
|
|
195
|
+
...(output ? { output: truncateOutput(output) } : {}),
|
|
196
|
+
});
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
164
199
|
/** Flush: mark every still-active step as failed (called on abnormal exit). */
|
|
165
200
|
finalize(status) {
|
|
166
|
-
|
|
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) {
|
|
@@ -474,6 +593,61 @@ export class ProgressAggregator {
|
|
|
474
593
|
});
|
|
475
594
|
return true;
|
|
476
595
|
}
|
|
596
|
+
// file_change — codex emits one item per Edit/Write/Delete batch. The audit
|
|
597
|
+
// mirror (codex-adapter.ts) already handles these; the timeline-step path was
|
|
598
|
+
// dropping them, so real codex file edits were invisible in "watch it work"
|
|
599
|
+
// (mock-currency audit 2026-06-02 caught this — the mock emits the real shape).
|
|
600
|
+
if (itemType === 'file_change') {
|
|
601
|
+
const changes = Array.isArray(item.changes)
|
|
602
|
+
? item.changes
|
|
603
|
+
: [];
|
|
604
|
+
let changed = false;
|
|
605
|
+
for (const ch of changes) {
|
|
606
|
+
const path = String(ch.path ?? '');
|
|
607
|
+
const kind = String(ch.kind ?? 'update');
|
|
608
|
+
const toolName = kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit';
|
|
609
|
+
// Label directly so the path always shows (labelForClaudeToolUse only
|
|
610
|
+
// knows Edit/Write/Read — it drops the path for Delete).
|
|
611
|
+
this._steps.push({
|
|
612
|
+
label: path ? toolName + ' ' + path : toolName,
|
|
613
|
+
state: 'done',
|
|
614
|
+
startedAt: Date.now(),
|
|
615
|
+
completedAt: Date.now(),
|
|
616
|
+
});
|
|
617
|
+
changed = true;
|
|
618
|
+
}
|
|
619
|
+
return changed;
|
|
620
|
+
}
|
|
621
|
+
// mcp_tool_call + web_search — real visible actions the codex-adapter mirrors
|
|
622
|
+
// to audit; surface them as timeline steps too (2026-06-02 follow-up, Ega-approved
|
|
623
|
+
// class: "watch it work"). Labels mirror codex-adapter conventions.
|
|
624
|
+
if (itemType === 'mcp_tool_call') {
|
|
625
|
+
const server = String(item.server ?? '');
|
|
626
|
+
const tool = String(item.tool ?? 'mcp');
|
|
627
|
+
this._steps.push({
|
|
628
|
+
label: 'MCP · ' + (server ? server + '.' : '') + tool,
|
|
629
|
+
state: 'done',
|
|
630
|
+
startedAt: Date.now(),
|
|
631
|
+
completedAt: Date.now(),
|
|
632
|
+
});
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
if (itemType === 'web_search') {
|
|
636
|
+
const q = String(item.query ?? '').trim().slice(0, 80);
|
|
637
|
+
this._steps.push({
|
|
638
|
+
label: q ? 'Web search · ' + q : 'Web search',
|
|
639
|
+
state: 'done',
|
|
640
|
+
startedAt: Date.now(),
|
|
641
|
+
completedAt: Date.now(),
|
|
642
|
+
});
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
// NOTE (mock-currency 2026-06-02): codex `agent_message` is intentionally NOT
|
|
646
|
+
// captured here — a prior decision (pm-layers.test.ts "just chatter, not a
|
|
647
|
+
// step") drops it. This is INCONSISTENT with ingestClaude/ingestAider, which
|
|
648
|
+
// DO capture agent narration as kind:'say' (per #45 + A1 "watch it work").
|
|
649
|
+
// Resolving that inconsistency is a product taste call → flagged for Ega in
|
|
650
|
+
// mock-currency-2026-06-02.md, not changed unilaterally by the autonomous loop.
|
|
477
651
|
return false;
|
|
478
652
|
}
|
|
479
653
|
}
|
|
@@ -20,6 +20,7 @@ import { OpenCodeAdapter } from './opencode-adapter.js';
|
|
|
20
20
|
import { GeminiAdapter } from './gemini-adapter.js';
|
|
21
21
|
import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js';
|
|
22
22
|
import { buildConductorAdapter } from './conductor-adapter.js';
|
|
23
|
+
import { recordTurnToWorkingMemory } from './memory-injector.js';
|
|
23
24
|
import { seedAgentsConfig } from './agents-config-seed.js';
|
|
24
25
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
25
26
|
import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
|
|
@@ -30,7 +31,7 @@ import { generateNarration } from '../../application/use-cases/generate-narratio
|
|
|
30
31
|
import { randomUUID } from 'crypto';
|
|
31
32
|
import { classifyFailoverEligibility } from './failover-classifier.js';
|
|
32
33
|
import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
|
|
33
|
-
import { loadFailoverConfig
|
|
34
|
+
import { loadFailoverConfig } from './failover-config.js';
|
|
34
35
|
import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
|
|
35
36
|
import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
|
|
36
37
|
import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
|
|
@@ -151,6 +152,14 @@ export class SessionManager {
|
|
|
151
152
|
narrationBySession = new Map();
|
|
152
153
|
narrationTimers = new Map();
|
|
153
154
|
narrationStepsAtLastFire = new Map();
|
|
155
|
+
/** Stall watchdog (dogfood 2026-05-27 #1): a spawned agent can hang with no
|
|
156
|
+
* output and sit "running" forever (opencode did exactly this). We track the
|
|
157
|
+
* timestamp of the last event from each session and, if a running session
|
|
158
|
+
* goes silent past the stall window, kill it and record an honest reason so
|
|
159
|
+
* the UI shows "stopped — no response", never a fake "running" or a normal row. */
|
|
160
|
+
stallTimers = new Map();
|
|
161
|
+
lastActivityBySession = new Map();
|
|
162
|
+
stalledBySession = new Map();
|
|
154
163
|
/** Stores active failover offers keyed by sessionId+target. API layer
|
|
155
164
|
* reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
|
|
156
165
|
failoverOffers;
|
|
@@ -171,7 +180,11 @@ export class SessionManager {
|
|
|
171
180
|
if (!adapter) {
|
|
172
181
|
throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
|
|
173
182
|
}
|
|
174
|
-
|
|
183
|
+
// CODEHERE_MOCK spawns a synthetic agent (mock REPL), so the real CLI need
|
|
184
|
+
// not be installed — bypass the readiness gate. Makes the mock harness truly
|
|
185
|
+
// zero-dependency (dogfood/dev without any agent CLI installed).
|
|
186
|
+
const mockMode = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
|
|
187
|
+
if (!mockMode && !adapter.isInstalled()) {
|
|
175
188
|
throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
|
|
176
189
|
}
|
|
177
190
|
// Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
|
|
@@ -221,7 +234,10 @@ export class SessionManager {
|
|
|
221
234
|
cwd,
|
|
222
235
|
startedAt: session.startedAt,
|
|
223
236
|
});
|
|
237
|
+
this.lastActivityBySession.set(session.sessionId, Date.now());
|
|
224
238
|
const unsubscribe = session.subscribe((event) => {
|
|
239
|
+
// Any event = the agent is alive. Reset the stall clock. (watchdog)
|
|
240
|
+
this.lastActivityBySession.set(session.sessionId, Date.now());
|
|
225
241
|
// ── Failover check — intercept rate-limit / quota errors before exit ──
|
|
226
242
|
// Adapters emit 'codehere:error' (structured: { code, message, recovery })
|
|
227
243
|
// or 'error' (spawn-level: { message }). Both need classification.
|
|
@@ -251,6 +267,7 @@ export class SessionManager {
|
|
|
251
267
|
completedAt,
|
|
252
268
|
steps: session.steps ? [...session.steps] : undefined,
|
|
253
269
|
artifacts: session.artifacts,
|
|
270
|
+
stalledReason: this.stalledBySession.get(session.sessionId)?.reason,
|
|
254
271
|
});
|
|
255
272
|
// v0.6.0 M1+M7 — forward-mirror completion to project thread.
|
|
256
273
|
mirrorCompletedToThread({
|
|
@@ -292,7 +309,22 @@ export class SessionManager {
|
|
|
292
309
|
summary: report.summary,
|
|
293
310
|
generatedAt: report.generatedAt,
|
|
294
311
|
durationMs: report.durationMs,
|
|
312
|
+
source: report.source,
|
|
295
313
|
});
|
|
314
|
+
// Shared-room hand-off: record this turn's outcome to the project's
|
|
315
|
+
// working memory so the NEXT agent spawned here reads it (closes the
|
|
316
|
+
// loop with applyMemoryPrefix). Best-effort; only initialized projects.
|
|
317
|
+
try {
|
|
318
|
+
recordTurnToWorkingMemory(cwd, {
|
|
319
|
+
agent: session.agent,
|
|
320
|
+
prompt: session.prompt,
|
|
321
|
+
summary: report.summary,
|
|
322
|
+
files: session.artifacts?.files?.length,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
/* memory write must never break the run */
|
|
327
|
+
}
|
|
296
328
|
try {
|
|
297
329
|
printFinalReportToTerminal(reportInput, report);
|
|
298
330
|
}
|
|
@@ -334,6 +366,8 @@ export class SessionManager {
|
|
|
334
366
|
clearInterval(t);
|
|
335
367
|
this.narrationTimers.delete(session.sessionId);
|
|
336
368
|
}
|
|
369
|
+
// Watchdog: session settled on its own — stop watching it.
|
|
370
|
+
this.clearStallTimer(session.sessionId);
|
|
337
371
|
this.evictOldCompleted();
|
|
338
372
|
unsubscribe();
|
|
339
373
|
});
|
|
@@ -344,6 +378,7 @@ export class SessionManager {
|
|
|
344
378
|
// failures (no claude binary, timeout, malformed output) silently
|
|
345
379
|
// skip — narration is enrichment, not the primary surface.
|
|
346
380
|
this.startNarrationTimer(session);
|
|
381
|
+
this.startStallWatchdog(session);
|
|
347
382
|
return session;
|
|
348
383
|
}
|
|
349
384
|
/** Tracks which agents have already been tried for a given prompt during
|
|
@@ -355,7 +390,6 @@ export class SessionManager {
|
|
|
355
390
|
* session if auto-spawn was triggered, undefined otherwise. */
|
|
356
391
|
handleFailover(session, signal) {
|
|
357
392
|
const config = loadFailoverConfig();
|
|
358
|
-
const chain = resolveChain(config?.chain);
|
|
359
393
|
const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
|
|
360
394
|
tried.add(signal.fromAgent);
|
|
361
395
|
this.failoverTriedBySession.set(session.sessionId, tried);
|
|
@@ -408,11 +442,14 @@ export class SessionManager {
|
|
|
408
442
|
this.sessions.set(newSession.sessionId, newSession);
|
|
409
443
|
this.spawnOrder.push(newSession.sessionId);
|
|
410
444
|
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
445
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
411
446
|
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
412
447
|
// adapter exits synchronously (command not found, instant crash, etc).
|
|
413
448
|
const unsubscribe = newSession.subscribe((event) => {
|
|
449
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
414
450
|
if (event.type !== 'exit')
|
|
415
451
|
return;
|
|
452
|
+
this.clearStallTimer(newSession.sessionId);
|
|
416
453
|
const data = event.data;
|
|
417
454
|
const completedAt = Date.now();
|
|
418
455
|
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
@@ -423,6 +460,7 @@ export class SessionManager {
|
|
|
423
460
|
completedAt,
|
|
424
461
|
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
425
462
|
artifacts: newSession.artifacts,
|
|
463
|
+
stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
|
|
426
464
|
});
|
|
427
465
|
mirrorCompletedToThread({
|
|
428
466
|
sessionId: newSession.sessionId,
|
|
@@ -451,6 +489,7 @@ export class SessionManager {
|
|
|
451
489
|
summary: report.summary,
|
|
452
490
|
generatedAt: report.generatedAt,
|
|
453
491
|
durationMs: report.durationMs,
|
|
492
|
+
source: report.source,
|
|
454
493
|
});
|
|
455
494
|
try {
|
|
456
495
|
printFinalReportToTerminal(reportInput, report);
|
|
@@ -519,6 +558,7 @@ export class SessionManager {
|
|
|
519
558
|
this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
|
|
520
559
|
this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
|
|
521
560
|
this.startNarrationTimer(newSession);
|
|
561
|
+
this.startStallWatchdog(newSession);
|
|
522
562
|
return newSession;
|
|
523
563
|
}
|
|
524
564
|
catch {
|
|
@@ -539,7 +579,6 @@ export class SessionManager {
|
|
|
539
579
|
this.failoverOffers = new Map();
|
|
540
580
|
}
|
|
541
581
|
const key = `${offer.session.sessionId}-${offer.target}`;
|
|
542
|
-
const expiresAt = Date.now() + 60_000;
|
|
543
582
|
this.failoverOffers.set(key, { offer, createdAt: Date.now() });
|
|
544
583
|
// Auto-expire offers after 60 seconds to keep the map bounded
|
|
545
584
|
setTimeout(() => {
|
|
@@ -551,7 +590,7 @@ export class SessionManager {
|
|
|
551
590
|
if (!this.failoverOffers)
|
|
552
591
|
return [];
|
|
553
592
|
const offers = [];
|
|
554
|
-
for (const [
|
|
593
|
+
for (const [, { offer, createdAt }] of this.failoverOffers) {
|
|
555
594
|
offers.push({
|
|
556
595
|
sessionId: offer.session.sessionId,
|
|
557
596
|
signal: offer.signal,
|
|
@@ -600,11 +639,14 @@ export class SessionManager {
|
|
|
600
639
|
this.sessions.set(newSession.sessionId, newSession);
|
|
601
640
|
this.spawnOrder.push(newSession.sessionId);
|
|
602
641
|
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
642
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
603
643
|
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
604
644
|
// adapter exits synchronously.
|
|
605
645
|
const unsubscribe = newSession.subscribe((event) => {
|
|
646
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
606
647
|
if (event.type !== 'exit')
|
|
607
648
|
return;
|
|
649
|
+
this.clearStallTimer(newSession.sessionId);
|
|
608
650
|
const data = event.data;
|
|
609
651
|
const completedAt = Date.now();
|
|
610
652
|
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
@@ -615,6 +657,7 @@ export class SessionManager {
|
|
|
615
657
|
completedAt,
|
|
616
658
|
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
617
659
|
artifacts: newSession.artifacts,
|
|
660
|
+
stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
|
|
618
661
|
});
|
|
619
662
|
mirrorCompletedToThread({
|
|
620
663
|
sessionId: newSession.sessionId,
|
|
@@ -643,6 +686,7 @@ export class SessionManager {
|
|
|
643
686
|
summary: report.summary,
|
|
644
687
|
generatedAt: report.generatedAt,
|
|
645
688
|
durationMs: report.durationMs,
|
|
689
|
+
source: report.source,
|
|
646
690
|
});
|
|
647
691
|
try {
|
|
648
692
|
printFinalReportToTerminal(reportInput, report);
|
|
@@ -709,6 +753,7 @@ export class SessionManager {
|
|
|
709
753
|
cwd,
|
|
710
754
|
});
|
|
711
755
|
this.startNarrationTimer(newSession);
|
|
756
|
+
this.startStallWatchdog(newSession);
|
|
712
757
|
return newSession;
|
|
713
758
|
}
|
|
714
759
|
/** v0.6 M3 — periodic narration loop per session. */
|
|
@@ -747,6 +792,54 @@ export class SessionManager {
|
|
|
747
792
|
}, intervalMs);
|
|
748
793
|
this.narrationTimers.set(session.sessionId, timer);
|
|
749
794
|
}
|
|
795
|
+
/** Stall watchdog (dogfood 2026-05-27 #1). Polls a running session; if no
|
|
796
|
+
* event has arrived within the stall window, kills it and records an honest
|
|
797
|
+
* reason. Cross-agent — opencode hung, but any adapter can. Timings are
|
|
798
|
+
* env-overridable so the mock harness + tests can drive it fast. Disable
|
|
799
|
+
* entirely with CODEHERE_DISABLE_WATCHDOG=1. */
|
|
800
|
+
startStallWatchdog(session) {
|
|
801
|
+
if (process.env.CODEHERE_DISABLE_WATCHDOG === '1')
|
|
802
|
+
return;
|
|
803
|
+
const stallMs = Number(process.env.CODEHERE_STALL_TIMEOUT_MS ?? 180_000);
|
|
804
|
+
const pollMs = Number(process.env.CODEHERE_STALL_POLL_MS ?? 15_000);
|
|
805
|
+
if (!(stallMs > 0) || !(pollMs > 0))
|
|
806
|
+
return;
|
|
807
|
+
const timer = setInterval(() => {
|
|
808
|
+
if (session.status !== 'running') {
|
|
809
|
+
this.clearStallTimer(session.sessionId);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const last = this.lastActivityBySession.get(session.sessionId) ?? session.startedAt;
|
|
813
|
+
const silentMs = Date.now() - last;
|
|
814
|
+
if (silentMs < stallMs)
|
|
815
|
+
return;
|
|
816
|
+
const reason = `Stopped after no response for ${Math.round(silentMs / 1000)}s — the agent hung without finishing.`;
|
|
817
|
+
this.stalledBySession.set(session.sessionId, { reason, stalledAt: Date.now() });
|
|
818
|
+
this.clearStallTimer(session.sessionId);
|
|
819
|
+
try {
|
|
820
|
+
session.kill();
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
// kill is best-effort; the recorded stall reason + 'killed' status is
|
|
824
|
+
// what the UI reads, so a kill that throws still surfaces honestly.
|
|
825
|
+
}
|
|
826
|
+
}, pollMs);
|
|
827
|
+
this.stallTimers.set(session.sessionId, timer);
|
|
828
|
+
}
|
|
829
|
+
clearStallTimer(sessionId) {
|
|
830
|
+
const t = this.stallTimers.get(sessionId);
|
|
831
|
+
if (t) {
|
|
832
|
+
clearInterval(t);
|
|
833
|
+
this.stallTimers.delete(sessionId);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/** Watchdog verdict for a session — the honest "why it stopped" reason when
|
|
837
|
+
* the stall watchdog killed it, or undefined if it settled on its own.
|
|
838
|
+
* The API layer surfaces this so the dashboard never shows a hung task as a
|
|
839
|
+
* normal row. */
|
|
840
|
+
getStallReason(sessionId) {
|
|
841
|
+
return this.stalledBySession.get(sessionId)?.reason;
|
|
842
|
+
}
|
|
750
843
|
/** v0.6 M3 — latest narration for a session, or undefined. */
|
|
751
844
|
getNarration(sessionId) {
|
|
752
845
|
return this.narrationBySession.get(sessionId);
|
|
@@ -809,6 +902,9 @@ export class SessionManager {
|
|
|
809
902
|
clearInterval(t);
|
|
810
903
|
this.narrationTimers.delete(id);
|
|
811
904
|
}
|
|
905
|
+
this.clearStallTimer(id);
|
|
906
|
+
this.lastActivityBySession.delete(id);
|
|
907
|
+
this.stalledBySession.delete(id);
|
|
812
908
|
this.sessions.delete(id);
|
|
813
909
|
this.completedAtBySession.delete(id);
|
|
814
910
|
this.cwdBySession.delete(id);
|
|
@@ -833,10 +929,14 @@ export class SessionManager {
|
|
|
833
929
|
}
|
|
834
930
|
/** Graceful shutdown: kill all running sessions and clear all timers. */
|
|
835
931
|
shutdown() {
|
|
836
|
-
for (const [
|
|
932
|
+
for (const [, timer] of this.narrationTimers) {
|
|
837
933
|
clearInterval(timer);
|
|
838
934
|
}
|
|
839
935
|
this.narrationTimers.clear();
|
|
936
|
+
for (const [, timer] of this.stallTimers) {
|
|
937
|
+
clearInterval(timer);
|
|
938
|
+
}
|
|
939
|
+
this.stallTimers.clear();
|
|
840
940
|
for (const session of this.sessions.values()) {
|
|
841
941
|
if (session.status === 'running')
|
|
842
942
|
session.kill();
|
|
@@ -982,4 +1082,10 @@ export class SessionManager {
|
|
|
982
1082
|
export function __testOnlyRegisterAdapter(adapter) {
|
|
983
1083
|
REGISTRY.push(adapter);
|
|
984
1084
|
}
|
|
1085
|
+
/** @internal Resets REGISTRY to BUILTIN_REGISTRY for test isolation.
|
|
1086
|
+
* Call before __testOnlyRegisterAdapter to avoid adapter accumulation. */
|
|
1087
|
+
export function __testOnlyResetRegistry() {
|
|
1088
|
+
REGISTRY.length = 0;
|
|
1089
|
+
REGISTRY.push(...BUILTIN_REGISTRY);
|
|
1090
|
+
}
|
|
985
1091
|
//# sourceMappingURL=session-manager.js.map
|