codehere 0.5.0-alpha.6 → 0.6.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 +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +89 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/context-handoff.js +143 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-config.js +77 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/graphify-context.js +256 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +515 -15
- package/dist/infrastructure/protocols/cloud-sync.js +208 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
- package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
- package/dist/infrastructure/protocols/ui-page.js +2872 -220
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/presentation/cli/repl.js +29 -0
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +2 -1
|
@@ -25,6 +25,15 @@ import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
|
25
25
|
import { appendSpawned, appendCompleted, appendFinalReport, loadRecent } from './session-persistence.js';
|
|
26
26
|
import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
|
|
27
27
|
import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/project-thread-mirror.js';
|
|
28
|
+
import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
|
|
29
|
+
import { generateNarration } from '../../application/use-cases/generate-narration.js';
|
|
30
|
+
import { randomUUID } from 'crypto';
|
|
31
|
+
import { classifyFailoverEligibility } from './failover-classifier.js';
|
|
32
|
+
import { resolveFailoverTarget, toAvailability, } from './failover-policy.js';
|
|
33
|
+
import { loadFailoverConfig, resolveChain } from './failover-config.js';
|
|
34
|
+
import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
|
|
35
|
+
import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
|
|
36
|
+
import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
|
|
28
37
|
// ── Agent registry ─────────────────────────────────────────────────────────────
|
|
29
38
|
// All backends codehere can spawn. Order here drives the default UI dropdown
|
|
30
39
|
// order. Built-in adapters first; user-config-driven ones appended from
|
|
@@ -47,19 +56,25 @@ const REGISTRY = [
|
|
|
47
56
|
// Conductor is appended to the same array so UIs that iterate REGISTRY see it.
|
|
48
57
|
REGISTRY.push(buildConductorAdapter(() => REGISTRY));
|
|
49
58
|
export function availableAgents() {
|
|
50
|
-
return REGISTRY.map((a) => ({
|
|
59
|
+
return REGISTRY.map((a) => ({
|
|
60
|
+
id: a.id,
|
|
61
|
+
label: a.label,
|
|
62
|
+
installed: a.isInstalled(),
|
|
63
|
+
...(a.description ? { description: a.description } : {}),
|
|
64
|
+
}));
|
|
51
65
|
}
|
|
52
66
|
/** Per-agent readiness across the full registry. Powers the setup-aware UI. */
|
|
53
67
|
export function readinessForAllAgents() {
|
|
54
68
|
return REGISTRY.map((a) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
const base = a.checkReadiness
|
|
70
|
+
? a.checkReadiness()
|
|
71
|
+
: a.isInstalled()
|
|
72
|
+
? { id: a.id, label: a.label, state: 'ready', installed: true }
|
|
73
|
+
: { id: a.id, label: a.label, state: 'needs_install', installed: false };
|
|
74
|
+
// v0.6 S5 — mirror the adapter's "when to use me" description into the
|
|
75
|
+
// readiness payload so the dashboard dropdown can surface it as a
|
|
76
|
+
// plain-English hover tooltip without a separate API call.
|
|
77
|
+
return a.description ? { ...base, description: a.description } : base;
|
|
63
78
|
});
|
|
64
79
|
}
|
|
65
80
|
export function getAdapter(id) {
|
|
@@ -115,6 +130,29 @@ export class SessionManager {
|
|
|
115
130
|
* reads via getFinalReport() through the /api/tasks serializer so non-technical
|
|
116
131
|
* users see the prose summary without opening a terminal. */
|
|
117
132
|
finalReportBySession = new Map();
|
|
133
|
+
/** v0.6 M3 · PM Narrator. Mid-task plain-English summaries cached per
|
|
134
|
+
* session so the API can serve the latest narration without re-running
|
|
135
|
+
* the LLM call on every frontend poll. Latest-only — older narrations
|
|
136
|
+
* are overwritten when a new one fires. Per claim_user_persona HIGH:
|
|
137
|
+
* non-technical users read narration, not tool calls. */
|
|
138
|
+
narrationBySession = new Map();
|
|
139
|
+
narrationTimers = new Map();
|
|
140
|
+
narrationStepsAtLastFire = new Map();
|
|
141
|
+
/** Stores active failover offers keyed by sessionId+target. API layer
|
|
142
|
+
* reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
|
|
143
|
+
failoverOffers;
|
|
144
|
+
/** Tracks the failover error code per session so acceptFailover can emit
|
|
145
|
+
* the correct reason in the thread event. Populated by handleFailover. */
|
|
146
|
+
failoverReasonBySession = new Map();
|
|
147
|
+
/** Session threading — tracks which session continued as which after failover.
|
|
148
|
+
* continuedAsBySession: original → new session ID
|
|
149
|
+
* continuedFromBySession: new → original session ID */
|
|
150
|
+
continuedAsBySession = new Map();
|
|
151
|
+
continuedFromBySession = new Map();
|
|
152
|
+
/** Final Report failure tracking — when generateFinalReport rejects, we
|
|
153
|
+
* record it so the API can return a definitive "failed" state instead
|
|
154
|
+
* of leaving the UI showing "Generating report…" forever. */
|
|
155
|
+
failedReportBySession = new Set();
|
|
118
156
|
spawn(prompt, agentId = 'claude-code', options) {
|
|
119
157
|
const adapter = getAdapter(agentId);
|
|
120
158
|
if (!adapter) {
|
|
@@ -123,12 +161,35 @@ export class SessionManager {
|
|
|
123
161
|
if (!adapter.isInstalled()) {
|
|
124
162
|
throw new Error(`Agent ${adapter.label} (${adapter.id}) is not installed on this machine`);
|
|
125
163
|
}
|
|
126
|
-
|
|
164
|
+
// Cross-vendor pre-prompt scan (task #110, slice 1). Closes the gap
|
|
165
|
+
// the FAQ now claims as "v0.5.x → v0.6 track": every adapter — not
|
|
166
|
+
// just claude-code — gets a safety scan on the user's prompt before
|
|
167
|
+
// spawn. Catches API-key paste, prompt-injection patterns, etc.
|
|
168
|
+
// Always writes one audit entry (synthetic toolName 'pre_prompt');
|
|
169
|
+
// refuses spawn ONLY when getHookMode() === 'block' AND the prompt
|
|
170
|
+
// has critical/high findings. Other modes (advisory/log-only/off)
|
|
171
|
+
// log + proceed.
|
|
172
|
+
const preScanSessionId = randomUUID();
|
|
173
|
+
const scan = scanPromptBeforeSpawn({
|
|
174
|
+
sessionId: preScanSessionId,
|
|
175
|
+
agent: agentId,
|
|
176
|
+
prompt,
|
|
177
|
+
cwd: options?.cwd,
|
|
178
|
+
});
|
|
179
|
+
if (!scan.allowed) {
|
|
180
|
+
throw new Error(scan.blockReason || 'pre-prompt scan blocked the spawn');
|
|
181
|
+
}
|
|
182
|
+
// P1 — graphify-ts structural memory: enrich prompt with compact context
|
|
183
|
+
// pack so the agent starts with a verified knowledge graph of the workspace.
|
|
184
|
+
// Respects CODEHERE_GRAPHIFY env var (auto|force|off, default=auto).
|
|
185
|
+
// Synchronous call — degrades to original prompt if graphify fails.
|
|
186
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
187
|
+
const enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
|
|
188
|
+
const session = adapter.spawn(enrichedPrompt, options);
|
|
127
189
|
this.sessions.set(session.sessionId, session);
|
|
128
190
|
// Persistence: write the spawned record immediately so a daemon crash still
|
|
129
191
|
// leaves an audit trail of what was attempted. Subscribe to exit so we can
|
|
130
192
|
// capture the final status + steps + artifacts when the session settles.
|
|
131
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
132
193
|
this.cwdBySession.set(session.sessionId, cwd);
|
|
133
194
|
appendSpawned({
|
|
134
195
|
id: session.sessionId,
|
|
@@ -147,6 +208,20 @@ export class SessionManager {
|
|
|
147
208
|
startedAt: session.startedAt,
|
|
148
209
|
});
|
|
149
210
|
const unsubscribe = session.subscribe((event) => {
|
|
211
|
+
// ── Failover check — intercept rate-limit / quota errors before exit ──
|
|
212
|
+
// Adapters emit 'codehere:error' (structured: { code, message, recovery })
|
|
213
|
+
// or 'error' (spawn-level: { message }). Both need classification.
|
|
214
|
+
if (event.type === 'error' || event.type === 'codehere:error') {
|
|
215
|
+
const err = event.data;
|
|
216
|
+
if (err?.code && err?.message) {
|
|
217
|
+
const signal = classifyFailoverEligibility(session.agent, err.code, err.message);
|
|
218
|
+
if (signal) {
|
|
219
|
+
const newSession = this.handleFailover(session, signal);
|
|
220
|
+
if (newSession)
|
|
221
|
+
return; // auto-spawned — keep listening on new session
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
150
225
|
if (event.type !== 'exit')
|
|
151
226
|
return;
|
|
152
227
|
const data = event.data;
|
|
@@ -213,12 +288,424 @@ export class SessionManager {
|
|
|
213
288
|
}
|
|
214
289
|
})
|
|
215
290
|
.catch(() => {
|
|
216
|
-
// generateFinalReport
|
|
291
|
+
// generateFinalReport rejected — record it so the UI can show
|
|
292
|
+
// "Report unavailable" instead of "Generating report…" forever.
|
|
293
|
+
this.failedReportBySession.add(session.sessionId);
|
|
294
|
+
});
|
|
295
|
+
// P1 — persist context pack at session exit for cross-session handoff (P2).
|
|
296
|
+
// Fire-and-forget: graphify-ts may not be installed, graph may be empty,
|
|
297
|
+
// or pack compilation may fail. All degrade silently.
|
|
298
|
+
void Promise.resolve().then(() => {
|
|
299
|
+
try {
|
|
300
|
+
const stepsStr = session.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
|
|
301
|
+
const pack = compileSessionPackSync(cwd, {
|
|
302
|
+
prompt: session.prompt,
|
|
303
|
+
artifacts: JSON.stringify(session.artifacts, null, 2),
|
|
304
|
+
steps: stepsStr,
|
|
305
|
+
agent: session.agent,
|
|
306
|
+
});
|
|
307
|
+
if (pack)
|
|
308
|
+
persistContextPack(pack, cwd);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// Silent degradation — context pack is enrichment, not the primary path.
|
|
312
|
+
}
|
|
217
313
|
});
|
|
314
|
+
// v0.6 M3 — clean up the narration timer for this session.
|
|
315
|
+
const t = this.narrationTimers.get(session.sessionId);
|
|
316
|
+
if (t) {
|
|
317
|
+
clearInterval(t);
|
|
318
|
+
this.narrationTimers.delete(session.sessionId);
|
|
319
|
+
}
|
|
218
320
|
unsubscribe();
|
|
219
321
|
});
|
|
322
|
+
// v0.6 M3 — PM Narrator. Mid-task plain-English summaries fired
|
|
323
|
+
// every 30s while the session is running. Heuristic: only fire if
|
|
324
|
+
// the step count grew by ≥3 since the last narration (otherwise
|
|
325
|
+
// we'd narrate the same state with new words). Fire-and-forget;
|
|
326
|
+
// failures (no claude binary, timeout, malformed output) silently
|
|
327
|
+
// skip — narration is enrichment, not the primary surface.
|
|
328
|
+
this.startNarrationTimer(session);
|
|
220
329
|
return session;
|
|
221
330
|
}
|
|
331
|
+
/** Tracks which agents have already been tried for a given prompt during
|
|
332
|
+
* a failover chain. Maps sessionId → set of agent ids already attempted. */
|
|
333
|
+
failoverTriedBySession = new Map();
|
|
334
|
+
/** v0.6.x failover — emit a failover offer when an adapter hits a
|
|
335
|
+
* rate-limit / quota error. The offer is surfaced via the API layer
|
|
336
|
+
* (Slice E) and optionally auto-spawned (Slice D). Returns the new
|
|
337
|
+
* session if auto-spawn was triggered, undefined otherwise. */
|
|
338
|
+
handleFailover(session, signal) {
|
|
339
|
+
const config = loadFailoverConfig();
|
|
340
|
+
const chain = resolveChain(config?.chain);
|
|
341
|
+
const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
|
|
342
|
+
tried.add(signal.fromAgent);
|
|
343
|
+
this.failoverTriedBySession.set(session.sessionId, tried);
|
|
344
|
+
const adapters = allAdapters();
|
|
345
|
+
const available = toAvailability(adapters, (a) => {
|
|
346
|
+
return a.id !== signal.fromAgent && a.isInstalled();
|
|
347
|
+
});
|
|
348
|
+
const target = resolveFailoverTarget({
|
|
349
|
+
failedAgent: signal.fromAgent,
|
|
350
|
+
alreadyTried: Array.from(tried),
|
|
351
|
+
available,
|
|
352
|
+
preferredChain: config?.chain,
|
|
353
|
+
});
|
|
354
|
+
if (!target)
|
|
355
|
+
return undefined;
|
|
356
|
+
const targetAdapter = getAdapter(target);
|
|
357
|
+
if (!targetAdapter || !targetAdapter.isInstalled())
|
|
358
|
+
return undefined;
|
|
359
|
+
// Auto-failover: spawn on the next agent in the chain
|
|
360
|
+
const autoFailover = config?.autoFailover ?? true;
|
|
361
|
+
if (autoFailover) {
|
|
362
|
+
// Spawn replacement session on the next agent, preserving prompt + cwd
|
|
363
|
+
const cwd = this.cwdBySession.get(session.sessionId) ?? process.cwd();
|
|
364
|
+
// P2 — build handoff context for continuity across agent swap
|
|
365
|
+
const handoff = buildFailoverHandoff(cwd, {
|
|
366
|
+
failedAgent: session.agent,
|
|
367
|
+
failedPrompt: session.prompt,
|
|
368
|
+
});
|
|
369
|
+
// Enrich prompt with handoff if available (P2)
|
|
370
|
+
const promptWithHandoff = handoff
|
|
371
|
+
? enrichPromptWithHandoff(session.prompt, {
|
|
372
|
+
packId: handoff.packId,
|
|
373
|
+
agent: session.agent,
|
|
374
|
+
prompt: session.prompt,
|
|
375
|
+
status: 'failed',
|
|
376
|
+
timestamp: handoff.timestamp,
|
|
377
|
+
nodeCount: handoff.nodeCount,
|
|
378
|
+
edgeCount: handoff.edgeCount,
|
|
379
|
+
task: handoff.task,
|
|
380
|
+
payloadPreview: handoff.payloadPreview,
|
|
381
|
+
})
|
|
382
|
+
: session.prompt;
|
|
383
|
+
try {
|
|
384
|
+
const newSession = targetAdapter.spawn(promptWithHandoff, {
|
|
385
|
+
cwd,
|
|
386
|
+
env: process.env,
|
|
387
|
+
preferSubscription: true,
|
|
388
|
+
});
|
|
389
|
+
// Track this new session in the same way
|
|
390
|
+
this.sessions.set(newSession.sessionId, newSession);
|
|
391
|
+
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
392
|
+
appendSpawned({
|
|
393
|
+
id: newSession.sessionId,
|
|
394
|
+
agent: newSession.agent,
|
|
395
|
+
prompt: promptWithHandoff,
|
|
396
|
+
cwd,
|
|
397
|
+
startedAt: newSession.startedAt,
|
|
398
|
+
});
|
|
399
|
+
mirrorSpawnedToThread({
|
|
400
|
+
sessionId: newSession.sessionId,
|
|
401
|
+
agent: newSession.agent,
|
|
402
|
+
prompt: promptWithHandoff,
|
|
403
|
+
cwd,
|
|
404
|
+
startedAt: newSession.startedAt,
|
|
405
|
+
});
|
|
406
|
+
mirrorFailoverToThread({
|
|
407
|
+
sessionId: newSession.sessionId,
|
|
408
|
+
fromAgent: signal.fromAgent,
|
|
409
|
+
toAgent: target,
|
|
410
|
+
reason: signal.errorCode,
|
|
411
|
+
cwd,
|
|
412
|
+
});
|
|
413
|
+
// Subscribe to the new session with the same failover logic
|
|
414
|
+
const unsubscribe = newSession.subscribe((event) => {
|
|
415
|
+
if (event.type !== 'exit')
|
|
416
|
+
return;
|
|
417
|
+
const data = event.data;
|
|
418
|
+
const completedAt = Date.now();
|
|
419
|
+
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
420
|
+
appendCompleted({
|
|
421
|
+
id: newSession.sessionId,
|
|
422
|
+
status: newSession.status,
|
|
423
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
424
|
+
completedAt,
|
|
425
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
426
|
+
artifacts: newSession.artifacts,
|
|
427
|
+
});
|
|
428
|
+
mirrorCompletedToThread({
|
|
429
|
+
sessionId: newSession.sessionId,
|
|
430
|
+
cwd,
|
|
431
|
+
status: newSession.status,
|
|
432
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
433
|
+
completedAt,
|
|
434
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
435
|
+
artifacts: newSession.artifacts,
|
|
436
|
+
});
|
|
437
|
+
const reportInput = {
|
|
438
|
+
sessionId: newSession.sessionId,
|
|
439
|
+
prompt: newSession.prompt,
|
|
440
|
+
agent: newSession.agent,
|
|
441
|
+
status: newSession.status,
|
|
442
|
+
durationMs: completedAt - newSession.startedAt,
|
|
443
|
+
steps: newSession.steps ? [...newSession.steps] : [],
|
|
444
|
+
artifacts: newSession.artifacts,
|
|
445
|
+
};
|
|
446
|
+
generateFinalReport(reportInput)
|
|
447
|
+
.then((report) => {
|
|
448
|
+
if (report) {
|
|
449
|
+
this.finalReportBySession.set(newSession.sessionId, report);
|
|
450
|
+
appendFinalReport({
|
|
451
|
+
id: newSession.sessionId,
|
|
452
|
+
summary: report.summary,
|
|
453
|
+
generatedAt: report.generatedAt,
|
|
454
|
+
durationMs: report.durationMs,
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
printFinalReportToTerminal(reportInput, report);
|
|
458
|
+
}
|
|
459
|
+
catch { /* defensive */ }
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
.catch(() => { });
|
|
463
|
+
// P1 — persist context pack at failover-replacement session exit.
|
|
464
|
+
void Promise.resolve().then(() => {
|
|
465
|
+
try {
|
|
466
|
+
const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
|
|
467
|
+
const pack = compileSessionPackSync(cwd, {
|
|
468
|
+
prompt: newSession.prompt,
|
|
469
|
+
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
470
|
+
steps: stepsStr,
|
|
471
|
+
agent: newSession.agent,
|
|
472
|
+
});
|
|
473
|
+
if (pack)
|
|
474
|
+
persistContextPack(pack, cwd);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// Silent degradation — context pack is enrichment, not primary path.
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
const t = this.narrationTimers.get(newSession.sessionId);
|
|
481
|
+
if (t) {
|
|
482
|
+
clearInterval(t);
|
|
483
|
+
this.narrationTimers.delete(newSession.sessionId);
|
|
484
|
+
}
|
|
485
|
+
unsubscribe();
|
|
486
|
+
});
|
|
487
|
+
this.startNarrationTimer(newSession);
|
|
488
|
+
return newSession;
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// Spawn failed — fall through to offer-only mode
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Offer-only mode: signal the API layer to present a failover banner
|
|
496
|
+
// (Slice E handles the actual UI surfacing)
|
|
497
|
+
this.emitFailoverOffer({ session, signal, target });
|
|
498
|
+
this.failoverReasonBySession.set(session.sessionId, signal.errorCode);
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
/** Emitted when a failover offer is generated. The API layer listens
|
|
502
|
+
* to this to surface the banner in the web UI. */
|
|
503
|
+
emitFailoverOffer(offer) {
|
|
504
|
+
// Store in a map that the API layer can poll — Slice E wires the
|
|
505
|
+
// /api/failover-offers endpoint to read from here.
|
|
506
|
+
if (!this.failoverOffers) {
|
|
507
|
+
this.failoverOffers = new Map();
|
|
508
|
+
}
|
|
509
|
+
const key = `${offer.session.sessionId}-${offer.target}`;
|
|
510
|
+
this.failoverOffers.set(key, offer);
|
|
511
|
+
// Auto-expire offers after 60 seconds to keep the map bounded
|
|
512
|
+
setTimeout(() => {
|
|
513
|
+
this.failoverOffers?.delete(key);
|
|
514
|
+
}, 60_000);
|
|
515
|
+
}
|
|
516
|
+
/** Get all active failover offers for the API layer. */
|
|
517
|
+
getFailoverOffers() {
|
|
518
|
+
if (!this.failoverOffers)
|
|
519
|
+
return [];
|
|
520
|
+
const offers = [];
|
|
521
|
+
for (const [key, offer] of this.failoverOffers) {
|
|
522
|
+
offers.push({
|
|
523
|
+
sessionId: offer.session.sessionId,
|
|
524
|
+
signal: offer.signal,
|
|
525
|
+
target: offer.target,
|
|
526
|
+
expiresAt: Date.now() + 60_000,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return offers;
|
|
530
|
+
}
|
|
531
|
+
/** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
|
|
532
|
+
* session on the target agent, preserving the original prompt + cwd.
|
|
533
|
+
* P2 — injects handoff context for cross-agent continuity.
|
|
534
|
+
* Returns the new session or undefined if the target is unavailable. */
|
|
535
|
+
acceptFailover(sessionId, targetAgent) {
|
|
536
|
+
const session = this.sessions.get(sessionId);
|
|
537
|
+
if (!session)
|
|
538
|
+
return undefined;
|
|
539
|
+
const targetAdapter = getAdapter(targetAgent);
|
|
540
|
+
if (!targetAdapter || !targetAdapter.isInstalled())
|
|
541
|
+
return undefined;
|
|
542
|
+
const cwd = this.cwdBySession.get(sessionId) ?? process.cwd();
|
|
543
|
+
// P2 — build handoff context for continuity across agent swap
|
|
544
|
+
const handoff = buildFailoverHandoff(cwd, {
|
|
545
|
+
failedAgent: session.agent,
|
|
546
|
+
failedPrompt: session.prompt,
|
|
547
|
+
});
|
|
548
|
+
const promptWithHandoff = handoff
|
|
549
|
+
? enrichPromptWithHandoff(session.prompt, {
|
|
550
|
+
packId: handoff.packId,
|
|
551
|
+
agent: session.agent,
|
|
552
|
+
prompt: session.prompt,
|
|
553
|
+
status: 'failed',
|
|
554
|
+
timestamp: handoff.timestamp,
|
|
555
|
+
nodeCount: handoff.nodeCount,
|
|
556
|
+
edgeCount: handoff.edgeCount,
|
|
557
|
+
task: handoff.task,
|
|
558
|
+
payloadPreview: handoff.payloadPreview,
|
|
559
|
+
})
|
|
560
|
+
: session.prompt;
|
|
561
|
+
const newSession = targetAdapter.spawn(promptWithHandoff, {
|
|
562
|
+
cwd,
|
|
563
|
+
env: process.env,
|
|
564
|
+
preferSubscription: true,
|
|
565
|
+
});
|
|
566
|
+
// Track the new session
|
|
567
|
+
this.sessions.set(newSession.sessionId, newSession);
|
|
568
|
+
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
569
|
+
// Session threading: record the parent-child relationship
|
|
570
|
+
this.continuedAsBySession.set(sessionId, newSession.sessionId);
|
|
571
|
+
this.continuedFromBySession.set(newSession.sessionId, sessionId);
|
|
572
|
+
appendSpawned({
|
|
573
|
+
id: newSession.sessionId,
|
|
574
|
+
agent: newSession.agent,
|
|
575
|
+
prompt: promptWithHandoff,
|
|
576
|
+
cwd,
|
|
577
|
+
startedAt: newSession.startedAt,
|
|
578
|
+
});
|
|
579
|
+
mirrorSpawnedToThread({
|
|
580
|
+
sessionId: newSession.sessionId,
|
|
581
|
+
agent: newSession.agent,
|
|
582
|
+
prompt: promptWithHandoff,
|
|
583
|
+
cwd,
|
|
584
|
+
startedAt: newSession.startedAt,
|
|
585
|
+
});
|
|
586
|
+
mirrorFailoverToThread({
|
|
587
|
+
sessionId: newSession.sessionId,
|
|
588
|
+
fromAgent: session.agent,
|
|
589
|
+
toAgent: targetAgent,
|
|
590
|
+
reason: this.failoverReasonBySession.get(session.sessionId) ?? 'error',
|
|
591
|
+
cwd,
|
|
592
|
+
});
|
|
593
|
+
// Subscribe to the new session with the same failover logic
|
|
594
|
+
const unsubscribe = newSession.subscribe((event) => {
|
|
595
|
+
if (event.type !== 'exit')
|
|
596
|
+
return;
|
|
597
|
+
const data = event.data;
|
|
598
|
+
const completedAt = Date.now();
|
|
599
|
+
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
600
|
+
appendCompleted({
|
|
601
|
+
id: newSession.sessionId,
|
|
602
|
+
status: newSession.status,
|
|
603
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
604
|
+
completedAt,
|
|
605
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
606
|
+
artifacts: newSession.artifacts,
|
|
607
|
+
});
|
|
608
|
+
mirrorCompletedToThread({
|
|
609
|
+
sessionId: newSession.sessionId,
|
|
610
|
+
cwd,
|
|
611
|
+
status: newSession.status,
|
|
612
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
613
|
+
completedAt,
|
|
614
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
615
|
+
artifacts: newSession.artifacts,
|
|
616
|
+
});
|
|
617
|
+
const reportInput = {
|
|
618
|
+
sessionId: newSession.sessionId,
|
|
619
|
+
prompt: newSession.prompt,
|
|
620
|
+
agent: newSession.agent,
|
|
621
|
+
status: newSession.status,
|
|
622
|
+
durationMs: completedAt - newSession.startedAt,
|
|
623
|
+
steps: newSession.steps ? [...newSession.steps] : [],
|
|
624
|
+
artifacts: newSession.artifacts,
|
|
625
|
+
};
|
|
626
|
+
generateFinalReport(reportInput)
|
|
627
|
+
.then((report) => {
|
|
628
|
+
if (report) {
|
|
629
|
+
this.finalReportBySession.set(newSession.sessionId, report);
|
|
630
|
+
appendFinalReport({
|
|
631
|
+
id: newSession.sessionId,
|
|
632
|
+
summary: report.summary,
|
|
633
|
+
generatedAt: report.generatedAt,
|
|
634
|
+
durationMs: report.durationMs,
|
|
635
|
+
});
|
|
636
|
+
try {
|
|
637
|
+
printFinalReportToTerminal(reportInput, report);
|
|
638
|
+
}
|
|
639
|
+
catch { /* defensive */ }
|
|
640
|
+
}
|
|
641
|
+
})
|
|
642
|
+
.catch(() => { });
|
|
643
|
+
// P1 — persist context pack at accept-failover replacement session exit.
|
|
644
|
+
void Promise.resolve().then(() => {
|
|
645
|
+
try {
|
|
646
|
+
const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
|
|
647
|
+
const pack = compileSessionPackSync(cwd, {
|
|
648
|
+
prompt: newSession.prompt,
|
|
649
|
+
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
650
|
+
steps: stepsStr,
|
|
651
|
+
agent: newSession.agent,
|
|
652
|
+
});
|
|
653
|
+
if (pack)
|
|
654
|
+
persistContextPack(pack, cwd);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// Silent degradation — context pack is enrichment, not primary path.
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
const t = this.narrationTimers.get(newSession.sessionId);
|
|
661
|
+
if (t) {
|
|
662
|
+
clearInterval(t);
|
|
663
|
+
this.narrationTimers.delete(newSession.sessionId);
|
|
664
|
+
}
|
|
665
|
+
this.startNarrationTimer(newSession);
|
|
666
|
+
unsubscribe();
|
|
667
|
+
});
|
|
668
|
+
this.startNarrationTimer(newSession);
|
|
669
|
+
return newSession;
|
|
670
|
+
}
|
|
671
|
+
/** v0.6 M3 — periodic narration loop per session. */
|
|
672
|
+
startNarrationTimer(session) {
|
|
673
|
+
if (process.env.CODEHERE_DISABLE_NARRATOR === '1')
|
|
674
|
+
return;
|
|
675
|
+
this.narrationStepsAtLastFire.set(session.sessionId, 0);
|
|
676
|
+
const intervalMs = Number(process.env.CODEHERE_NARRATOR_INTERVAL_MS ?? 30_000);
|
|
677
|
+
const minStepDelta = Number(process.env.CODEHERE_NARRATOR_MIN_STEPS ?? 3);
|
|
678
|
+
const timer = setInterval(() => {
|
|
679
|
+
// Skip if session has settled.
|
|
680
|
+
if (session.status !== 'running')
|
|
681
|
+
return;
|
|
682
|
+
const steps = session.steps ?? [];
|
|
683
|
+
const lastFireSteps = this.narrationStepsAtLastFire.get(session.sessionId) ?? 0;
|
|
684
|
+
if (steps.length - lastFireSteps < minStepDelta)
|
|
685
|
+
return;
|
|
686
|
+
// Mark BEFORE the async call to prevent double-firing if the LLM
|
|
687
|
+
// is slow.
|
|
688
|
+
this.narrationStepsAtLastFire.set(session.sessionId, steps.length);
|
|
689
|
+
const elapsedMs = Date.now() - session.startedAt;
|
|
690
|
+
void generateNarration({
|
|
691
|
+
sessionId: session.sessionId,
|
|
692
|
+
prompt: session.prompt,
|
|
693
|
+
agent: session.agent,
|
|
694
|
+
elapsedMs,
|
|
695
|
+
steps: [...steps],
|
|
696
|
+
})
|
|
697
|
+
.then((out) => {
|
|
698
|
+
if (out)
|
|
699
|
+
this.narrationBySession.set(session.sessionId, out);
|
|
700
|
+
})
|
|
701
|
+
.catch(() => { });
|
|
702
|
+
}, intervalMs);
|
|
703
|
+
this.narrationTimers.set(session.sessionId, timer);
|
|
704
|
+
}
|
|
705
|
+
/** v0.6 M3 — latest narration for a session, or undefined. */
|
|
706
|
+
getNarration(sessionId) {
|
|
707
|
+
return this.narrationBySession.get(sessionId);
|
|
708
|
+
}
|
|
222
709
|
/** Persisted (read-only) sessions from prior daemon runs. Cached at boot. */
|
|
223
710
|
listPersisted() {
|
|
224
711
|
return this.persistedCache;
|
|
@@ -234,13 +721,26 @@ export class SessionManager {
|
|
|
234
721
|
getCwd(sessionId) {
|
|
235
722
|
return this.cwdBySession.get(sessionId);
|
|
236
723
|
}
|
|
724
|
+
/** Session threading — which session did this one continue from (after failover)? */
|
|
725
|
+
getContinuedFrom(sessionId) {
|
|
726
|
+
return this.continuedFromBySession.get(sessionId);
|
|
727
|
+
}
|
|
728
|
+
/** Session threading — which session did this one continue as (after failover)? */
|
|
729
|
+
getContinuedAs(sessionId) {
|
|
730
|
+
return this.continuedAsBySession.get(sessionId);
|
|
731
|
+
}
|
|
237
732
|
/** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
733
|
+
* Returns undefined while generation is in flight OR if the provider chain
|
|
734
|
+
* failed (no claude-free, no claude, no credit, etc). The web UI reads this
|
|
735
|
+
* through the /api/tasks serializer to render the prose summary inline. */
|
|
241
736
|
getFinalReport(sessionId) {
|
|
242
737
|
return this.finalReportBySession.get(sessionId);
|
|
243
738
|
}
|
|
739
|
+
/** Returns true if Final Report generation explicitly failed for this session.
|
|
740
|
+
* Lets the API distinguish "not yet" from "never coming". */
|
|
741
|
+
hasFailedReport(sessionId) {
|
|
742
|
+
return this.failedReportBySession.has(sessionId);
|
|
743
|
+
}
|
|
244
744
|
get(sessionId) {
|
|
245
745
|
return this.sessions.get(sessionId);
|
|
246
746
|
}
|