codehere 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +28 -4
- 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 +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 +146 -13
- 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/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 +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +94 -6
- 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 +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
- 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 +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 +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) {
|
|
@@ -474,6 +593,61 @@ export class ProgressAggregator {
|
|
|
474
593
|
});
|
|
475
594
|
return true;
|
|
476
595
|
}
|
|
596
|
+
// file_change — codex emits one item per Edit/Write/Delete batch. The audit
|
|
597
|
+
// mirror (codex-adapter.ts) already handles these; the timeline-step path was
|
|
598
|
+
// dropping them, so real codex file edits were invisible in "watch it work"
|
|
599
|
+
// (mock-currency audit 2026-06-02 caught this — the mock emits the real shape).
|
|
600
|
+
if (itemType === 'file_change') {
|
|
601
|
+
const changes = Array.isArray(item.changes)
|
|
602
|
+
? item.changes
|
|
603
|
+
: [];
|
|
604
|
+
let changed = false;
|
|
605
|
+
for (const ch of changes) {
|
|
606
|
+
const path = String(ch.path ?? '');
|
|
607
|
+
const kind = String(ch.kind ?? 'update');
|
|
608
|
+
const toolName = kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit';
|
|
609
|
+
// Label directly so the path always shows (labelForClaudeToolUse only
|
|
610
|
+
// knows Edit/Write/Read — it drops the path for Delete).
|
|
611
|
+
this._steps.push({
|
|
612
|
+
label: path ? toolName + ' ' + path : toolName,
|
|
613
|
+
state: 'done',
|
|
614
|
+
startedAt: Date.now(),
|
|
615
|
+
completedAt: Date.now(),
|
|
616
|
+
});
|
|
617
|
+
changed = true;
|
|
618
|
+
}
|
|
619
|
+
return changed;
|
|
620
|
+
}
|
|
621
|
+
// mcp_tool_call + web_search — real visible actions the codex-adapter mirrors
|
|
622
|
+
// to audit; surface them as timeline steps too (2026-06-02 follow-up, Ega-approved
|
|
623
|
+
// class: "watch it work"). Labels mirror codex-adapter conventions.
|
|
624
|
+
if (itemType === 'mcp_tool_call') {
|
|
625
|
+
const server = String(item.server ?? '');
|
|
626
|
+
const tool = String(item.tool ?? 'mcp');
|
|
627
|
+
this._steps.push({
|
|
628
|
+
label: 'MCP · ' + (server ? server + '.' : '') + tool,
|
|
629
|
+
state: 'done',
|
|
630
|
+
startedAt: Date.now(),
|
|
631
|
+
completedAt: Date.now(),
|
|
632
|
+
});
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
if (itemType === 'web_search') {
|
|
636
|
+
const q = String(item.query ?? '').trim().slice(0, 80);
|
|
637
|
+
this._steps.push({
|
|
638
|
+
label: q ? 'Web search · ' + q : 'Web search',
|
|
639
|
+
state: 'done',
|
|
640
|
+
startedAt: Date.now(),
|
|
641
|
+
completedAt: Date.now(),
|
|
642
|
+
});
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
// NOTE (mock-currency 2026-06-02): codex `agent_message` is intentionally NOT
|
|
646
|
+
// captured here — a prior decision (pm-layers.test.ts "just chatter, not a
|
|
647
|
+
// step") drops it. This is INCONSISTENT with ingestClaude/ingestAider, which
|
|
648
|
+
// DO capture agent narration as kind:'say' (per #45 + A1 "watch it work").
|
|
649
|
+
// Resolving that inconsistency is a product taste call → flagged for Ega in
|
|
650
|
+
// mock-currency-2026-06-02.md, not changed unilaterally by the autonomous loop.
|
|
477
651
|
return false;
|
|
478
652
|
}
|
|
479
653
|
}
|
|
@@ -30,7 +30,7 @@ import { generateNarration } from '../../application/use-cases/generate-narratio
|
|
|
30
30
|
import { randomUUID } from 'crypto';
|
|
31
31
|
import { classifyFailoverEligibility } from './failover-classifier.js';
|
|
32
32
|
import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
|
|
33
|
-
import { loadFailoverConfig
|
|
33
|
+
import { loadFailoverConfig } from './failover-config.js';
|
|
34
34
|
import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
|
|
35
35
|
import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
|
|
36
36
|
import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
|
|
@@ -151,6 +151,14 @@ export class SessionManager {
|
|
|
151
151
|
narrationBySession = new Map();
|
|
152
152
|
narrationTimers = new Map();
|
|
153
153
|
narrationStepsAtLastFire = new Map();
|
|
154
|
+
/** Stall watchdog (dogfood 2026-05-27 #1): a spawned agent can hang with no
|
|
155
|
+
* output and sit "running" forever (opencode did exactly this). We track the
|
|
156
|
+
* timestamp of the last event from each session and, if a running session
|
|
157
|
+
* goes silent past the stall window, kill it and record an honest reason so
|
|
158
|
+
* the UI shows "stopped — no response", never a fake "running" or a normal row. */
|
|
159
|
+
stallTimers = new Map();
|
|
160
|
+
lastActivityBySession = new Map();
|
|
161
|
+
stalledBySession = new Map();
|
|
154
162
|
/** Stores active failover offers keyed by sessionId+target. API layer
|
|
155
163
|
* reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
|
|
156
164
|
failoverOffers;
|
|
@@ -171,7 +179,11 @@ export class SessionManager {
|
|
|
171
179
|
if (!adapter) {
|
|
172
180
|
throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
|
|
173
181
|
}
|
|
174
|
-
|
|
182
|
+
// CODEHERE_MOCK spawns a synthetic agent (mock REPL), so the real CLI need
|
|
183
|
+
// not be installed — bypass the readiness gate. Makes the mock harness truly
|
|
184
|
+
// zero-dependency (dogfood/dev without any agent CLI installed).
|
|
185
|
+
const mockMode = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
|
|
186
|
+
if (!mockMode && !adapter.isInstalled()) {
|
|
175
187
|
throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
|
|
176
188
|
}
|
|
177
189
|
// Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
|
|
@@ -221,7 +233,10 @@ export class SessionManager {
|
|
|
221
233
|
cwd,
|
|
222
234
|
startedAt: session.startedAt,
|
|
223
235
|
});
|
|
236
|
+
this.lastActivityBySession.set(session.sessionId, Date.now());
|
|
224
237
|
const unsubscribe = session.subscribe((event) => {
|
|
238
|
+
// Any event = the agent is alive. Reset the stall clock. (watchdog)
|
|
239
|
+
this.lastActivityBySession.set(session.sessionId, Date.now());
|
|
225
240
|
// ── Failover check — intercept rate-limit / quota errors before exit ──
|
|
226
241
|
// Adapters emit 'codehere:error' (structured: { code, message, recovery })
|
|
227
242
|
// or 'error' (spawn-level: { message }). Both need classification.
|
|
@@ -251,6 +266,7 @@ export class SessionManager {
|
|
|
251
266
|
completedAt,
|
|
252
267
|
steps: session.steps ? [...session.steps] : undefined,
|
|
253
268
|
artifacts: session.artifacts,
|
|
269
|
+
stalledReason: this.stalledBySession.get(session.sessionId)?.reason,
|
|
254
270
|
});
|
|
255
271
|
// v0.6.0 M1+M7 — forward-mirror completion to project thread.
|
|
256
272
|
mirrorCompletedToThread({
|
|
@@ -334,6 +350,8 @@ export class SessionManager {
|
|
|
334
350
|
clearInterval(t);
|
|
335
351
|
this.narrationTimers.delete(session.sessionId);
|
|
336
352
|
}
|
|
353
|
+
// Watchdog: session settled on its own — stop watching it.
|
|
354
|
+
this.clearStallTimer(session.sessionId);
|
|
337
355
|
this.evictOldCompleted();
|
|
338
356
|
unsubscribe();
|
|
339
357
|
});
|
|
@@ -344,6 +362,7 @@ export class SessionManager {
|
|
|
344
362
|
// failures (no claude binary, timeout, malformed output) silently
|
|
345
363
|
// skip — narration is enrichment, not the primary surface.
|
|
346
364
|
this.startNarrationTimer(session);
|
|
365
|
+
this.startStallWatchdog(session);
|
|
347
366
|
return session;
|
|
348
367
|
}
|
|
349
368
|
/** Tracks which agents have already been tried for a given prompt during
|
|
@@ -355,7 +374,6 @@ export class SessionManager {
|
|
|
355
374
|
* session if auto-spawn was triggered, undefined otherwise. */
|
|
356
375
|
handleFailover(session, signal) {
|
|
357
376
|
const config = loadFailoverConfig();
|
|
358
|
-
const chain = resolveChain(config?.chain);
|
|
359
377
|
const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
|
|
360
378
|
tried.add(signal.fromAgent);
|
|
361
379
|
this.failoverTriedBySession.set(session.sessionId, tried);
|
|
@@ -408,11 +426,14 @@ export class SessionManager {
|
|
|
408
426
|
this.sessions.set(newSession.sessionId, newSession);
|
|
409
427
|
this.spawnOrder.push(newSession.sessionId);
|
|
410
428
|
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
429
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
411
430
|
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
412
431
|
// adapter exits synchronously (command not found, instant crash, etc).
|
|
413
432
|
const unsubscribe = newSession.subscribe((event) => {
|
|
433
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
414
434
|
if (event.type !== 'exit')
|
|
415
435
|
return;
|
|
436
|
+
this.clearStallTimer(newSession.sessionId);
|
|
416
437
|
const data = event.data;
|
|
417
438
|
const completedAt = Date.now();
|
|
418
439
|
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
@@ -423,6 +444,7 @@ export class SessionManager {
|
|
|
423
444
|
completedAt,
|
|
424
445
|
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
425
446
|
artifacts: newSession.artifacts,
|
|
447
|
+
stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
|
|
426
448
|
});
|
|
427
449
|
mirrorCompletedToThread({
|
|
428
450
|
sessionId: newSession.sessionId,
|
|
@@ -519,6 +541,7 @@ export class SessionManager {
|
|
|
519
541
|
this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
|
|
520
542
|
this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
|
|
521
543
|
this.startNarrationTimer(newSession);
|
|
544
|
+
this.startStallWatchdog(newSession);
|
|
522
545
|
return newSession;
|
|
523
546
|
}
|
|
524
547
|
catch {
|
|
@@ -539,7 +562,6 @@ export class SessionManager {
|
|
|
539
562
|
this.failoverOffers = new Map();
|
|
540
563
|
}
|
|
541
564
|
const key = `${offer.session.sessionId}-${offer.target}`;
|
|
542
|
-
const expiresAt = Date.now() + 60_000;
|
|
543
565
|
this.failoverOffers.set(key, { offer, createdAt: Date.now() });
|
|
544
566
|
// Auto-expire offers after 60 seconds to keep the map bounded
|
|
545
567
|
setTimeout(() => {
|
|
@@ -551,7 +573,7 @@ export class SessionManager {
|
|
|
551
573
|
if (!this.failoverOffers)
|
|
552
574
|
return [];
|
|
553
575
|
const offers = [];
|
|
554
|
-
for (const [
|
|
576
|
+
for (const [, { offer, createdAt }] of this.failoverOffers) {
|
|
555
577
|
offers.push({
|
|
556
578
|
sessionId: offer.session.sessionId,
|
|
557
579
|
signal: offer.signal,
|
|
@@ -600,11 +622,14 @@ export class SessionManager {
|
|
|
600
622
|
this.sessions.set(newSession.sessionId, newSession);
|
|
601
623
|
this.spawnOrder.push(newSession.sessionId);
|
|
602
624
|
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
625
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
603
626
|
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
604
627
|
// adapter exits synchronously.
|
|
605
628
|
const unsubscribe = newSession.subscribe((event) => {
|
|
629
|
+
this.lastActivityBySession.set(newSession.sessionId, Date.now());
|
|
606
630
|
if (event.type !== 'exit')
|
|
607
631
|
return;
|
|
632
|
+
this.clearStallTimer(newSession.sessionId);
|
|
608
633
|
const data = event.data;
|
|
609
634
|
const completedAt = Date.now();
|
|
610
635
|
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
@@ -615,6 +640,7 @@ export class SessionManager {
|
|
|
615
640
|
completedAt,
|
|
616
641
|
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
617
642
|
artifacts: newSession.artifacts,
|
|
643
|
+
stalledReason: this.stalledBySession.get(newSession.sessionId)?.reason,
|
|
618
644
|
});
|
|
619
645
|
mirrorCompletedToThread({
|
|
620
646
|
sessionId: newSession.sessionId,
|
|
@@ -709,6 +735,7 @@ export class SessionManager {
|
|
|
709
735
|
cwd,
|
|
710
736
|
});
|
|
711
737
|
this.startNarrationTimer(newSession);
|
|
738
|
+
this.startStallWatchdog(newSession);
|
|
712
739
|
return newSession;
|
|
713
740
|
}
|
|
714
741
|
/** v0.6 M3 — periodic narration loop per session. */
|
|
@@ -747,6 +774,54 @@ export class SessionManager {
|
|
|
747
774
|
}, intervalMs);
|
|
748
775
|
this.narrationTimers.set(session.sessionId, timer);
|
|
749
776
|
}
|
|
777
|
+
/** Stall watchdog (dogfood 2026-05-27 #1). Polls a running session; if no
|
|
778
|
+
* event has arrived within the stall window, kills it and records an honest
|
|
779
|
+
* reason. Cross-agent — opencode hung, but any adapter can. Timings are
|
|
780
|
+
* env-overridable so the mock harness + tests can drive it fast. Disable
|
|
781
|
+
* entirely with CODEHERE_DISABLE_WATCHDOG=1. */
|
|
782
|
+
startStallWatchdog(session) {
|
|
783
|
+
if (process.env.CODEHERE_DISABLE_WATCHDOG === '1')
|
|
784
|
+
return;
|
|
785
|
+
const stallMs = Number(process.env.CODEHERE_STALL_TIMEOUT_MS ?? 180_000);
|
|
786
|
+
const pollMs = Number(process.env.CODEHERE_STALL_POLL_MS ?? 15_000);
|
|
787
|
+
if (!(stallMs > 0) || !(pollMs > 0))
|
|
788
|
+
return;
|
|
789
|
+
const timer = setInterval(() => {
|
|
790
|
+
if (session.status !== 'running') {
|
|
791
|
+
this.clearStallTimer(session.sessionId);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const last = this.lastActivityBySession.get(session.sessionId) ?? session.startedAt;
|
|
795
|
+
const silentMs = Date.now() - last;
|
|
796
|
+
if (silentMs < stallMs)
|
|
797
|
+
return;
|
|
798
|
+
const reason = `Stopped after no response for ${Math.round(silentMs / 1000)}s — the agent hung without finishing.`;
|
|
799
|
+
this.stalledBySession.set(session.sessionId, { reason, stalledAt: Date.now() });
|
|
800
|
+
this.clearStallTimer(session.sessionId);
|
|
801
|
+
try {
|
|
802
|
+
session.kill();
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
// kill is best-effort; the recorded stall reason + 'killed' status is
|
|
806
|
+
// what the UI reads, so a kill that throws still surfaces honestly.
|
|
807
|
+
}
|
|
808
|
+
}, pollMs);
|
|
809
|
+
this.stallTimers.set(session.sessionId, timer);
|
|
810
|
+
}
|
|
811
|
+
clearStallTimer(sessionId) {
|
|
812
|
+
const t = this.stallTimers.get(sessionId);
|
|
813
|
+
if (t) {
|
|
814
|
+
clearInterval(t);
|
|
815
|
+
this.stallTimers.delete(sessionId);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/** Watchdog verdict for a session — the honest "why it stopped" reason when
|
|
819
|
+
* the stall watchdog killed it, or undefined if it settled on its own.
|
|
820
|
+
* The API layer surfaces this so the dashboard never shows a hung task as a
|
|
821
|
+
* normal row. */
|
|
822
|
+
getStallReason(sessionId) {
|
|
823
|
+
return this.stalledBySession.get(sessionId)?.reason;
|
|
824
|
+
}
|
|
750
825
|
/** v0.6 M3 — latest narration for a session, or undefined. */
|
|
751
826
|
getNarration(sessionId) {
|
|
752
827
|
return this.narrationBySession.get(sessionId);
|
|
@@ -809,6 +884,9 @@ export class SessionManager {
|
|
|
809
884
|
clearInterval(t);
|
|
810
885
|
this.narrationTimers.delete(id);
|
|
811
886
|
}
|
|
887
|
+
this.clearStallTimer(id);
|
|
888
|
+
this.lastActivityBySession.delete(id);
|
|
889
|
+
this.stalledBySession.delete(id);
|
|
812
890
|
this.sessions.delete(id);
|
|
813
891
|
this.completedAtBySession.delete(id);
|
|
814
892
|
this.cwdBySession.delete(id);
|
|
@@ -833,10 +911,14 @@ export class SessionManager {
|
|
|
833
911
|
}
|
|
834
912
|
/** Graceful shutdown: kill all running sessions and clear all timers. */
|
|
835
913
|
shutdown() {
|
|
836
|
-
for (const [
|
|
914
|
+
for (const [, timer] of this.narrationTimers) {
|
|
837
915
|
clearInterval(timer);
|
|
838
916
|
}
|
|
839
917
|
this.narrationTimers.clear();
|
|
918
|
+
for (const [, timer] of this.stallTimers) {
|
|
919
|
+
clearInterval(timer);
|
|
920
|
+
}
|
|
921
|
+
this.stallTimers.clear();
|
|
840
922
|
for (const session of this.sessions.values()) {
|
|
841
923
|
if (session.status === 'running')
|
|
842
924
|
session.kill();
|
|
@@ -982,4 +1064,10 @@ export class SessionManager {
|
|
|
982
1064
|
export function __testOnlyRegisterAdapter(adapter) {
|
|
983
1065
|
REGISTRY.push(adapter);
|
|
984
1066
|
}
|
|
1067
|
+
/** @internal Resets REGISTRY to BUILTIN_REGISTRY for test isolation.
|
|
1068
|
+
* Call before __testOnlyRegisterAdapter to avoid adapter accumulation. */
|
|
1069
|
+
export function __testOnlyResetRegistry() {
|
|
1070
|
+
REGISTRY.length = 0;
|
|
1071
|
+
REGISTRY.push(...BUILTIN_REGISTRY);
|
|
1072
|
+
}
|
|
985
1073
|
//# sourceMappingURL=session-manager.js.map
|