codehere 0.6.0-alpha.1 → 0.6.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 +8 -8
- package/dist/index.js +41 -1
- package/dist/infrastructure/hooks/claude-sdk-hook.js +44 -2
- package/dist/infrastructure/orchestration/context-handoff.js +143 -0
- package/dist/infrastructure/orchestration/failover-config.js +77 -0
- package/dist/infrastructure/orchestration/graphify-context.js +256 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +41 -0
- package/dist/infrastructure/orchestration/session-manager.js +497 -6
- package/dist/infrastructure/protocols/cloud-sync.js +208 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +447 -311
- package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
- package/dist/infrastructure/protocols/ui-page.js +303 -37
- package/dist/infrastructure/storage/project-thread-mirror.js +25 -15
- package/dist/presentation/cli/repl.js +29 -0
- package/package.json +3 -1
|
@@ -28,6 +28,12 @@ import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/proje
|
|
|
28
28
|
import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
|
|
29
29
|
import { generateNarration } from '../../application/use-cases/generate-narration.js';
|
|
30
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';
|
|
31
37
|
// ── Agent registry ─────────────────────────────────────────────────────────────
|
|
32
38
|
// All backends codehere can spawn. Order here drives the default UI dropdown
|
|
33
39
|
// order. Built-in adapters first; user-config-driven ones appended from
|
|
@@ -102,7 +108,14 @@ function parseEtime(s) {
|
|
|
102
108
|
return 0;
|
|
103
109
|
}
|
|
104
110
|
export class SessionManager {
|
|
111
|
+
/** Max completed sessions kept in memory. Older sessions are evicted to
|
|
112
|
+
* prevent unbounded memory growth in the internal tracking maps. The data
|
|
113
|
+
* survives on disk via sessions.jsonl + thread.jsonl. */
|
|
114
|
+
static MAX_COMPLETED_SESSIONS = 100;
|
|
105
115
|
sessions = new Map();
|
|
116
|
+
/** Spawn order FIFO — used to evict oldest completed sessions when the cap
|
|
117
|
+
* is reached. Pushed on spawn, referenced by evictOldCompleted(). */
|
|
118
|
+
spawnOrder = [];
|
|
106
119
|
/** Read-only sessions replayed from ~/.codehere/sessions.jsonl on daemon start.
|
|
107
120
|
* These subprocesses are long-dead; we surface them so the user doesn't lose
|
|
108
121
|
* their Final reports across restarts. */
|
|
@@ -132,6 +145,21 @@ export class SessionManager {
|
|
|
132
145
|
narrationBySession = new Map();
|
|
133
146
|
narrationTimers = new Map();
|
|
134
147
|
narrationStepsAtLastFire = new Map();
|
|
148
|
+
/** Stores active failover offers keyed by sessionId+target. API layer
|
|
149
|
+
* reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
|
|
150
|
+
failoverOffers;
|
|
151
|
+
/** Tracks the failover error code per session so acceptFailover can emit
|
|
152
|
+
* the correct reason in the thread event. Populated by handleFailover. */
|
|
153
|
+
failoverReasonBySession = new Map();
|
|
154
|
+
/** Session threading — tracks which session continued as which after failover.
|
|
155
|
+
* continuedAsBySession: original → new session ID
|
|
156
|
+
* continuedFromBySession: new → original session ID */
|
|
157
|
+
continuedAsBySession = new Map();
|
|
158
|
+
continuedFromBySession = new Map();
|
|
159
|
+
/** Final Report failure tracking — when generateFinalReport rejects, we
|
|
160
|
+
* record it so the API can return a definitive "failed" status instead
|
|
161
|
+
* of leaving the UI showing "Generating report…" forever. */
|
|
162
|
+
failedReportBySession = new Set();
|
|
135
163
|
spawn(prompt, agentId = 'claude-code', options) {
|
|
136
164
|
const adapter = getAdapter(agentId);
|
|
137
165
|
if (!adapter) {
|
|
@@ -158,12 +186,18 @@ export class SessionManager {
|
|
|
158
186
|
if (!scan.allowed) {
|
|
159
187
|
throw new Error(scan.blockReason || 'pre-prompt scan blocked the spawn');
|
|
160
188
|
}
|
|
161
|
-
|
|
189
|
+
// P1 — graphify-ts structural memory: enrich prompt with compact context
|
|
190
|
+
// pack so the agent starts with a verified knowledge graph of the workspace.
|
|
191
|
+
// Respects CODEHERE_GRAPHIFY env var (auto|force|off, default=auto).
|
|
192
|
+
// Synchronous call — degrades to original prompt if graphify fails.
|
|
193
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
194
|
+
const enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
|
|
195
|
+
const session = adapter.spawn(enrichedPrompt, options);
|
|
162
196
|
this.sessions.set(session.sessionId, session);
|
|
197
|
+
this.spawnOrder.push(session.sessionId);
|
|
163
198
|
// Persistence: write the spawned record immediately so a daemon crash still
|
|
164
199
|
// leaves an audit trail of what was attempted. Subscribe to exit so we can
|
|
165
200
|
// capture the final status + steps + artifacts when the session settles.
|
|
166
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
167
201
|
this.cwdBySession.set(session.sessionId, cwd);
|
|
168
202
|
appendSpawned({
|
|
169
203
|
id: session.sessionId,
|
|
@@ -182,6 +216,20 @@ export class SessionManager {
|
|
|
182
216
|
startedAt: session.startedAt,
|
|
183
217
|
});
|
|
184
218
|
const unsubscribe = session.subscribe((event) => {
|
|
219
|
+
// ── Failover check — intercept rate-limit / quota errors before exit ──
|
|
220
|
+
// Adapters emit 'codehere:error' (structured: { code, message, recovery })
|
|
221
|
+
// or 'error' (spawn-level: { message }). Both need classification.
|
|
222
|
+
if (event.type === 'error' || event.type === 'codehere:error') {
|
|
223
|
+
const err = event.data;
|
|
224
|
+
if (err?.code && err?.message) {
|
|
225
|
+
const signal = classifyFailoverEligibility(session.agent, err.code, err.message);
|
|
226
|
+
if (signal) {
|
|
227
|
+
const newSession = this.handleFailover(session, signal);
|
|
228
|
+
if (newSession)
|
|
229
|
+
return; // auto-spawned — keep listening on new session
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
185
233
|
if (event.type !== 'exit')
|
|
186
234
|
return;
|
|
187
235
|
const data = event.data;
|
|
@@ -248,7 +296,28 @@ export class SessionManager {
|
|
|
248
296
|
}
|
|
249
297
|
})
|
|
250
298
|
.catch(() => {
|
|
251
|
-
// generateFinalReport
|
|
299
|
+
// generateFinalReport rejected — record it so the UI can show
|
|
300
|
+
// "Report unavailable" instead of "Generating report…" forever.
|
|
301
|
+
this.failedReportBySession.add(session.sessionId);
|
|
302
|
+
});
|
|
303
|
+
// P1 — persist context pack at session exit for cross-session handoff (P2).
|
|
304
|
+
// Fire-and-forget: graphify-ts may not be installed, graph may be empty,
|
|
305
|
+
// or pack compilation may fail. All degrade silently.
|
|
306
|
+
void Promise.resolve().then(() => {
|
|
307
|
+
try {
|
|
308
|
+
const stepsStr = session.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
|
|
309
|
+
const pack = compileSessionPackSync(cwd, {
|
|
310
|
+
prompt: session.prompt,
|
|
311
|
+
artifacts: JSON.stringify(session.artifacts, null, 2),
|
|
312
|
+
steps: stepsStr,
|
|
313
|
+
agent: session.agent,
|
|
314
|
+
});
|
|
315
|
+
if (pack)
|
|
316
|
+
persistContextPack(pack, cwd);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Silent degradation — context pack is enrichment, not the primary path.
|
|
320
|
+
}
|
|
252
321
|
});
|
|
253
322
|
// v0.6 M3 — clean up the narration timer for this session.
|
|
254
323
|
const t = this.narrationTimers.get(session.sessionId);
|
|
@@ -256,6 +325,7 @@ export class SessionManager {
|
|
|
256
325
|
clearInterval(t);
|
|
257
326
|
this.narrationTimers.delete(session.sessionId);
|
|
258
327
|
}
|
|
328
|
+
this.evictOldCompleted();
|
|
259
329
|
unsubscribe();
|
|
260
330
|
});
|
|
261
331
|
// v0.6 M3 — PM Narrator. Mid-task plain-English summaries fired
|
|
@@ -267,6 +337,357 @@ export class SessionManager {
|
|
|
267
337
|
this.startNarrationTimer(session);
|
|
268
338
|
return session;
|
|
269
339
|
}
|
|
340
|
+
/** Tracks which agents have already been tried for a given prompt during
|
|
341
|
+
* a failover chain. Maps sessionId → set of agent ids already attempted. */
|
|
342
|
+
failoverTriedBySession = new Map();
|
|
343
|
+
/** v0.6.x failover — emit a failover offer when an adapter hits a
|
|
344
|
+
* rate-limit / quota error. The offer is surfaced via the API layer
|
|
345
|
+
* (Slice E) and optionally auto-spawned (Slice D). Returns the new
|
|
346
|
+
* session if auto-spawn was triggered, undefined otherwise. */
|
|
347
|
+
handleFailover(session, signal) {
|
|
348
|
+
const config = loadFailoverConfig();
|
|
349
|
+
const chain = resolveChain(config?.chain);
|
|
350
|
+
const tried = this.failoverTriedBySession.get(session.sessionId) ?? new Set();
|
|
351
|
+
tried.add(signal.fromAgent);
|
|
352
|
+
this.failoverTriedBySession.set(session.sessionId, tried);
|
|
353
|
+
const adapters = allAdapters();
|
|
354
|
+
const available = toAvailability(adapters, (a) => {
|
|
355
|
+
return a.id !== signal.fromAgent && a.isInstalled();
|
|
356
|
+
});
|
|
357
|
+
const target = resolveFailoverTarget({
|
|
358
|
+
failedAgent: signal.fromAgent,
|
|
359
|
+
alreadyTried: Array.from(tried),
|
|
360
|
+
available,
|
|
361
|
+
preferredChain: config?.chain,
|
|
362
|
+
});
|
|
363
|
+
if (!target)
|
|
364
|
+
return undefined;
|
|
365
|
+
const targetAdapter = getAdapter(target);
|
|
366
|
+
if (!targetAdapter || !targetAdapter.isInstalled())
|
|
367
|
+
return undefined;
|
|
368
|
+
// Auto-failover: spawn on the next agent in the chain
|
|
369
|
+
const autoFailover = config?.autoFailover ?? true;
|
|
370
|
+
if (autoFailover) {
|
|
371
|
+
// Spawn replacement session on the next agent, preserving prompt + cwd
|
|
372
|
+
const cwd = this.cwdBySession.get(session.sessionId) ?? process.cwd();
|
|
373
|
+
// P2 — build handoff context for continuity across agent swap
|
|
374
|
+
const handoff = buildFailoverHandoff(cwd, {
|
|
375
|
+
failedAgent: session.agent,
|
|
376
|
+
failedPrompt: session.prompt,
|
|
377
|
+
});
|
|
378
|
+
// Enrich prompt with handoff if available (P2)
|
|
379
|
+
const promptWithHandoff = handoff
|
|
380
|
+
? enrichPromptWithHandoff(session.prompt, {
|
|
381
|
+
packId: handoff.packId,
|
|
382
|
+
agent: session.agent,
|
|
383
|
+
prompt: session.prompt,
|
|
384
|
+
status: 'failed',
|
|
385
|
+
timestamp: handoff.timestamp,
|
|
386
|
+
nodeCount: handoff.nodeCount,
|
|
387
|
+
edgeCount: handoff.edgeCount,
|
|
388
|
+
task: handoff.task,
|
|
389
|
+
payloadPreview: handoff.payloadPreview,
|
|
390
|
+
})
|
|
391
|
+
: session.prompt;
|
|
392
|
+
try {
|
|
393
|
+
const newSession = targetAdapter.spawn(promptWithHandoff, {
|
|
394
|
+
cwd,
|
|
395
|
+
env: process.env,
|
|
396
|
+
preferSubscription: true,
|
|
397
|
+
});
|
|
398
|
+
// Track this new session in the same way
|
|
399
|
+
this.sessions.set(newSession.sessionId, newSession);
|
|
400
|
+
this.spawnOrder.push(newSession.sessionId);
|
|
401
|
+
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
402
|
+
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
403
|
+
// adapter exits synchronously (command not found, instant crash, etc).
|
|
404
|
+
const unsubscribe = newSession.subscribe((event) => {
|
|
405
|
+
if (event.type !== 'exit')
|
|
406
|
+
return;
|
|
407
|
+
const data = event.data;
|
|
408
|
+
const completedAt = Date.now();
|
|
409
|
+
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
410
|
+
appendCompleted({
|
|
411
|
+
id: newSession.sessionId,
|
|
412
|
+
status: newSession.status,
|
|
413
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
414
|
+
completedAt,
|
|
415
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
416
|
+
artifacts: newSession.artifacts,
|
|
417
|
+
});
|
|
418
|
+
mirrorCompletedToThread({
|
|
419
|
+
sessionId: newSession.sessionId,
|
|
420
|
+
cwd,
|
|
421
|
+
status: newSession.status,
|
|
422
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
423
|
+
completedAt,
|
|
424
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
425
|
+
artifacts: newSession.artifacts,
|
|
426
|
+
});
|
|
427
|
+
const reportInput = {
|
|
428
|
+
sessionId: newSession.sessionId,
|
|
429
|
+
prompt: newSession.prompt,
|
|
430
|
+
agent: newSession.agent,
|
|
431
|
+
status: newSession.status,
|
|
432
|
+
durationMs: completedAt - newSession.startedAt,
|
|
433
|
+
steps: newSession.steps ? [...newSession.steps] : [],
|
|
434
|
+
artifacts: newSession.artifacts,
|
|
435
|
+
};
|
|
436
|
+
generateFinalReport(reportInput)
|
|
437
|
+
.then((report) => {
|
|
438
|
+
if (report) {
|
|
439
|
+
this.finalReportBySession.set(newSession.sessionId, report);
|
|
440
|
+
appendFinalReport({
|
|
441
|
+
id: newSession.sessionId,
|
|
442
|
+
summary: report.summary,
|
|
443
|
+
generatedAt: report.generatedAt,
|
|
444
|
+
durationMs: report.durationMs,
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
printFinalReportToTerminal(reportInput, report);
|
|
448
|
+
}
|
|
449
|
+
catch { /* defensive */ }
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
.catch(() => { });
|
|
453
|
+
// P1 — persist context pack at failover-replacement session exit.
|
|
454
|
+
void Promise.resolve().then(() => {
|
|
455
|
+
try {
|
|
456
|
+
const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
|
|
457
|
+
const pack = compileSessionPackSync(cwd, {
|
|
458
|
+
prompt: newSession.prompt,
|
|
459
|
+
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
460
|
+
steps: stepsStr,
|
|
461
|
+
agent: newSession.agent,
|
|
462
|
+
});
|
|
463
|
+
if (pack)
|
|
464
|
+
persistContextPack(pack, cwd);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// Silent degradation — context pack is enrichment, not primary path.
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
const t = this.narrationTimers.get(newSession.sessionId);
|
|
471
|
+
if (t) {
|
|
472
|
+
clearInterval(t);
|
|
473
|
+
this.narrationTimers.delete(newSession.sessionId);
|
|
474
|
+
}
|
|
475
|
+
this.evictOldCompleted();
|
|
476
|
+
unsubscribe();
|
|
477
|
+
});
|
|
478
|
+
// Persistence + threading — safe after subscribe because the exit
|
|
479
|
+
// handler is already registered. These can run even if the session
|
|
480
|
+
// already exited (spawned record still useful, exit handler handles
|
|
481
|
+
// the completion).
|
|
482
|
+
appendSpawned({
|
|
483
|
+
id: newSession.sessionId,
|
|
484
|
+
agent: newSession.agent,
|
|
485
|
+
prompt: promptWithHandoff,
|
|
486
|
+
cwd,
|
|
487
|
+
startedAt: newSession.startedAt,
|
|
488
|
+
});
|
|
489
|
+
mirrorSpawnedToThread({
|
|
490
|
+
sessionId: newSession.sessionId,
|
|
491
|
+
agent: newSession.agent,
|
|
492
|
+
prompt: promptWithHandoff,
|
|
493
|
+
cwd,
|
|
494
|
+
startedAt: newSession.startedAt,
|
|
495
|
+
});
|
|
496
|
+
mirrorFailoverToThread({
|
|
497
|
+
sessionId: newSession.sessionId,
|
|
498
|
+
fromAgent: signal.fromAgent,
|
|
499
|
+
toAgent: target,
|
|
500
|
+
reason: signal.errorCode,
|
|
501
|
+
cwd,
|
|
502
|
+
});
|
|
503
|
+
this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
|
|
504
|
+
this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
|
|
505
|
+
this.startNarrationTimer(newSession);
|
|
506
|
+
return newSession;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// Spawn failed — fall through to offer-only mode
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Offer-only mode: signal the API layer to present a failover banner
|
|
514
|
+
// (Slice E handles the actual UI surfacing)
|
|
515
|
+
this.emitFailoverOffer({ session, signal, target });
|
|
516
|
+
this.failoverReasonBySession.set(session.sessionId, signal.errorCode);
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
/** Emitted when a failover offer is generated. The API layer listens
|
|
520
|
+
* to this to surface the banner in the web UI. */
|
|
521
|
+
emitFailoverOffer(offer) {
|
|
522
|
+
if (!this.failoverOffers) {
|
|
523
|
+
this.failoverOffers = new Map();
|
|
524
|
+
}
|
|
525
|
+
const key = `${offer.session.sessionId}-${offer.target}`;
|
|
526
|
+
const expiresAt = Date.now() + 60_000;
|
|
527
|
+
this.failoverOffers.set(key, { offer, createdAt: Date.now() });
|
|
528
|
+
// Auto-expire offers after 60 seconds to keep the map bounded
|
|
529
|
+
setTimeout(() => {
|
|
530
|
+
this.failoverOffers?.delete(key);
|
|
531
|
+
}, 60_000);
|
|
532
|
+
}
|
|
533
|
+
/** Get all active failover offers for the API layer. */
|
|
534
|
+
getFailoverOffers() {
|
|
535
|
+
if (!this.failoverOffers)
|
|
536
|
+
return [];
|
|
537
|
+
const offers = [];
|
|
538
|
+
for (const [key, { offer, createdAt }] of this.failoverOffers) {
|
|
539
|
+
offers.push({
|
|
540
|
+
sessionId: offer.session.sessionId,
|
|
541
|
+
signal: offer.signal,
|
|
542
|
+
target: offer.target,
|
|
543
|
+
expiresAt: createdAt + 60_000,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
return offers;
|
|
547
|
+
}
|
|
548
|
+
/** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
|
|
549
|
+
* session on the target agent, preserving the original prompt + cwd.
|
|
550
|
+
* P2 — injects handoff context for cross-agent continuity.
|
|
551
|
+
* Returns the new session or undefined if the target is unavailable. */
|
|
552
|
+
acceptFailover(sessionId, targetAgent) {
|
|
553
|
+
const session = this.sessions.get(sessionId);
|
|
554
|
+
if (!session)
|
|
555
|
+
return undefined;
|
|
556
|
+
const targetAdapter = getAdapter(targetAgent);
|
|
557
|
+
if (!targetAdapter || !targetAdapter.isInstalled())
|
|
558
|
+
return undefined;
|
|
559
|
+
const cwd = this.cwdBySession.get(sessionId) ?? process.cwd();
|
|
560
|
+
// P2 — build handoff context for continuity across agent swap
|
|
561
|
+
const handoff = buildFailoverHandoff(cwd, {
|
|
562
|
+
failedAgent: session.agent,
|
|
563
|
+
failedPrompt: session.prompt,
|
|
564
|
+
});
|
|
565
|
+
const promptWithHandoff = handoff
|
|
566
|
+
? enrichPromptWithHandoff(session.prompt, {
|
|
567
|
+
packId: handoff.packId,
|
|
568
|
+
agent: session.agent,
|
|
569
|
+
prompt: session.prompt,
|
|
570
|
+
status: 'failed',
|
|
571
|
+
timestamp: handoff.timestamp,
|
|
572
|
+
nodeCount: handoff.nodeCount,
|
|
573
|
+
edgeCount: handoff.edgeCount,
|
|
574
|
+
task: handoff.task,
|
|
575
|
+
payloadPreview: handoff.payloadPreview,
|
|
576
|
+
})
|
|
577
|
+
: session.prompt;
|
|
578
|
+
const newSession = targetAdapter.spawn(promptWithHandoff, {
|
|
579
|
+
cwd,
|
|
580
|
+
env: process.env,
|
|
581
|
+
preferSubscription: true,
|
|
582
|
+
});
|
|
583
|
+
// Track the new session
|
|
584
|
+
this.sessions.set(newSession.sessionId, newSession);
|
|
585
|
+
this.spawnOrder.push(newSession.sessionId);
|
|
586
|
+
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
587
|
+
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
588
|
+
// adapter exits synchronously.
|
|
589
|
+
const unsubscribe = newSession.subscribe((event) => {
|
|
590
|
+
if (event.type !== 'exit')
|
|
591
|
+
return;
|
|
592
|
+
const data = event.data;
|
|
593
|
+
const completedAt = Date.now();
|
|
594
|
+
this.completedAtBySession.set(newSession.sessionId, completedAt);
|
|
595
|
+
appendCompleted({
|
|
596
|
+
id: newSession.sessionId,
|
|
597
|
+
status: newSession.status,
|
|
598
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
599
|
+
completedAt,
|
|
600
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
601
|
+
artifacts: newSession.artifacts,
|
|
602
|
+
});
|
|
603
|
+
mirrorCompletedToThread({
|
|
604
|
+
sessionId: newSession.sessionId,
|
|
605
|
+
cwd,
|
|
606
|
+
status: newSession.status,
|
|
607
|
+
durationMs: Number(data?.durationMs ?? completedAt - newSession.startedAt),
|
|
608
|
+
completedAt,
|
|
609
|
+
steps: newSession.steps ? [...newSession.steps] : undefined,
|
|
610
|
+
artifacts: newSession.artifacts,
|
|
611
|
+
});
|
|
612
|
+
const reportInput = {
|
|
613
|
+
sessionId: newSession.sessionId,
|
|
614
|
+
prompt: newSession.prompt,
|
|
615
|
+
agent: newSession.agent,
|
|
616
|
+
status: newSession.status,
|
|
617
|
+
durationMs: completedAt - newSession.startedAt,
|
|
618
|
+
steps: newSession.steps ? [...newSession.steps] : [],
|
|
619
|
+
artifacts: newSession.artifacts,
|
|
620
|
+
};
|
|
621
|
+
generateFinalReport(reportInput)
|
|
622
|
+
.then((report) => {
|
|
623
|
+
if (report) {
|
|
624
|
+
this.finalReportBySession.set(newSession.sessionId, report);
|
|
625
|
+
appendFinalReport({
|
|
626
|
+
id: newSession.sessionId,
|
|
627
|
+
summary: report.summary,
|
|
628
|
+
generatedAt: report.generatedAt,
|
|
629
|
+
durationMs: report.durationMs,
|
|
630
|
+
});
|
|
631
|
+
try {
|
|
632
|
+
printFinalReportToTerminal(reportInput, report);
|
|
633
|
+
}
|
|
634
|
+
catch { /* defensive */ }
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
.catch(() => { });
|
|
638
|
+
// P1 — persist context pack at accept-failover replacement session exit.
|
|
639
|
+
void Promise.resolve().then(() => {
|
|
640
|
+
try {
|
|
641
|
+
const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
|
|
642
|
+
const pack = compileSessionPackSync(cwd, {
|
|
643
|
+
prompt: newSession.prompt,
|
|
644
|
+
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
645
|
+
steps: stepsStr,
|
|
646
|
+
agent: newSession.agent,
|
|
647
|
+
});
|
|
648
|
+
if (pack)
|
|
649
|
+
persistContextPack(pack, cwd);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// Silent degradation — context pack is enrichment, not primary path.
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
const t = this.narrationTimers.get(newSession.sessionId);
|
|
656
|
+
if (t) {
|
|
657
|
+
clearInterval(t);
|
|
658
|
+
this.narrationTimers.delete(newSession.sessionId);
|
|
659
|
+
}
|
|
660
|
+
this.evictOldCompleted();
|
|
661
|
+
unsubscribe();
|
|
662
|
+
});
|
|
663
|
+
// Persistence + threading — safe after subscribe because the exit
|
|
664
|
+
// handler is already registered.
|
|
665
|
+
this.continuedAsBySession.set(sessionId, newSession.sessionId);
|
|
666
|
+
this.continuedFromBySession.set(newSession.sessionId, sessionId);
|
|
667
|
+
appendSpawned({
|
|
668
|
+
id: newSession.sessionId,
|
|
669
|
+
agent: newSession.agent,
|
|
670
|
+
prompt: promptWithHandoff,
|
|
671
|
+
cwd,
|
|
672
|
+
startedAt: newSession.startedAt,
|
|
673
|
+
});
|
|
674
|
+
mirrorSpawnedToThread({
|
|
675
|
+
sessionId: newSession.sessionId,
|
|
676
|
+
agent: newSession.agent,
|
|
677
|
+
prompt: promptWithHandoff,
|
|
678
|
+
cwd,
|
|
679
|
+
startedAt: newSession.startedAt,
|
|
680
|
+
});
|
|
681
|
+
mirrorFailoverToThread({
|
|
682
|
+
sessionId: newSession.sessionId,
|
|
683
|
+
fromAgent: session.agent,
|
|
684
|
+
toAgent: targetAgent,
|
|
685
|
+
reason: this.failoverReasonBySession.get(session.sessionId) ?? 'error',
|
|
686
|
+
cwd,
|
|
687
|
+
});
|
|
688
|
+
this.startNarrationTimer(newSession);
|
|
689
|
+
return newSession;
|
|
690
|
+
}
|
|
270
691
|
/** v0.6 M3 — periodic narration loop per session. */
|
|
271
692
|
startNarrationTimer(session) {
|
|
272
693
|
if (process.env.CODEHERE_DISABLE_NARRATOR === '1')
|
|
@@ -320,16 +741,61 @@ export class SessionManager {
|
|
|
320
741
|
getCwd(sessionId) {
|
|
321
742
|
return this.cwdBySession.get(sessionId);
|
|
322
743
|
}
|
|
744
|
+
/** Session threading — which session did this one continue from (after failover)? */
|
|
745
|
+
getContinuedFrom(sessionId) {
|
|
746
|
+
return this.continuedFromBySession.get(sessionId);
|
|
747
|
+
}
|
|
748
|
+
/** Session threading — which session did this one continue as (after failover)? */
|
|
749
|
+
getContinuedAs(sessionId) {
|
|
750
|
+
return this.continuedAsBySession.get(sessionId);
|
|
751
|
+
}
|
|
323
752
|
/** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
753
|
+
* Returns undefined while generation is in flight OR if the provider chain
|
|
754
|
+
* failed (no claude-free, no claude, no credit, etc). The web UI reads this
|
|
755
|
+
* through the /api/tasks serializer to render the prose summary inline. */
|
|
327
756
|
getFinalReport(sessionId) {
|
|
328
757
|
return this.finalReportBySession.get(sessionId);
|
|
329
758
|
}
|
|
759
|
+
/** Returns true if Final Report generation explicitly failed for this session.
|
|
760
|
+
* Lets the API distinguish "not yet" from "never coming". */
|
|
761
|
+
hasFailedReport(sessionId) {
|
|
762
|
+
return this.failedReportBySession.has(sessionId);
|
|
763
|
+
}
|
|
330
764
|
get(sessionId) {
|
|
331
765
|
return this.sessions.get(sessionId);
|
|
332
766
|
}
|
|
767
|
+
/** Evict the oldest completed sessions when the in-memory cap is reached.
|
|
768
|
+
* Prevents unbounded memory growth in the internal tracking maps. The
|
|
769
|
+
* data survives on disk via sessions.jsonl + thread.jsonl. */
|
|
770
|
+
evictOldCompleted() {
|
|
771
|
+
const completed = [];
|
|
772
|
+
for (const id of this.spawnOrder) {
|
|
773
|
+
const at = this.completedAtBySession.get(id);
|
|
774
|
+
if (at !== undefined)
|
|
775
|
+
completed.push({ id, completedAt: at });
|
|
776
|
+
}
|
|
777
|
+
if (completed.length <= SessionManager.MAX_COMPLETED_SESSIONS)
|
|
778
|
+
return;
|
|
779
|
+
const toEvict = completed.slice(0, completed.length - SessionManager.MAX_COMPLETED_SESSIONS);
|
|
780
|
+
const evictSet = new Set(toEvict.map((e) => e.id));
|
|
781
|
+
for (const id of evictSet) {
|
|
782
|
+
const t = this.narrationTimers.get(id);
|
|
783
|
+
if (t) {
|
|
784
|
+
clearInterval(t);
|
|
785
|
+
this.narrationTimers.delete(id);
|
|
786
|
+
}
|
|
787
|
+
this.sessions.delete(id);
|
|
788
|
+
this.completedAtBySession.delete(id);
|
|
789
|
+
this.cwdBySession.delete(id);
|
|
790
|
+
this.finalReportBySession.delete(id);
|
|
791
|
+
this.narrationBySession.delete(id);
|
|
792
|
+
this.failoverReasonBySession.delete(id);
|
|
793
|
+
this.continuedAsBySession.delete(id);
|
|
794
|
+
this.continuedFromBySession.delete(id);
|
|
795
|
+
this.failedReportBySession.delete(id);
|
|
796
|
+
this.failoverTriedBySession.delete(id);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
333
799
|
list() {
|
|
334
800
|
return Array.from(this.sessions.values());
|
|
335
801
|
}
|
|
@@ -340,6 +806,17 @@ export class SessionManager {
|
|
|
340
806
|
session.kill();
|
|
341
807
|
return true;
|
|
342
808
|
}
|
|
809
|
+
/** Graceful shutdown: kill all running sessions and clear all timers. */
|
|
810
|
+
shutdown() {
|
|
811
|
+
for (const [id, timer] of this.narrationTimers) {
|
|
812
|
+
clearInterval(timer);
|
|
813
|
+
}
|
|
814
|
+
this.narrationTimers.clear();
|
|
815
|
+
for (const session of this.sessions.values()) {
|
|
816
|
+
if (session.status === 'running')
|
|
817
|
+
session.kill();
|
|
818
|
+
}
|
|
819
|
+
}
|
|
343
820
|
/**
|
|
344
821
|
* Active Claude Code sessions on this machine, derived from the audit log.
|
|
345
822
|
* A session is "active" if any tool call from it was recorded within the
|
|
@@ -465,5 +942,19 @@ export class SessionManager {
|
|
|
465
942
|
static getInstance() {
|
|
466
943
|
return (this._instance ??= new SessionManager());
|
|
467
944
|
}
|
|
945
|
+
/** @internal Resets the singleton for test isolation. */
|
|
946
|
+
static __testOnlyReset() {
|
|
947
|
+
this._instance = undefined;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
// ── Test seam ─────────────────────────────────────────────────────────────────
|
|
951
|
+
// Avoids refactoring the module-level REGISTRY const to DI. Usage:
|
|
952
|
+
// import { __testOnlyRegisterAdapter, SessionManager } from '...';
|
|
953
|
+
// __testOnlyRegisterAdapter(mockAdapter);
|
|
954
|
+
// SessionManager.__testOnlyReset();
|
|
955
|
+
// const sm = SessionManager.getInstance();
|
|
956
|
+
/** @internal Exported for testing only — allows mock adapter injection. */
|
|
957
|
+
export function __testOnlyRegisterAdapter(adapter) {
|
|
958
|
+
REGISTRY.push(adapter);
|
|
468
959
|
}
|
|
469
960
|
//# sourceMappingURL=session-manager.js.map
|