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.
Files changed (39) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +89 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/context-handoff.js +143 -0
  17. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  18. package/dist/infrastructure/orchestration/failover-config.js +77 -0
  19. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  20. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  21. package/dist/infrastructure/orchestration/graphify-context.js +256 -0
  22. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  23. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  24. package/dist/infrastructure/orchestration/session-manager.js +515 -15
  25. package/dist/infrastructure/protocols/cloud-sync.js +208 -0
  26. package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
  27. package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
  28. package/dist/infrastructure/protocols/ui-page.js +2872 -220
  29. package/dist/infrastructure/security/permissions-config.js +234 -0
  30. package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
  31. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  32. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  33. package/dist/presentation/cli/commands/budget-command.js +79 -0
  34. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  35. package/dist/presentation/cli/commands/permission-command.js +116 -0
  36. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  37. package/dist/presentation/cli/repl.js +29 -0
  38. package/dist/utils/typo-guard.js +77 -17
  39. 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) => ({ id: a.id, label: a.label, installed: a.isInstalled() }));
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
- if (a.checkReadiness)
56
- return a.checkReadiness();
57
- // Fallback for adapters that haven't implemented checkReadiness yet: treat
58
- // "installed" as "ready", "not installed" as "needs_install without hint".
59
- const installed = a.isInstalled();
60
- return installed
61
- ? { id: a.id, label: a.label, state: 'ready', installed: true }
62
- : { id: a.id, label: a.label, state: 'needs_install', installed: false };
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
- const session = adapter.spawn(prompt, options);
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 never throws by contract; defensive only.
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
- * Returns undefined while generation is in flight OR if the provider chain
239
- * failed (no claude-free, no claude, no credit, etc). The web UI reads this
240
- * through the /api/tasks serializer to render the prose summary inline. */
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
  }