codehere 0.8.0 → 1.0.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 +22 -13
- package/dist/application/services/verdict-service.js +154 -0
- package/dist/application/use-cases/assess-cross-vendor.js +126 -0
- package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
- package/dist/application/use-cases/export-aat.js +107 -0
- package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
- package/dist/application/use-cases/generate-final-report.js +140 -15
- package/dist/application/use-cases/generate-narration.js +5 -3
- package/dist/application/use-cases/generate-thread-title.js +5 -2
- package/dist/application/use-cases/plan-auto-review.js +57 -0
- package/dist/application/use-cases/reconcile-audit.js +100 -0
- package/dist/application/use-cases/rerun-tests.js +226 -0
- package/dist/application/use-cases/search-sessions.js +70 -0
- package/dist/application/use-cases/should-auto-review.js +71 -0
- package/dist/application/use-cases/thread-export.js +90 -0
- package/dist/application/use-cases/verify-claim.js +187 -5
- package/dist/domain/audit.js +11 -0
- package/dist/domain/services/codebase-detector.js +0 -6
- package/dist/domain/task.js +91 -0
- package/dist/error-handler.js +3 -115
- package/dist/index.js +269 -25
- package/dist/infrastructure/completion/bash-completion.js +7 -3
- package/dist/infrastructure/completion/fish-completion.js +7 -12
- package/dist/infrastructure/completion/zsh-completion.js +7 -12
- package/dist/infrastructure/config/config-manager.js +4 -3
- package/dist/infrastructure/config/product-context.js +1 -19
- package/dist/infrastructure/context/adapters/figma-source.js +37 -2
- package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
- package/dist/infrastructure/context/github-api.js +110 -0
- package/dist/infrastructure/context/github-device-flow.js +65 -0
- package/dist/infrastructure/context/oauth-store.js +5 -2
- package/dist/infrastructure/context/registry-bootstrap.js +0 -4
- package/dist/infrastructure/cost/budget-config.js +3 -2
- package/dist/infrastructure/cost/cost-router.js +16 -23
- package/dist/infrastructure/cost/spend-aggregator.js +90 -15
- package/dist/infrastructure/cost/team-ledger.js +355 -0
- package/dist/infrastructure/discovery/discovery-log.js +85 -0
- package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
- package/dist/infrastructure/hooks/hook-mode.js +20 -4
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
- package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
- package/dist/infrastructure/observability/execution-tracer.js +1 -1
- package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
- package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
- package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
- package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
- package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
- package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
- package/dist/infrastructure/orchestration/context-handoff.js +32 -14
- package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
- package/dist/infrastructure/orchestration/failover-config.js +0 -10
- package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
- package/dist/infrastructure/orchestration/graphify-context.js +14 -59
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
- package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
- package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
- package/dist/infrastructure/orchestration/session-manager.js +593 -94
- package/dist/infrastructure/orchestration/session-persistence.js +177 -41
- package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
- package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
- package/dist/infrastructure/protocols/mcp-tools.js +401 -0
- package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
- package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
- package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
- package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
- package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
- package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
- package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
- package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
- package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
- package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
- package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
- package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
- package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
- package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
- package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
- package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
- package/dist/infrastructure/protocols/ui/styles.js +2147 -0
- package/dist/infrastructure/protocols/ui/tokens.js +96 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
- package/dist/infrastructure/protocols/verify-page.js +849 -155
- package/dist/infrastructure/security/fix-code.js +144 -0
- package/dist/infrastructure/security/fix-secret.js +89 -0
- package/dist/infrastructure/security/permissions-config.js +3 -2
- package/dist/infrastructure/security/scan-types.js +10 -0
- package/dist/infrastructure/security/ship-scan.js +195 -0
- package/dist/infrastructure/security/trust-scorer.js +70 -3
- package/dist/infrastructure/security/vibe-checks.js +99 -0
- package/dist/infrastructure/storage/atomic-write.js +86 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
- package/dist/infrastructure/storage/project-thread-reader.js +261 -24
- package/dist/monitoring.js +2 -2
- package/dist/presentation/cli/commands/audit-command.js +79 -4
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/check-command.js +96 -0
- package/dist/presentation/cli/commands/context-source-command.js +20 -1
- package/dist/presentation/cli/commands/help-command.js +5 -5
- package/dist/presentation/cli/commands/hook-command.js +5 -5
- package/dist/presentation/cli/commands/init-command.js +26 -14
- package/dist/presentation/cli/commands/memory-command.js +28 -28
- package/dist/presentation/cli/commands/permission-command.js +1 -12
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/protocols-command.js +4 -4
- package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
- package/dist/presentation/cli/commands/report-command.js +48 -32
- package/dist/presentation/cli/commands/scan-command.js +52 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
- package/dist/presentation/cli/commands/search-command.js +51 -0
- package/dist/presentation/cli/commands/setup-command.js +31 -8
- package/dist/presentation/cli/commands/status-command.js +39 -86
- package/dist/presentation/cli/commands/team-command.js +256 -0
- package/dist/presentation/cli/commands/thread-command.js +87 -5
- package/dist/presentation/cli/commands/trust-command.js +53 -31
- package/dist/presentation/cli/commands/verify-command.js +74 -9
- package/dist/presentation/cli/error-display.js +0 -8
- package/dist/presentation/cli/repl.js +15 -7
- package/dist/presentation/cli/task-deliverable.js +36 -10
- package/dist/testing/replay-adapter.js +0 -19
- package/dist/ui.js +51 -239
- package/dist/utils/version.js +0 -6
- package/package.json +30 -10
- package/dist/domain/entities/task.js +0 -61
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- package/dist/infrastructure/storage/task-repository.js +0 -206
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* Durable state lives in the audit log (F0). On daemon restart, spawned
|
|
12
12
|
* sessions are gone but observed ones remain (they're re-derived from the log).
|
|
13
13
|
*/
|
|
14
|
-
import { existsSync, readFileSync } from 'fs';
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
15
15
|
import { execFileSync } from 'child_process';
|
|
16
|
+
import { EventEmitter } from 'node:events';
|
|
16
17
|
import { ClaudeCodeAdapter } from './claude-code-adapter.js';
|
|
17
18
|
import { CodexAdapter } from './codex-adapter.js';
|
|
18
19
|
import { AiderAdapter } from './aider-adapter.js';
|
|
@@ -22,18 +23,29 @@ import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js'
|
|
|
22
23
|
import { buildConductorAdapter } from './conductor-adapter.js';
|
|
23
24
|
import { recordTurnToWorkingMemory } from './memory-injector.js';
|
|
24
25
|
import { seedAgentsConfig } from './agents-config-seed.js';
|
|
26
|
+
import { applyTestObservation } from '../../domain/task.js';
|
|
25
27
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
26
|
-
import {
|
|
28
|
+
import { signAuditTip } from '../hooks/audit-tip-signature.js';
|
|
29
|
+
import { resumeClaudeMessage } from './claude-code-adapter.js';
|
|
30
|
+
import { appendSpawned, appendCompleted, appendFinalReport, appendAuditRecon, loadRecent, loadSessionById, } from './session-persistence.js';
|
|
27
31
|
import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
|
|
28
|
-
import {
|
|
32
|
+
import { generateAcceptanceCriteria, gradeAcceptanceCriteria, isTrivialRequest, } from '../../application/use-cases/generate-acceptance-criteria.js';
|
|
33
|
+
import { mirrorSpawnedToThread, mirrorCompletedToThread, mirrorFinalReportToThread, } from '../storage/project-thread-mirror.js';
|
|
29
34
|
import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
|
|
30
35
|
import { generateNarration } from '../../application/use-cases/generate-narration.js';
|
|
31
36
|
import { randomUUID } from 'crypto';
|
|
32
37
|
import { classifyFailoverEligibility } from './failover-classifier.js';
|
|
33
38
|
import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
|
|
34
39
|
import { loadFailoverConfig } from './failover-config.js';
|
|
40
|
+
import { loadBudget, isOverBudget } from '../cost/budget-config.js';
|
|
41
|
+
import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
|
|
42
|
+
import { planAutoReview } from '../../application/use-cases/plan-auto-review.js';
|
|
43
|
+
import { rerunTests, detectTestCommand } from '../../application/use-cases/rerun-tests.js';
|
|
44
|
+
import { reconcileAudit } from '../../application/use-cases/reconcile-audit.js';
|
|
45
|
+
import { collectAuditEntriesForSession } from '../../application/use-cases/thread-export.js';
|
|
35
46
|
import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
|
|
36
|
-
import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
|
|
47
|
+
import { buildFailoverHandoff, enrichPromptWithHandoff, enrichPromptWithThreadHandoff } from './context-handoff.js';
|
|
48
|
+
import { readMergedProjectThread, listProjects } from '../storage/project-thread-reader.js';
|
|
37
49
|
import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
|
|
38
50
|
// ── Agent registry ─────────────────────────────────────────────────────────────
|
|
39
51
|
// All backends codehere can spawn. Order here drives the default UI dropdown
|
|
@@ -62,6 +74,24 @@ const REGISTRY = [
|
|
|
62
74
|
// returns the current populated array (including any future user adapters).
|
|
63
75
|
// Conductor is appended to the same array so UIs that iterate REGISTRY see it.
|
|
64
76
|
REGISTRY.push(buildConductorAdapter(() => REGISTRY));
|
|
77
|
+
/**
|
|
78
|
+
* The stall watchdog window, per agent. Most adapters STREAM events as they
|
|
79
|
+
* work (claude/codex/gemini/aider), so 180s of silence is a real hang. OpenCode
|
|
80
|
+
* in `--format json` is SILENT BY DESIGN — it buffers all stdout until the run
|
|
81
|
+
* completes (verified: `opencode run --format json` emits 0 bytes until done),
|
|
82
|
+
* so it never resets the stall clock mid-run. On a slow local model that easily
|
|
83
|
+
* exceeds 180s, the watchdog killed a working opencode mid-edit (real-mode
|
|
84
|
+
* dogfood 2026-06-13). Silent-buffering adapters get a longer leash; a truly
|
|
85
|
+
* hung opencode is still caught, just later. CODEHERE_STALL_TIMEOUT_MS overrides
|
|
86
|
+
* the default for every agent.
|
|
87
|
+
*/
|
|
88
|
+
const SILENT_BUFFERING_AGENTS = new Set(['opencode']);
|
|
89
|
+
export function stallWindowFor(agent) {
|
|
90
|
+
const override = process.env.CODEHERE_STALL_TIMEOUT_MS;
|
|
91
|
+
if (override !== undefined && override !== '')
|
|
92
|
+
return Number(override);
|
|
93
|
+
return SILENT_BUFFERING_AGENTS.has(agent) ? 600_000 : 180_000;
|
|
94
|
+
}
|
|
65
95
|
export function availableAgents() {
|
|
66
96
|
return REGISTRY.map((a) => ({
|
|
67
97
|
id: a.id,
|
|
@@ -119,6 +149,14 @@ export class SessionManager {
|
|
|
119
149
|
* prevent unbounded memory growth in the internal tracking maps. The data
|
|
120
150
|
* survives on disk via sessions.jsonl + thread.jsonl. */
|
|
121
151
|
static MAX_COMPLETED_SESSIONS = 100;
|
|
152
|
+
/** Live-progress push. The /api/events/sessions SSE stream watches
|
|
153
|
+
* sessions.jsonl for changes, but that file is only written on spawn +
|
|
154
|
+
* completion — so during a run the browser got NO updates and the room
|
|
155
|
+
* sat at "0 steps" until the agent finished, then jumped straight to the
|
|
156
|
+
* outcome (user-#1, 2026-06-07). Steps DO update in memory; this emitter
|
|
157
|
+
* lets the SSE layer push a "change" the instant they do, so the room
|
|
158
|
+
* actually watches the agent work. */
|
|
159
|
+
liveChanges = new EventEmitter();
|
|
122
160
|
sessions = new Map();
|
|
123
161
|
/** Spawn order FIFO — used to evict oldest completed sessions when the cap
|
|
124
162
|
* is reached. Pushed on spawn, referenced by evictOldCompleted(). */
|
|
@@ -127,29 +165,33 @@ export class SessionManager {
|
|
|
127
165
|
* These subprocesses are long-dead; we surface them so the user doesn't lose
|
|
128
166
|
* their Final reports across restarts. */
|
|
129
167
|
persistedCache = loadRecent(20);
|
|
130
|
-
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
168
|
+
/**
|
|
169
|
+
* R13 / M3 — THE typed per-session state record, replacing the ~17
|
|
170
|
+
* `*BySession` maps one field at a time (each map deleted in the commit its
|
|
171
|
+
* field moves here — move-not-add). One id, one record, one lifecycle:
|
|
172
|
+
* created on first write, dropped whole at eviction. When the migration
|
|
173
|
+
* completes this becomes the single source for everything the manager
|
|
174
|
+
* tracks about a session beyond the adapter's live TaskSession handle,
|
|
175
|
+
* and `CompletedSession` (the completeness invariant: tests + files +
|
|
176
|
+
* audit + report, or an explicit honest reason for each absence) types the
|
|
177
|
+
* terminal state.
|
|
178
|
+
*/
|
|
179
|
+
records = new Map();
|
|
180
|
+
/** The session's state record, created on first write. Reads that must not
|
|
181
|
+
* create should use `this.records.get(id)?.field`. */
|
|
182
|
+
rec(id) {
|
|
183
|
+
let r = this.records.get(id);
|
|
184
|
+
if (!r) {
|
|
185
|
+
r = {};
|
|
186
|
+
this.records.set(id, r);
|
|
187
|
+
}
|
|
188
|
+
return r;
|
|
189
|
+
}
|
|
147
190
|
/** v0.6 M3 · PM Narrator. Mid-task plain-English summaries cached per
|
|
148
191
|
* session so the API can serve the latest narration without re-running
|
|
149
192
|
* the LLM call on every frontend poll. Latest-only — older narrations
|
|
150
193
|
* are overwritten when a new one fires. Per claim_user_persona HIGH:
|
|
151
194
|
* non-technical users read narration, not tool calls. */
|
|
152
|
-
narrationBySession = new Map();
|
|
153
195
|
narrationTimers = new Map();
|
|
154
196
|
narrationStepsAtLastFire = new Map();
|
|
155
197
|
/** Stall watchdog (dogfood 2026-05-27 #1): a spawned agent can hang with no
|
|
@@ -158,28 +200,56 @@ export class SessionManager {
|
|
|
158
200
|
* goes silent past the stall window, kill it and record an honest reason so
|
|
159
201
|
* the UI shows "stopped — no response", never a fake "running" or a normal row. */
|
|
160
202
|
stallTimers = new Map();
|
|
161
|
-
lastActivityBySession = new Map();
|
|
162
|
-
stalledBySession = new Map();
|
|
163
203
|
/** Stores active failover offers keyed by sessionId+target. API layer
|
|
164
204
|
* reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
|
|
165
205
|
failoverOffers;
|
|
166
|
-
/**
|
|
167
|
-
*
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the turn a continuation hands off from, into the prior agent +
|
|
208
|
+
* prompt + Final Report summary. Reads the durable project thread (so it
|
|
209
|
+
* survives a daemon restart and unifies clones) via the same merged reader
|
|
210
|
+
* the room renders from — agent and human see one truth. Best-effort: any
|
|
211
|
+
* failure (no project record yet, parent not found) returns null, and the
|
|
212
|
+
* spawn proceeds without a preamble rather than breaking.
|
|
213
|
+
*/
|
|
214
|
+
lookupThreadHandoffParent(parentSessionId, cwd) {
|
|
215
|
+
try {
|
|
216
|
+
const proj = listProjects().find((p) => (p.localPaths || []).includes(cwd) || p.cwd === cwd);
|
|
217
|
+
if (!proj)
|
|
218
|
+
return null;
|
|
219
|
+
const thread = readMergedProjectThread(proj.slug);
|
|
220
|
+
const parent = thread?.tasks.find((t) => t.sessionId === parentSessionId);
|
|
221
|
+
if (!parent)
|
|
222
|
+
return null;
|
|
223
|
+
return {
|
|
224
|
+
agent: String(parent.agent || 'an agent'),
|
|
225
|
+
prompt: parent.prompt,
|
|
226
|
+
summary: parent.finalReport?.summary || undefined,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
178
233
|
spawn(prompt, agentId = 'claude-code', options) {
|
|
179
234
|
const adapter = getAdapter(agentId);
|
|
180
235
|
if (!adapter) {
|
|
181
236
|
throw new Error(`Unknown agent: ${agentId}. Registered: ${REGISTRY.map((a) => a.id).join(', ')}`);
|
|
182
237
|
}
|
|
238
|
+
// Canonicalize the cwd ONCE at the spawn boundary so every downstream
|
|
239
|
+
// consumer (pre-scan, adapter, persistence, thread mirror, room grouping)
|
|
240
|
+
// sees one spelling. Browser dogfood 2026-06-10 caught the live break:
|
|
241
|
+
// /tmp/x and /private/tmp/x (macOS /tmp is a symlink) rendered as TWO
|
|
242
|
+
// separate goals/rooms for the SAME repo — splitting the shared-context
|
|
243
|
+
// story the room exists to tell. Best-effort: a non-existent cwd passes
|
|
244
|
+
// through unchanged (the adapter will surface that failure honestly).
|
|
245
|
+
if (options?.cwd) {
|
|
246
|
+
try {
|
|
247
|
+
options = { ...options, cwd: realpathSync(options.cwd) };
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
/* keep the caller's path */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
183
253
|
// CODEHERE_MOCK spawns a synthetic agent (mock REPL), so the real CLI need
|
|
184
254
|
// not be installed — bypass the readiness gate. Makes the mock harness truly
|
|
185
255
|
// zero-dependency (dogfood/dev without any agent CLI installed).
|
|
@@ -187,6 +257,23 @@ export class SessionManager {
|
|
|
187
257
|
if (!mockMode && !adapter.isInstalled()) {
|
|
188
258
|
throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
|
|
189
259
|
}
|
|
260
|
+
// Fail-fast on an unusable agent. An explicit pick that's installed-but-not-
|
|
261
|
+
// signed-in used to spawn and HANG at "running" forever (gemini: no auth → no
|
|
262
|
+
// output → the watchdog only fires after minutes). Reject it in milliseconds
|
|
263
|
+
// with the real reason instead. Skipped for the conductor (it resolves a
|
|
264
|
+
// specialist internally) and in mock mode. Honesty-at-the-surface: a task that
|
|
265
|
+
// can't start must say why, not sit spinning.
|
|
266
|
+
// Skipped under VITEST: scenario/cassette tests replay recorded vendor
|
|
267
|
+
// streams through a MOCKED child_process, so no real binary spawns and the
|
|
268
|
+
// auth gate would only reject a replay that can't hang. This is a runtime
|
|
269
|
+
// UX protection, not domain logic.
|
|
270
|
+
if (!mockMode && !process.env.VITEST && agentId !== 'conductor' && adapter.checkReadiness) {
|
|
271
|
+
const r = adapter.checkReadiness();
|
|
272
|
+
if (r.state !== 'ready') {
|
|
273
|
+
const why = r.authHint?.detail || r.installHint?.command || `${adapter.label} is not ready (${r.state})`;
|
|
274
|
+
throw new Error(`${adapter.label} can't run yet — ${why}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
190
277
|
// Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
|
|
191
278
|
// the FAQ now claims as "v0.5.x → v0.6 track": every adapter — not
|
|
192
279
|
// just claude-code — gets a safety scan on the user's prompt before
|
|
@@ -210,20 +297,50 @@ export class SessionManager {
|
|
|
210
297
|
// Respects CODEHERE_GRAPHIFY env var (auto|force|off, default=auto).
|
|
211
298
|
// Synchronous call — degrades to original prompt if graphify fails.
|
|
212
299
|
const cwd = options?.cwd ?? process.cwd();
|
|
213
|
-
|
|
300
|
+
let enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
|
|
301
|
+
// Phase 3 — cross-agent thread hand-off. When this spawn CONTINUES a turn
|
|
302
|
+
// (parentSessionId, set by the room composer), inject what the prior agent
|
|
303
|
+
// was asked + what it did, so a DIFFERENT agent picks up the work instead of
|
|
304
|
+
// restarting blind. The injected summary is the SAME one the room shows, so
|
|
305
|
+
// agent + human read one truth. The displayed prompt stays clean (the mirror
|
|
306
|
+
// records `prompt`, not this enriched one — same split graphify already uses).
|
|
307
|
+
if (options?.parentSessionId) {
|
|
308
|
+
const parent = this.lookupThreadHandoffParent(options.parentSessionId, cwd);
|
|
309
|
+
if (parent)
|
|
310
|
+
enrichedPrompt = enrichPromptWithThreadHandoff(enrichedPrompt, parent);
|
|
311
|
+
}
|
|
214
312
|
const session = adapter.spawn(enrichedPrompt, options);
|
|
215
313
|
this.sessions.set(session.sessionId, session);
|
|
216
314
|
this.spawnOrder.push(session.sessionId);
|
|
217
315
|
// Persistence: write the spawned record immediately so a daemon crash still
|
|
218
316
|
// leaves an audit trail of what was attempted. Subscribe to exit so we can
|
|
219
317
|
// capture the final status + steps + artifacts when the session settles.
|
|
220
|
-
this.
|
|
318
|
+
this.rec(session.sessionId).cwd = cwd;
|
|
319
|
+
this.rec(session.sessionId).displayPrompt = prompt; // clean user ask (not the enriched input)
|
|
320
|
+
if (options?.batchId)
|
|
321
|
+
this.rec(session.sessionId).batchId = options.batchId;
|
|
322
|
+
// REQUEST_METHOD §2 — for a non-trivial request, derive the acceptance
|
|
323
|
+
// contract (async, non-blocking like the title agent) and store it on the
|
|
324
|
+
// record; the room shows "Done means …" once it lands. Trivial asks skip it
|
|
325
|
+
// (the scaling principle — no ceremony tax, no per-request LLM call).
|
|
326
|
+
if (!isTrivialRequest(prompt)) {
|
|
327
|
+
const _sid = session.sessionId;
|
|
328
|
+
generateAcceptanceCriteria(prompt, cwd || '')
|
|
329
|
+
.then((criteria) => {
|
|
330
|
+
if (criteria.length && this.records.has(_sid))
|
|
331
|
+
this.rec(_sid).acceptanceCriteria = criteria;
|
|
332
|
+
})
|
|
333
|
+
.catch(() => { });
|
|
334
|
+
}
|
|
221
335
|
appendSpawned({
|
|
222
336
|
id: session.sessionId,
|
|
223
337
|
agent: session.agent,
|
|
224
338
|
prompt,
|
|
225
339
|
cwd,
|
|
226
340
|
startedAt: session.startedAt,
|
|
341
|
+
...(options?.batchId ? { batchId: options.batchId } : {}),
|
|
342
|
+
...(options?.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
|
|
343
|
+
...(options?.isReview ? { isReview: true } : {}),
|
|
227
344
|
});
|
|
228
345
|
// v0.6.0 M1+M7 — forward-mirror to per-project thread.jsonl alongside
|
|
229
346
|
// sessions.jsonl. Best-effort; mirror failures don't break the spawn path.
|
|
@@ -233,16 +350,41 @@ export class SessionManager {
|
|
|
233
350
|
prompt,
|
|
234
351
|
cwd,
|
|
235
352
|
startedAt: session.startedAt,
|
|
353
|
+
...(options?.batchId ? { batchId: options.batchId } : {}),
|
|
354
|
+
...(options?.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
|
|
236
355
|
});
|
|
237
|
-
this.
|
|
356
|
+
this.rec(session.sessionId).lastActivityAt = Date.now();
|
|
238
357
|
const unsubscribe = session.subscribe((event) => {
|
|
239
358
|
// Any event = the agent is alive. Reset the stall clock. (watchdog)
|
|
240
|
-
this.
|
|
359
|
+
this.rec(session.sessionId).lastActivityAt = Date.now();
|
|
360
|
+
// Capture which specialist the Conductor routed to, so the UI can name
|
|
361
|
+
// the agent that actually ran (not just "Auto").
|
|
362
|
+
if (event.type === 'codehere:conductor-routing') {
|
|
363
|
+
const d = event.data?.delegatedTo;
|
|
364
|
+
if (d)
|
|
365
|
+
this.rec(session.sessionId).delegatedTo = d;
|
|
366
|
+
}
|
|
367
|
+
// Push a live-progress tick so an open room/detail refetches mid-run
|
|
368
|
+
// instead of waiting for the completion disk-write. Steps stream into
|
|
369
|
+
// memory; this is how the browser hears about them in real time.
|
|
370
|
+
if (event.type === 'codehere:steps' ||
|
|
371
|
+
event.type === 'codehere:artifacts' ||
|
|
372
|
+
event.type === 'codehere:conductor-routing' ||
|
|
373
|
+
event.type === 'exit') {
|
|
374
|
+
// conductor-routing included so "routed to <agent>" surfaces during the
|
|
375
|
+
// spin-up gap instead of the user staring at "starting…" with nothing.
|
|
376
|
+
this.liveChanges.emit('change');
|
|
377
|
+
}
|
|
241
378
|
// ── Failover check — intercept rate-limit / quota errors before exit ──
|
|
242
379
|
// Adapters emit 'codehere:error' (structured: { code, message, recovery })
|
|
243
380
|
// or 'error' (spawn-level: { message }). Both need classification.
|
|
244
381
|
if (event.type === 'error' || event.type === 'codehere:error') {
|
|
245
382
|
const err = event.data;
|
|
383
|
+
// Remember the specific error text so the Final Report can name it
|
|
384
|
+
// (the report reads session.steps, which don't carry this event).
|
|
385
|
+
if (err?.message) {
|
|
386
|
+
this.rec(session.sessionId).errorText = [err.message, err.recovery].filter(Boolean).join(' ');
|
|
387
|
+
}
|
|
246
388
|
if (err?.code && err?.message) {
|
|
247
389
|
const signal = classifyFailoverEligibility(session.agent, err.code, err.message);
|
|
248
390
|
if (signal) {
|
|
@@ -254,20 +396,40 @@ export class SessionManager {
|
|
|
254
396
|
}
|
|
255
397
|
if (event.type !== 'exit')
|
|
256
398
|
return;
|
|
399
|
+
// Tier 1.1 · OBSERVE the tests instead of trusting the agent's scrape.
|
|
400
|
+
// Fire-and-forget (never block the daemon): re-run the repo's own test
|
|
401
|
+
// command on the real on-disk state and replace the scraped count with the
|
|
402
|
+
// ground-truth result. The live verdict (mcp-server recomputes verifyClaim
|
|
403
|
+
// from session.artifacts each poll) refreshes automatically once it lands,
|
|
404
|
+
// so a "tests pass" claim that fails on re-run flips the ✓ to broken.
|
|
405
|
+
void this.observeTests(session, cwd);
|
|
406
|
+
// Tier 1.2 · reconcile the audit's declared file-writes against the real
|
|
407
|
+
// git-diff effect (read-only; never mutates the hash-chained audit).
|
|
408
|
+
// Deferred a tick so the synchronous exit path + any final mirror writes
|
|
409
|
+
// settle before we read the audit file.
|
|
410
|
+
void Promise.resolve().then(() => this.reconcileSessionAudit(session));
|
|
411
|
+
// Tier 2.1 · auto cross-vendor adversarial review (flag-gated, default
|
|
412
|
+
// OFF). When enabled, a DIFFERENT vendor independently reviews this work
|
|
413
|
+
// in refute-mode so the cross-vendor proof becomes automatic, not a manual
|
|
414
|
+
// two-task workflow. Deferred a tick so artifacts/status have settled.
|
|
415
|
+
void Promise.resolve().then(() => this.maybeAutoReview(session, cwd));
|
|
257
416
|
const data = event.data;
|
|
258
417
|
const completedAt = Date.now();
|
|
259
418
|
// Surface completedAt to API consumers reading the still-in-memory
|
|
260
419
|
// session (Bug 2 sister-fix, May 2). Without this, /api/tasks shows
|
|
261
420
|
// status=completed but no completedAt → NaN duration in the UI.
|
|
262
|
-
this.
|
|
421
|
+
this.rec(session.sessionId).completedAt = completedAt;
|
|
263
422
|
appendCompleted({
|
|
264
423
|
id: session.sessionId,
|
|
424
|
+
nativeSessionId: session.nativeSessionId,
|
|
425
|
+
delegateSessionId: session.delegateSessionId,
|
|
265
426
|
status: session.status,
|
|
266
427
|
durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
|
|
267
428
|
completedAt,
|
|
268
429
|
steps: session.steps ? [...session.steps] : undefined,
|
|
269
430
|
artifacts: session.artifacts,
|
|
270
|
-
stalledReason: this.
|
|
431
|
+
stalledReason: this.records.get(session.sessionId)?.stalled?.reason,
|
|
432
|
+
delegatedTo: this.records.get(session.sessionId)?.delegatedTo,
|
|
271
433
|
});
|
|
272
434
|
// v0.6.0 M1+M7 — forward-mirror completion to project thread.
|
|
273
435
|
mirrorCompletedToThread({
|
|
@@ -292,13 +454,39 @@ export class SessionManager {
|
|
|
292
454
|
durationMs: completedAt - session.startedAt,
|
|
293
455
|
steps: session.steps ? [...session.steps] : [],
|
|
294
456
|
artifacts: session.artifacts,
|
|
457
|
+
errorText: this.records.get(session.sessionId)?.errorText,
|
|
458
|
+
delegatedTo: this.records.get(session.sessionId)?.delegatedTo,
|
|
295
459
|
};
|
|
296
460
|
generateFinalReport(reportInput)
|
|
297
461
|
.then((report) => {
|
|
298
462
|
if (report) {
|
|
299
463
|
// Cache for web UI consumption via /api/tasks (iter 12 — non-technical
|
|
300
464
|
// users live in the web UI, not the terminal).
|
|
301
|
-
this.
|
|
465
|
+
this.rec(session.sessionId).finalReport = report;
|
|
466
|
+
// REQUEST_METHOD stage 5 — grade the contract against the result.
|
|
467
|
+
// The assessment answers MET/UNMET per criterion; never marks met
|
|
468
|
+
// without evidence (no fake green). Runs only if a contract exists.
|
|
469
|
+
const _ac = this.rec(session.sessionId).acceptanceCriteria;
|
|
470
|
+
if (_ac && _ac.length) {
|
|
471
|
+
const _sid2 = session.sessionId;
|
|
472
|
+
const _files = (session.artifacts?.files || [])
|
|
473
|
+
.map((f) => typeof f === 'string'
|
|
474
|
+
? f
|
|
475
|
+
: (f?.path ?? f?.file))
|
|
476
|
+
.filter((x) => !!x);
|
|
477
|
+
const _ok = session.status === 'completed' || session.status === 'done';
|
|
478
|
+
gradeAcceptanceCriteria(_ac, {
|
|
479
|
+
prompt: this.rec(_sid2).displayPrompt || '',
|
|
480
|
+
reportSummary: report.summary,
|
|
481
|
+
files: _files,
|
|
482
|
+
verdictGreen: _ok, // gate met-grading on TASK success, never report-gen success (no fake green on a failed run)
|
|
483
|
+
})
|
|
484
|
+
.then((graded) => {
|
|
485
|
+
if (this.records.has(_sid2))
|
|
486
|
+
this.rec(_sid2).acceptanceCriteria = graded;
|
|
487
|
+
})
|
|
488
|
+
.catch(() => { });
|
|
489
|
+
}
|
|
302
490
|
// v0.6.x — persist to disk so the report survives daemon restart.
|
|
303
491
|
// Pre-fix: founder's Mac restart 2026-05-03 left a session card
|
|
304
492
|
// showing "Generating report…" forever because reports lived only
|
|
@@ -311,6 +499,16 @@ export class SessionManager {
|
|
|
311
499
|
durationMs: report.durationMs,
|
|
312
500
|
source: report.source,
|
|
313
501
|
});
|
|
502
|
+
// Persist the summary to the project thread too (Step C) — the report
|
|
503
|
+
// is async, so the earlier mirrorCompletedToThread ran without it. This
|
|
504
|
+
// is what a cross-agent hand-off reads, and what the conversation view
|
|
505
|
+
// shows after a daemon restart. Best-effort; never breaks the run.
|
|
506
|
+
mirrorFinalReportToThread({
|
|
507
|
+
sessionId: session.sessionId,
|
|
508
|
+
cwd,
|
|
509
|
+
summary: report.summary,
|
|
510
|
+
source: report.source,
|
|
511
|
+
});
|
|
314
512
|
// Shared-room hand-off: record this turn's outcome to the project's
|
|
315
513
|
// working memory so the NEXT agent spawned here reads it (closes the
|
|
316
514
|
// loop with applyMemoryPrefix). Best-effort; only initialized projects.
|
|
@@ -336,7 +534,7 @@ export class SessionManager {
|
|
|
336
534
|
.catch(() => {
|
|
337
535
|
// generateFinalReport rejected — record it so the UI can show
|
|
338
536
|
// "Report unavailable" instead of "Generating report…" forever.
|
|
339
|
-
this.
|
|
537
|
+
this.rec(session.sessionId).failedReport = true;
|
|
340
538
|
});
|
|
341
539
|
// P1 — persist context pack at session exit for cross-session handoff (P2).
|
|
342
540
|
// Fire-and-forget: graphify-ts may not be installed, graph may be empty,
|
|
@@ -381,21 +579,36 @@ export class SessionManager {
|
|
|
381
579
|
this.startStallWatchdog(session);
|
|
382
580
|
return session;
|
|
383
581
|
}
|
|
384
|
-
/** Tracks which agents have already been tried for a given prompt during
|
|
385
|
-
* a failover chain. Maps sessionId → set of agent ids already attempted. */
|
|
386
|
-
failoverTriedBySession = new Map();
|
|
387
582
|
/** v0.6.x failover — emit a failover offer when an adapter hits a
|
|
388
583
|
* rate-limit / quota error. The offer is surfaced via the API layer
|
|
389
584
|
* (Slice E) and optionally auto-spawned (Slice D). Returns the new
|
|
390
585
|
* session if auto-spawn was triggered, undefined otherwise. */
|
|
391
586
|
handleFailover(session, signal) {
|
|
392
587
|
const config = loadFailoverConfig();
|
|
393
|
-
const tried = this.
|
|
588
|
+
const tried = this.records.get(session.sessionId)?.failoverTried ?? new Set();
|
|
394
589
|
tried.add(signal.fromAgent);
|
|
395
|
-
this.
|
|
590
|
+
this.rec(session.sessionId).failoverTried = tried;
|
|
591
|
+
// Cost-bounded fail-over (refuter 2026-06-08). A stall/error fail-over spawns
|
|
592
|
+
// a REAL replacement agent — so it must respect the daily budget the same way
|
|
593
|
+
// conductor routing does, or the "multi-agent layer that doesn't waste money"
|
|
594
|
+
// thesis breaks (a hung agent could otherwise cascade paid spawns across the
|
|
595
|
+
// whole chain). When over budget, only FREE agents (estimateCostUsd === 0) are
|
|
596
|
+
// eligible; no free agent ready → no target → honest kill, never an
|
|
597
|
+
// over-budget paid spawn.
|
|
598
|
+
// NOTE (fail-open boundedness): estimateCostUsd returns 0 for an id absent
|
|
599
|
+
// from the cost table, so an UNKNOWN paid adapter could read as free here.
|
|
600
|
+
// That path is unreachable today — the failover chain is validated against
|
|
601
|
+
// KNOWN_AGENT_IDS (validateFailoverChain), and all five are priced. If you
|
|
602
|
+
// add a paid adapter, add its cost-table row in the SAME change.
|
|
603
|
+
const budgetCfg = loadBudget();
|
|
604
|
+
const budgetBounded = budgetCfg.dailyUsd !== null && isOverBudget(computeSpendToday().totalUsd, budgetCfg);
|
|
396
605
|
const adapters = allAdapters();
|
|
397
606
|
const available = toAvailability(adapters, (a) => {
|
|
398
|
-
|
|
607
|
+
if (a.id === signal.fromAgent || !a.isInstalled())
|
|
608
|
+
return false;
|
|
609
|
+
if (budgetBounded && estimateCostUsd(a.id, { inputTokens: 1, outputTokens: 1 }) !== 0)
|
|
610
|
+
return false;
|
|
611
|
+
return true;
|
|
399
612
|
});
|
|
400
613
|
const target = resolveFailoverTarget({
|
|
401
614
|
failedAgent: signal.fromAgent,
|
|
@@ -412,7 +625,7 @@ export class SessionManager {
|
|
|
412
625
|
const autoFailover = config?.autoFailover ?? true;
|
|
413
626
|
if (autoFailover) {
|
|
414
627
|
// Spawn replacement session on the next agent, preserving prompt + cwd
|
|
415
|
-
const cwd = this.
|
|
628
|
+
const cwd = this.records.get(session.sessionId)?.cwd ?? process.cwd();
|
|
416
629
|
// P2 — build handoff context for continuity across agent swap
|
|
417
630
|
const handoff = buildFailoverHandoff(cwd, {
|
|
418
631
|
failedAgent: session.agent,
|
|
@@ -441,26 +654,32 @@ export class SessionManager {
|
|
|
441
654
|
// Track this new session in the same way
|
|
442
655
|
this.sessions.set(newSession.sessionId, newSession);
|
|
443
656
|
this.spawnOrder.push(newSession.sessionId);
|
|
444
|
-
this.
|
|
445
|
-
|
|
657
|
+
this.rec(newSession.sessionId).cwd = cwd;
|
|
658
|
+
// The failover continues the SAME user task, so display the SAME clean ask —
|
|
659
|
+
// not the hand-off-enriched prompt the adapter stores as session.prompt (the
|
|
660
|
+
// "[Continuing a thread …]" leak on the failover path).
|
|
661
|
+
this.rec(newSession.sessionId).displayPrompt = this.getDisplayPrompt(session.sessionId) ?? session.prompt;
|
|
662
|
+
this.rec(newSession.sessionId).lastActivityAt = Date.now();
|
|
446
663
|
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
447
664
|
// adapter exits synchronously (command not found, instant crash, etc).
|
|
448
665
|
const unsubscribe = newSession.subscribe((event) => {
|
|
449
|
-
this.
|
|
666
|
+
this.rec(newSession.sessionId).lastActivityAt = Date.now();
|
|
450
667
|
if (event.type !== 'exit')
|
|
451
668
|
return;
|
|
452
669
|
this.clearStallTimer(newSession.sessionId);
|
|
453
670
|
const data = event.data;
|
|
454
671
|
const completedAt = Date.now();
|
|
455
|
-
this.
|
|
672
|
+
this.rec(newSession.sessionId).completedAt = completedAt;
|
|
456
673
|
appendCompleted({
|
|
457
674
|
id: newSession.sessionId,
|
|
675
|
+
nativeSessionId: newSession.nativeSessionId,
|
|
676
|
+
delegateSessionId: newSession.delegateSessionId,
|
|
458
677
|
status: newSession.status,
|
|
459
678
|
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
460
679
|
completedAt,
|
|
461
680
|
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
462
681
|
artifacts: newSession.artifacts,
|
|
463
|
-
stalledReason: this.
|
|
682
|
+
stalledReason: this.records.get(newSession.sessionId)?.stalled?.reason,
|
|
464
683
|
});
|
|
465
684
|
mirrorCompletedToThread({
|
|
466
685
|
sessionId: newSession.sessionId,
|
|
@@ -479,11 +698,13 @@ export class SessionManager {
|
|
|
479
698
|
durationMs: completedAt - newSession.startedAt,
|
|
480
699
|
steps: newSession.steps ? [...newSession.steps] : [],
|
|
481
700
|
artifacts: newSession.artifacts,
|
|
701
|
+
errorText: this.records.get(newSession.sessionId)?.errorText,
|
|
702
|
+
delegatedTo: this.records.get(newSession.sessionId)?.delegatedTo,
|
|
482
703
|
};
|
|
483
704
|
generateFinalReport(reportInput)
|
|
484
705
|
.then((report) => {
|
|
485
706
|
if (report) {
|
|
486
|
-
this.
|
|
707
|
+
this.rec(newSession.sessionId).finalReport = report;
|
|
487
708
|
appendFinalReport({
|
|
488
709
|
id: newSession.sessionId,
|
|
489
710
|
summary: report.summary,
|
|
@@ -491,6 +712,12 @@ export class SessionManager {
|
|
|
491
712
|
durationMs: report.durationMs,
|
|
492
713
|
source: report.source,
|
|
493
714
|
});
|
|
715
|
+
mirrorFinalReportToThread({
|
|
716
|
+
sessionId: newSession.sessionId,
|
|
717
|
+
cwd,
|
|
718
|
+
summary: report.summary,
|
|
719
|
+
source: report.source,
|
|
720
|
+
});
|
|
494
721
|
try {
|
|
495
722
|
printFinalReportToTerminal(reportInput, report);
|
|
496
723
|
}
|
|
@@ -555,8 +782,23 @@ export class SessionManager {
|
|
|
555
782
|
reason: signal.errorCode,
|
|
556
783
|
cwd,
|
|
557
784
|
});
|
|
558
|
-
this.
|
|
559
|
-
this.
|
|
785
|
+
this.rec(session.sessionId).continuedAs = newSession.sessionId;
|
|
786
|
+
this.rec(newSession.sessionId).continuedFrom = session.sessionId;
|
|
787
|
+
// Record the recovery lineage on the replacement so the dashboard can
|
|
788
|
+
// surface "recovered from <fromAgent>" — the resilience must be VISIBLE,
|
|
789
|
+
// not buried in a killed row (the S3 dogfood gap, 2026-06-08).
|
|
790
|
+
this.rec(newSession.sessionId).recoveredFrom = {
|
|
791
|
+
agent: signal.fromAgent,
|
|
792
|
+
reason: signal.errorCode,
|
|
793
|
+
};
|
|
794
|
+
// Thread the already-tried set FORWARD onto the replacement session.
|
|
795
|
+
// Without this, a multi-hop cascade (A hangs → B hangs → …) loses its
|
|
796
|
+
// history: B's failover reads a fresh empty set and re-picks A (first
|
|
797
|
+
// ready in the chain), looping A↔B and never reaching C. Seeding the
|
|
798
|
+
// replacement with the accumulated `tried` set (already includes the
|
|
799
|
+
// just-failed agent) makes the chain walk strictly forward and exhaust
|
|
800
|
+
// honestly. (Caught by stall-failover-stress.test.ts, 2026-06-08.)
|
|
801
|
+
this.rec(newSession.sessionId).failoverTried = new Set(tried);
|
|
560
802
|
this.startNarrationTimer(newSession);
|
|
561
803
|
this.startStallWatchdog(newSession);
|
|
562
804
|
return newSession;
|
|
@@ -569,7 +811,7 @@ export class SessionManager {
|
|
|
569
811
|
// Offer-only mode: signal the API layer to present a failover banner
|
|
570
812
|
// (Slice E handles the actual UI surfacing)
|
|
571
813
|
this.emitFailoverOffer({ session, signal, target });
|
|
572
|
-
this.
|
|
814
|
+
this.rec(session.sessionId).failoverReason = signal.errorCode;
|
|
573
815
|
return undefined;
|
|
574
816
|
}
|
|
575
817
|
/** Emitted when a failover offer is generated. The API layer listens
|
|
@@ -611,7 +853,7 @@ export class SessionManager {
|
|
|
611
853
|
const targetAdapter = getAdapter(targetAgent);
|
|
612
854
|
if (!targetAdapter || !targetAdapter.isInstalled())
|
|
613
855
|
return undefined;
|
|
614
|
-
const cwd = this.
|
|
856
|
+
const cwd = this.records.get(sessionId)?.cwd ?? process.cwd();
|
|
615
857
|
// P2 — build handoff context for continuity across agent swap
|
|
616
858
|
const handoff = buildFailoverHandoff(cwd, {
|
|
617
859
|
failedAgent: session.agent,
|
|
@@ -638,26 +880,30 @@ export class SessionManager {
|
|
|
638
880
|
// Track the new session
|
|
639
881
|
this.sessions.set(newSession.sessionId, newSession);
|
|
640
882
|
this.spawnOrder.push(newSession.sessionId);
|
|
641
|
-
this.
|
|
642
|
-
|
|
883
|
+
this.rec(newSession.sessionId).cwd = cwd;
|
|
884
|
+
// Failover continues the SAME ask — display the clean prompt, not the enriched input.
|
|
885
|
+
this.rec(newSession.sessionId).displayPrompt = this.getDisplayPrompt(session.sessionId) ?? session.prompt;
|
|
886
|
+
this.rec(newSession.sessionId).lastActivityAt = Date.now();
|
|
643
887
|
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
644
888
|
// adapter exits synchronously.
|
|
645
889
|
const unsubscribe = newSession.subscribe((event) => {
|
|
646
|
-
this.
|
|
890
|
+
this.rec(newSession.sessionId).lastActivityAt = Date.now();
|
|
647
891
|
if (event.type !== 'exit')
|
|
648
892
|
return;
|
|
649
893
|
this.clearStallTimer(newSession.sessionId);
|
|
650
894
|
const data = event.data;
|
|
651
895
|
const completedAt = Date.now();
|
|
652
|
-
this.
|
|
896
|
+
this.rec(newSession.sessionId).completedAt = completedAt;
|
|
653
897
|
appendCompleted({
|
|
654
898
|
id: newSession.sessionId,
|
|
899
|
+
nativeSessionId: newSession.nativeSessionId,
|
|
900
|
+
delegateSessionId: newSession.delegateSessionId,
|
|
655
901
|
status: newSession.status,
|
|
656
902
|
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
657
903
|
completedAt,
|
|
658
904
|
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
659
905
|
artifacts: newSession.artifacts,
|
|
660
|
-
stalledReason: this.
|
|
906
|
+
stalledReason: this.records.get(newSession.sessionId)?.stalled?.reason,
|
|
661
907
|
});
|
|
662
908
|
mirrorCompletedToThread({
|
|
663
909
|
sessionId: newSession.sessionId,
|
|
@@ -676,11 +922,13 @@ export class SessionManager {
|
|
|
676
922
|
durationMs: completedAt - newSession.startedAt,
|
|
677
923
|
steps: newSession.steps ? [...newSession.steps] : [],
|
|
678
924
|
artifacts: newSession.artifacts,
|
|
925
|
+
errorText: this.records.get(newSession.sessionId)?.errorText,
|
|
926
|
+
delegatedTo: this.records.get(newSession.sessionId)?.delegatedTo,
|
|
679
927
|
};
|
|
680
928
|
generateFinalReport(reportInput)
|
|
681
929
|
.then((report) => {
|
|
682
930
|
if (report) {
|
|
683
|
-
this.
|
|
931
|
+
this.rec(newSession.sessionId).finalReport = report;
|
|
684
932
|
appendFinalReport({
|
|
685
933
|
id: newSession.sessionId,
|
|
686
934
|
summary: report.summary,
|
|
@@ -729,8 +977,8 @@ export class SessionManager {
|
|
|
729
977
|
});
|
|
730
978
|
// Persistence + threading — safe after subscribe because the exit
|
|
731
979
|
// handler is already registered.
|
|
732
|
-
this.
|
|
733
|
-
this.
|
|
980
|
+
this.rec(sessionId).continuedAs = newSession.sessionId;
|
|
981
|
+
this.rec(newSession.sessionId).continuedFrom = sessionId;
|
|
734
982
|
appendSpawned({
|
|
735
983
|
id: newSession.sessionId,
|
|
736
984
|
agent: newSession.agent,
|
|
@@ -749,7 +997,7 @@ export class SessionManager {
|
|
|
749
997
|
sessionId: newSession.sessionId,
|
|
750
998
|
fromAgent: session.agent,
|
|
751
999
|
toAgent: targetAgent,
|
|
752
|
-
reason: this.
|
|
1000
|
+
reason: this.records.get(session.sessionId)?.failoverReason ?? 'error',
|
|
753
1001
|
cwd,
|
|
754
1002
|
});
|
|
755
1003
|
this.startNarrationTimer(newSession);
|
|
@@ -784,7 +1032,7 @@ export class SessionManager {
|
|
|
784
1032
|
})
|
|
785
1033
|
.then((out) => {
|
|
786
1034
|
if (out)
|
|
787
|
-
this.
|
|
1035
|
+
this.rec(session.sessionId).narration = out;
|
|
788
1036
|
})
|
|
789
1037
|
.catch(() => {
|
|
790
1038
|
/* defensive — generateNarration never throws */
|
|
@@ -792,6 +1040,166 @@ export class SessionManager {
|
|
|
792
1040
|
}, intervalMs);
|
|
793
1041
|
this.narrationTimers.set(session.sessionId, timer);
|
|
794
1042
|
}
|
|
1043
|
+
/** Tier 1.1 · re-run the project's tests on the real on-disk state and replace
|
|
1044
|
+
* the agent's SCRAPED test count with the observed ground truth, so the verdict
|
|
1045
|
+
* checks reality, not the agent's narration. Async + best-effort: skips mock
|
|
1046
|
+
* runs (no real suite), sessions that made no test claim, and any re-run that
|
|
1047
|
+
* can't run; mutates the in-memory session so the live verdict refreshes. */
|
|
1048
|
+
async observeTests(session, cwd) {
|
|
1049
|
+
const mockMode = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
|
|
1050
|
+
if (mockMode)
|
|
1051
|
+
return;
|
|
1052
|
+
if (session.status !== 'completed')
|
|
1053
|
+
return;
|
|
1054
|
+
const claimed = session.artifacts?.tests;
|
|
1055
|
+
// R5 — re-run when the task is TEST-RELEVANT, not only when the agent happened
|
|
1056
|
+
// to print a parseable count. The old `if (!claimed) return` was the root cause
|
|
1057
|
+
// of "tests came back null on a real run": real test work whose stdout had no
|
|
1058
|
+
// scrapeable count was never re-verified. We now re-run when there's a claim,
|
|
1059
|
+
// OR the task changed test files, OR a step mentions tests — closing that gap
|
|
1060
|
+
// without running the whole suite after every trivial (non-test) task.
|
|
1061
|
+
// Test-file shapes across ecosystems: tests|spec|__tests__ dirs · *.test.* /
|
|
1062
|
+
// *.spec.* / *_test.* suffixes (JS, Go) · pytest's canonical test_*.py PREFIX
|
|
1063
|
+
// (devil's-advocate audit 2026-06-09: the suffix-only pattern missed exactly
|
|
1064
|
+
// the ecosystem R4 added).
|
|
1065
|
+
const touchedTestFiles = (session.artifacts?.files ?? []).some((f) => /(^|\/)(tests?|spec|__tests__)\//i.test(f.path) ||
|
|
1066
|
+
/\.(test|spec)\.\w+$|_test\.\w+$/i.test(f.path) ||
|
|
1067
|
+
/(^|\/)test_[^/]+\.py$/i.test(f.path));
|
|
1068
|
+
// Tool-action steps only ('say'/'think' carry the agent's full prose, where a
|
|
1069
|
+
// stray "no tests needed" would over-fire a whole suite run). \btest misses
|
|
1070
|
+
// pytest/vitest/jest (no word boundary mid-word) — name the runners.
|
|
1071
|
+
const stepsMentionTests = (session.steps ?? []).some((s) => s.kind !== 'say' && s.kind !== 'think' && /\btests?\b|pytest|vitest|jest/i.test(s.label));
|
|
1072
|
+
// The verifier's whole premise is "don't trust the agent's narration." So
|
|
1073
|
+
// also re-run when the agent CHANGED FILES and the repo has a runnable suite
|
|
1074
|
+
// — even if the agent never mentioned tests. That's exactly the case a
|
|
1075
|
+
// verifier must catch: an agent silently breaks the suite and says nothing
|
|
1076
|
+
// (2026-06-14 — a real run implemented code, broke its tests, and codehere
|
|
1077
|
+
// stayed blind because no trigger fired). Bounded: a detectable command must
|
|
1078
|
+
// exist; the re-run is timeout-bound and opt-out via CODEHERE_RERUN_TESTS=0.
|
|
1079
|
+
const changedFilesWithSuite = (session.artifacts?.files ?? []).length > 0 && detectTestCommand(cwd) !== null;
|
|
1080
|
+
if (!claimed && !touchedTestFiles && !stepsMentionTests && !changedFilesWithSuite)
|
|
1081
|
+
return;
|
|
1082
|
+
if (this.records.get(session.sessionId)?.rerunStarted)
|
|
1083
|
+
return;
|
|
1084
|
+
this.rec(session.sessionId).rerunStarted = true;
|
|
1085
|
+
try {
|
|
1086
|
+
const result = await rerunTests(cwd);
|
|
1087
|
+
if (!session.artifacts)
|
|
1088
|
+
return;
|
|
1089
|
+
// R13/M3 — applyTestObservation (domain/task.ts) is THE one owned writer
|
|
1090
|
+
// of the post-exit tests artifact: the exit-code-authoritative merge, the
|
|
1091
|
+
// claim-preservation rule, and (new) the explicit couldn't-observe reason
|
|
1092
|
+
// landing in testsUnobserved instead of a silent blank (the R5 leftover —
|
|
1093
|
+
// this test-RELEVANT session deserved a verdict input or an honest why).
|
|
1094
|
+
applyTestObservation(session.artifacts, result);
|
|
1095
|
+
// Re-persist so the OBSERVED verdict (or the honest couldn't-observe
|
|
1096
|
+
// reason) survives a daemon restart. The exit handler already wrote a
|
|
1097
|
+
// completed record from the SCRAPED artifact (before this async re-run
|
|
1098
|
+
// landed); loadRecent dedups completed records by id newest-first, so
|
|
1099
|
+
// this later write wins — without it, a caught failure would silently
|
|
1100
|
+
// revert to the agent's "pass" scrape on restart.
|
|
1101
|
+
const completedAt = this.records.get(session.sessionId)?.completedAt ?? Date.now();
|
|
1102
|
+
appendCompleted({
|
|
1103
|
+
id: session.sessionId,
|
|
1104
|
+
nativeSessionId: session.nativeSessionId,
|
|
1105
|
+
delegateSessionId: session.delegateSessionId,
|
|
1106
|
+
status: session.status,
|
|
1107
|
+
durationMs: completedAt - session.startedAt,
|
|
1108
|
+
completedAt,
|
|
1109
|
+
steps: session.steps ? [...session.steps] : undefined,
|
|
1110
|
+
artifacts: session.artifacts,
|
|
1111
|
+
stalledReason: this.records.get(session.sessionId)?.stalled?.reason,
|
|
1112
|
+
delegatedTo: this.records.get(session.sessionId)?.delegatedTo,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
/* re-run is enrichment; never let it break completion */
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/** Tier 1.2 · reconcile the audit's declared file-writes against the real
|
|
1120
|
+
* git-diff effect, once at completion. Read-only over the append-only audit;
|
|
1121
|
+
* best-effort (never breaks completion). The verdict is surfaced so the audit
|
|
1122
|
+
* isn't just "what the agent claimed" but "what codehere could confirm". */
|
|
1123
|
+
reconcileSessionAudit(session) {
|
|
1124
|
+
try {
|
|
1125
|
+
// 1.3 join · include the delegate's runtime sid so a conductor-routed
|
|
1126
|
+
// task's real writes are reconciled (else Auto tasks read 'no-writes').
|
|
1127
|
+
const entries = collectAuditEntriesForSession(session.sessionId, undefined, session.delegateSessionId ? [session.delegateSessionId] : []);
|
|
1128
|
+
const recon = reconcileAudit(entries, session.artifacts);
|
|
1129
|
+
this.rec(session.sessionId).auditRecon = recon;
|
|
1130
|
+
// 2.2 · persist the OBSERVED signal so it survives a daemon restart (the
|
|
1131
|
+
// composite grade reads it back via PersistedSession.auditRecon).
|
|
1132
|
+
appendAuditRecon(session.sessionId, recon);
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
/* reconciliation is enrichment; never let it break completion */
|
|
1136
|
+
}
|
|
1137
|
+
// R2 · sign the live chain tip now that this session's audit entries have
|
|
1138
|
+
// landed — so a later on-disk edit/truncation is detectable by anyone
|
|
1139
|
+
// verifying with the public key (best-effort; never blocks completion).
|
|
1140
|
+
signAuditTip(AUDIT_FILE);
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Tier 2.1 · auto cross-vendor adversarial review. On a successful completion,
|
|
1144
|
+
* spawn a DIFFERENT vendor in refute-mode to independently review this work,
|
|
1145
|
+
* so the cross-vendor proof fires automatically instead of waiting for a human
|
|
1146
|
+
* to run a second task. Flag-gated (CODEHERE_AUTO_REVIEW=1, default OFF) — it
|
|
1147
|
+
* spawns a real agent and costs money, so it never surprises a user.
|
|
1148
|
+
*
|
|
1149
|
+
* Safety: the loop guard (planAutoReview returns null for a reviewer's own
|
|
1150
|
+
* completion via rec.isAutoReview) + a one-shot guard (autoReviewStarted) +
|
|
1151
|
+
* the cost-bounded eligibility gate. Best-effort; never breaks completion.
|
|
1152
|
+
*/
|
|
1153
|
+
maybeAutoReview(session, cwd) {
|
|
1154
|
+
if (process.env.CODEHERE_AUTO_REVIEW !== '1')
|
|
1155
|
+
return; // opt-in only
|
|
1156
|
+
try {
|
|
1157
|
+
const rec = this.records.get(session.sessionId);
|
|
1158
|
+
if (rec?.autoReviewStarted)
|
|
1159
|
+
return; // one-shot — exit can fire twice
|
|
1160
|
+
// Only review work that actually succeeded; failures have nothing proven to refute.
|
|
1161
|
+
if (session.status !== 'completed')
|
|
1162
|
+
return;
|
|
1163
|
+
// Claim the one-shot NOW — before computing the plan or spawning — so a
|
|
1164
|
+
// second exit event (the path can fire twice) can't race past the guard
|
|
1165
|
+
// above and spawn a duplicate reviewer. Marking an ineligible writer
|
|
1166
|
+
// (plan === null below) is fine: eligibility is deterministic, so there's
|
|
1167
|
+
// nothing to retry.
|
|
1168
|
+
this.rec(session.sessionId).autoReviewStarted = true;
|
|
1169
|
+
const writerFiles = (session.artifacts?.files ?? []).map((f) => f.path);
|
|
1170
|
+
const installedAgents = REGISTRY.filter((a) => a.id !== 'conductor' && a.isInstalled()).map((a) => a.id);
|
|
1171
|
+
const budgetCfg = loadBudget();
|
|
1172
|
+
const budgetRemainingUsd = budgetCfg.dailyUsd === null
|
|
1173
|
+
? Number.POSITIVE_INFINITY
|
|
1174
|
+
: Math.max(0, budgetCfg.dailyUsd - computeSpendToday().totalUsd);
|
|
1175
|
+
const plan = planAutoReview({
|
|
1176
|
+
writerAgent: session.agent,
|
|
1177
|
+
writerFiles,
|
|
1178
|
+
cwd,
|
|
1179
|
+
installedAgents,
|
|
1180
|
+
budgetRemainingUsd,
|
|
1181
|
+
isAutoReviewSession: rec?.isAutoReview === true,
|
|
1182
|
+
});
|
|
1183
|
+
if (!plan)
|
|
1184
|
+
return;
|
|
1185
|
+
// Spawn the reviewer in the SAME cwd (so buildCrossVendorVerdict pairs it
|
|
1186
|
+
// with this writer) and TAG it so its own completion can't recurse.
|
|
1187
|
+
const reviewer = this.spawn(plan.prompt, plan.reviewerVendor, {
|
|
1188
|
+
cwd: plan.cwd,
|
|
1189
|
+
parentSessionId: session.sessionId,
|
|
1190
|
+
isReview: true, // persisted → cross-vendor pairing observes the link, never infers it
|
|
1191
|
+
});
|
|
1192
|
+
this.rec(reviewer.sessionId).isAutoReview = true;
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
/* auto-review is enrichment; never let it break the completion path */
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/** Tier 1.2 · the audit↔disk reconciliation verdict for a session, or undefined
|
|
1199
|
+
* if not computed (still running / not reconciled). */
|
|
1200
|
+
getAuditRecon(sessionId) {
|
|
1201
|
+
return this.records.get(sessionId)?.auditRecon;
|
|
1202
|
+
}
|
|
795
1203
|
/** Stall watchdog (dogfood 2026-05-27 #1). Polls a running session; if no
|
|
796
1204
|
* event has arrived within the stall window, kills it and records an honest
|
|
797
1205
|
* reason. Cross-agent — opencode hung, but any adapter can. Timings are
|
|
@@ -800,7 +1208,7 @@ export class SessionManager {
|
|
|
800
1208
|
startStallWatchdog(session) {
|
|
801
1209
|
if (process.env.CODEHERE_DISABLE_WATCHDOG === '1')
|
|
802
1210
|
return;
|
|
803
|
-
const stallMs =
|
|
1211
|
+
const stallMs = stallWindowFor(session.agent);
|
|
804
1212
|
const pollMs = Number(process.env.CODEHERE_STALL_POLL_MS ?? 15_000);
|
|
805
1213
|
if (!(stallMs > 0) || !(pollMs > 0))
|
|
806
1214
|
return;
|
|
@@ -809,13 +1217,29 @@ export class SessionManager {
|
|
|
809
1217
|
this.clearStallTimer(session.sessionId);
|
|
810
1218
|
return;
|
|
811
1219
|
}
|
|
812
|
-
const last = this.
|
|
1220
|
+
const last = this.records.get(session.sessionId)?.lastActivityAt ?? session.startedAt;
|
|
813
1221
|
const silentMs = Date.now() - last;
|
|
814
1222
|
if (silentMs < stallMs)
|
|
815
1223
|
return;
|
|
816
1224
|
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
1225
|
this.clearStallTimer(session.sessionId);
|
|
1226
|
+
// Stall → FAIL OVER (Ega 2026-06-08). A full-stall-window silence is NOT a
|
|
1227
|
+
// transient timeout (the error classifier rightly skips those) — the agent
|
|
1228
|
+
// is genuinely stuck. Hand the task to the next vendor WITH context handoff
|
|
1229
|
+
// (handleFailover → buildFailoverHandoff) so a hung free agent (e.g. gemini
|
|
1230
|
+
// on the free tier) RECOVERS instead of dead-ending. Falls back to the
|
|
1231
|
+
// honest kill + stalledReason when no failover target is available.
|
|
1232
|
+
let recovered;
|
|
1233
|
+
try {
|
|
1234
|
+
recovered = this.handleFailover(session, { fromAgent: session.agent, errorCode: 'stall', reason });
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
recovered = undefined;
|
|
1238
|
+
}
|
|
1239
|
+
this.rec(session.sessionId).stalled = {
|
|
1240
|
+
reason: recovered ? `${reason} Handed off to ${recovered.agent}.` : reason,
|
|
1241
|
+
stalledAt: Date.now(),
|
|
1242
|
+
};
|
|
819
1243
|
try {
|
|
820
1244
|
session.kill();
|
|
821
1245
|
}
|
|
@@ -838,57 +1262,130 @@ export class SessionManager {
|
|
|
838
1262
|
* The API layer surfaces this so the dashboard never shows a hung task as a
|
|
839
1263
|
* normal row. */
|
|
840
1264
|
getStallReason(sessionId) {
|
|
841
|
-
return this.
|
|
1265
|
+
return this.records.get(sessionId)?.stalled?.reason;
|
|
1266
|
+
}
|
|
1267
|
+
/** The clean user prompt for display — strips the graphify/hand-off enrichment
|
|
1268
|
+
* that lives on session.prompt (the agent's input). Undefined for sessions
|
|
1269
|
+
* not spawned this process-lifetime (their persisted prompt is already clean). */
|
|
1270
|
+
getDisplayPrompt(sessionId) {
|
|
1271
|
+
return this.records.get(sessionId)?.displayPrompt;
|
|
842
1272
|
}
|
|
843
1273
|
/** v0.6 M3 — latest narration for a session, or undefined. */
|
|
844
1274
|
getNarration(sessionId) {
|
|
845
|
-
return this.
|
|
1275
|
+
return this.records.get(sessionId)?.narration;
|
|
846
1276
|
}
|
|
847
1277
|
/** Persisted (read-only) sessions from prior daemon runs. Cached at boot. */
|
|
848
1278
|
listPersisted() {
|
|
849
1279
|
return this.persistedCache;
|
|
850
1280
|
}
|
|
1281
|
+
/** S3 · mirror a just-written 'resolved' record into the boot cache, so the
|
|
1282
|
+
* acknowledged badge survives a page reload WITHOUT a daemon restart. The
|
|
1283
|
+
* acceptance walk caught the gap: appendResolved hit disk, but /api/tasks
|
|
1284
|
+
* serves this cache — the UI wiped its own ack on the next poll. */
|
|
1285
|
+
markPersistedResolved(sessionId, resolvedAt) {
|
|
1286
|
+
const rec = this.persistedCache.find((p) => p.sessionId === sessionId);
|
|
1287
|
+
if (rec)
|
|
1288
|
+
rec.resolvedAt = resolvedAt;
|
|
1289
|
+
}
|
|
1290
|
+
/** S13 refuter · mirror a zombie-record bookkeeping kill into the boot
|
|
1291
|
+
* cache (same reload-vs-restart gap as markPersistedResolved). */
|
|
1292
|
+
markPersistedKilled(sessionId) {
|
|
1293
|
+
const rec = this.persistedCache.find((p) => p.sessionId === sessionId);
|
|
1294
|
+
if (rec) {
|
|
1295
|
+
rec.status = 'killed';
|
|
1296
|
+
rec.completedAt = Date.now();
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
851
1299
|
/** Completion timestamp for an in-memory session, if it has exited.
|
|
852
1300
|
* Returns undefined if the session is still running OR was never spawned
|
|
853
1301
|
* through this manager. Bug 2 sister-fix (May 2): TaskSession itself has
|
|
854
1302
|
* no completedAt field; this method lets the API layer compute durations. */
|
|
855
1303
|
getCompletedAt(sessionId) {
|
|
856
|
-
return this.
|
|
1304
|
+
return this.records.get(sessionId)?.completedAt;
|
|
857
1305
|
}
|
|
858
1306
|
/** v0.6.x Slack-shape spike — return cwd for an in-memory session. */
|
|
859
1307
|
getCwd(sessionId) {
|
|
860
|
-
return this.
|
|
1308
|
+
return this.records.get(sessionId)?.cwd;
|
|
1309
|
+
}
|
|
1310
|
+
/** The specialist the Conductor routed this session to, if it was an Auto/
|
|
1311
|
+
* conductor task — so the UI can name the agent that actually ran. */
|
|
1312
|
+
getDelegatedTo(sessionId) {
|
|
1313
|
+
return this.records.get(sessionId)?.delegatedTo;
|
|
1314
|
+
}
|
|
1315
|
+
/** It-4 V1-MULTITASK — batchId for an in-memory session, if it was spawned as
|
|
1316
|
+
* part of a batch (@all fanout / multi-task project). Lets /api/tasks expose
|
|
1317
|
+
* batchId on LIVE sessions so the room rollup row derives before any restart. */
|
|
1318
|
+
getBatchId(sessionId) {
|
|
1319
|
+
return this.records.get(sessionId)?.batchId;
|
|
861
1320
|
}
|
|
862
1321
|
/** Session threading — which session did this one continue from (after failover)? */
|
|
863
1322
|
getContinuedFrom(sessionId) {
|
|
864
|
-
return this.
|
|
1323
|
+
return this.records.get(sessionId)?.continuedFrom;
|
|
865
1324
|
}
|
|
866
1325
|
/** Session threading — which session did this one continue as (after failover)? */
|
|
867
1326
|
getContinuedAs(sessionId) {
|
|
868
|
-
return this.
|
|
1327
|
+
return this.records.get(sessionId)?.continuedAs;
|
|
1328
|
+
}
|
|
1329
|
+
/** Recovery lineage — if this session is a failover REPLACEMENT, which agent it
|
|
1330
|
+
* took over from + why. Drives the dashboard "recovered from <vendor>" badge.
|
|
1331
|
+
* undefined for a normal (non-recovered) session. */
|
|
1332
|
+
getRecoveredFrom(sessionId) {
|
|
1333
|
+
return this.records.get(sessionId)?.recoveredFrom;
|
|
869
1334
|
}
|
|
870
1335
|
/** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
|
|
871
1336
|
* Returns undefined while generation is in flight OR if the provider chain
|
|
872
1337
|
* failed (no claude-free, no claude, no credit, etc). The web UI reads this
|
|
873
1338
|
* through the /api/tasks serializer to render the prose summary inline. */
|
|
874
1339
|
getFinalReport(sessionId) {
|
|
875
|
-
return this.
|
|
1340
|
+
return this.records.get(sessionId)?.finalReport;
|
|
1341
|
+
}
|
|
1342
|
+
/** REQUEST_METHOD §2 — the acceptance contract for a session, served through
|
|
1343
|
+
* /api/tasks so the room can render "Done means …". */
|
|
1344
|
+
getAcceptanceCriteria(sessionId) {
|
|
1345
|
+
return this.records.get(sessionId)?.acceptanceCriteria;
|
|
876
1346
|
}
|
|
877
1347
|
/** Returns true if Final Report generation explicitly failed for this session.
|
|
878
1348
|
* Lets the API distinguish "not yet" from "never coming". */
|
|
879
1349
|
hasFailedReport(sessionId) {
|
|
880
|
-
return this.
|
|
1350
|
+
return this.records.get(sessionId)?.failedReport === true;
|
|
881
1351
|
}
|
|
882
1352
|
get(sessionId) {
|
|
883
1353
|
return this.sessions.get(sessionId);
|
|
884
1354
|
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Reconstruct an APPENDABLE handle for a completed session no longer in memory
|
|
1357
|
+
* (e.g. after a daemon restart), from its persisted record. claude keeps its
|
|
1358
|
+
* own session store, so `claude --resume <nativeSessionId>` in the original cwd
|
|
1359
|
+
* continues the goal with full context — no live session object needed. Only
|
|
1360
|
+
* claude-code today (it has a stable native resume token); undefined otherwise.
|
|
1361
|
+
* This is what makes `codehere reply <id>` survive a daemon restart.
|
|
1362
|
+
*/
|
|
1363
|
+
reconstructAppendable(sessionId) {
|
|
1364
|
+
const rec = loadSessionById(sessionId);
|
|
1365
|
+
if (!rec || rec.agent !== 'claude-code' || !rec.nativeSessionId || !rec.cwd)
|
|
1366
|
+
return undefined;
|
|
1367
|
+
const native = rec.nativeSessionId;
|
|
1368
|
+
const cwd = rec.cwd;
|
|
1369
|
+
return {
|
|
1370
|
+
sessionId: rec.sessionId,
|
|
1371
|
+
startedAt: rec.startedAt,
|
|
1372
|
+
prompt: rec.prompt,
|
|
1373
|
+
agent: rec.agent,
|
|
1374
|
+
status: rec.status,
|
|
1375
|
+
cwd,
|
|
1376
|
+
canAppendMessage: true,
|
|
1377
|
+
subscribe: () => () => { },
|
|
1378
|
+
kill: () => { },
|
|
1379
|
+
appendMessage: (text) => resumeClaudeMessage(native, cwd, text),
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
885
1382
|
/** Evict the oldest completed sessions when the in-memory cap is reached.
|
|
886
1383
|
* Prevents unbounded memory growth in the internal tracking maps. The
|
|
887
1384
|
* data survives on disk via sessions.jsonl + thread.jsonl. */
|
|
888
1385
|
evictOldCompleted() {
|
|
889
1386
|
const completed = [];
|
|
890
1387
|
for (const id of this.spawnOrder) {
|
|
891
|
-
const at = this.
|
|
1388
|
+
const at = this.records.get(id)?.completedAt;
|
|
892
1389
|
if (at !== undefined)
|
|
893
1390
|
completed.push({ id, completedAt: at });
|
|
894
1391
|
}
|
|
@@ -896,6 +1393,9 @@ export class SessionManager {
|
|
|
896
1393
|
return;
|
|
897
1394
|
const toEvict = completed.slice(0, completed.length - SessionManager.MAX_COMPLETED_SESSIONS);
|
|
898
1395
|
const evictSet = new Set(toEvict.map((e) => e.id));
|
|
1396
|
+
// MED #10 (atomic audit) · spawnOrder was never trimmed — it grew for the
|
|
1397
|
+
// daemon's life and the eviction scan degraded with it.
|
|
1398
|
+
this.spawnOrder = this.spawnOrder.filter((id) => !evictSet.has(id));
|
|
899
1399
|
for (const id of evictSet) {
|
|
900
1400
|
const t = this.narrationTimers.get(id);
|
|
901
1401
|
if (t) {
|
|
@@ -903,18 +1403,8 @@ export class SessionManager {
|
|
|
903
1403
|
this.narrationTimers.delete(id);
|
|
904
1404
|
}
|
|
905
1405
|
this.clearStallTimer(id);
|
|
906
|
-
this.lastActivityBySession.delete(id);
|
|
907
|
-
this.stalledBySession.delete(id);
|
|
908
1406
|
this.sessions.delete(id);
|
|
909
|
-
this.
|
|
910
|
-
this.cwdBySession.delete(id);
|
|
911
|
-
this.finalReportBySession.delete(id);
|
|
912
|
-
this.narrationBySession.delete(id);
|
|
913
|
-
this.failoverReasonBySession.delete(id);
|
|
914
|
-
this.continuedAsBySession.delete(id);
|
|
915
|
-
this.continuedFromBySession.delete(id);
|
|
916
|
-
this.failedReportBySession.delete(id);
|
|
917
|
-
this.failoverTriedBySession.delete(id);
|
|
1407
|
+
this.records.delete(id);
|
|
918
1408
|
}
|
|
919
1409
|
}
|
|
920
1410
|
list() {
|
|
@@ -1063,6 +1553,15 @@ export class SessionManager {
|
|
|
1063
1553
|
}
|
|
1064
1554
|
return agents.sort((a, b) => b.startedAt - a.startedAt);
|
|
1065
1555
|
}
|
|
1556
|
+
/** Subscribe to live-progress ticks (steps/say/exit). Returns an unsubscribe
|
|
1557
|
+
* fn. The SSE endpoint uses this to push 'change' mid-run, not just when
|
|
1558
|
+
* sessions.jsonl grows on disk. Listener cap is lifted because each
|
|
1559
|
+
* connected browser tab adds one. */
|
|
1560
|
+
onLiveChange(cb) {
|
|
1561
|
+
this.liveChanges.setMaxListeners(0);
|
|
1562
|
+
this.liveChanges.on('change', cb);
|
|
1563
|
+
return () => this.liveChanges.off('change', cb);
|
|
1564
|
+
}
|
|
1066
1565
|
static _instance;
|
|
1067
1566
|
static getInstance() {
|
|
1068
1567
|
return (this._instance ??= new SessionManager());
|