codehere 0.6.0 → 0.6.2
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/application/use-cases/generate-final-report.js +57 -2
- package/dist/application/use-cases/generate-narration.js +9 -3
- package/dist/index.js +34 -4
- package/dist/infrastructure/cost/budget-config.js +1 -1
- package/dist/infrastructure/cost/spend-aggregator.js +5 -5
- package/dist/infrastructure/hooks/claude-sdk-hook.js +48 -2
- package/dist/infrastructure/orchestration/codex-adapter.js +11 -6
- package/dist/infrastructure/orchestration/context-handoff.js +1 -1
- package/dist/infrastructure/orchestration/gemini-adapter.js +3 -15
- package/dist/infrastructure/orchestration/graphify-context.js +6 -13
- package/dist/infrastructure/orchestration/memory-injector.js +11 -16
- package/dist/infrastructure/orchestration/opencode-adapter.js +42 -1
- package/dist/infrastructure/orchestration/progress-aggregator.js +1 -2
- package/dist/infrastructure/orchestration/session-manager.js +178 -76
- package/dist/infrastructure/protocols/cloud-sync.js +9 -5
- package/dist/infrastructure/protocols/mcp-server-impl.js +368 -334
- package/dist/infrastructure/storage/cross-tool-agents.js +19 -2
- package/dist/infrastructure/storage/project-thread-migration.js +12 -7
- package/dist/infrastructure/storage/project-thread-reader.js +11 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +6 -2
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/init-command.js +5 -4
- package/dist/presentation/cli/commands/memory-command.js +29 -15
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
- package/dist/presentation/cli/repl.js +20 -1
- package/dist/ui.js +36 -28
- package/dist/utils/typo-guard.js +1 -1
- package/package.json +2 -1
|
@@ -22,14 +22,14 @@ import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js'
|
|
|
22
22
|
import { buildConductorAdapter } from './conductor-adapter.js';
|
|
23
23
|
import { seedAgentsConfig } from './agents-config-seed.js';
|
|
24
24
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
25
|
-
import { appendSpawned, appendCompleted, appendFinalReport, loadRecent } from './session-persistence.js';
|
|
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
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
31
|
import { classifyFailoverEligibility } from './failover-classifier.js';
|
|
32
|
-
import { resolveFailoverTarget, toAvailability
|
|
32
|
+
import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
|
|
33
33
|
import { loadFailoverConfig, resolveChain } from './failover-config.js';
|
|
34
34
|
import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
|
|
35
35
|
import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
|
|
@@ -39,7 +39,13 @@ import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
|
|
|
39
39
|
// order. Built-in adapters first; user-config-driven ones appended from
|
|
40
40
|
// ~/.codehere/agents.yaml (generic-cli-adapter). isInstalled() gates visibility
|
|
41
41
|
// so an adapter registering for an un-installed CLI is harmless.
|
|
42
|
-
const BUILTIN_REGISTRY = [
|
|
42
|
+
const BUILTIN_REGISTRY = [
|
|
43
|
+
ClaudeCodeAdapter,
|
|
44
|
+
CodexAdapter,
|
|
45
|
+
AiderAdapter,
|
|
46
|
+
OpenCodeAdapter,
|
|
47
|
+
GeminiAdapter,
|
|
48
|
+
];
|
|
43
49
|
// Seed the default agents.yaml if this user has never configured one, so Gemini
|
|
44
50
|
// / Cursor / Pi / Antigravity / Goose appear in the registry pre-wired. They
|
|
45
51
|
// stay hidden in the UI until isInstalled() flips to true. Idempotent.
|
|
@@ -108,7 +114,14 @@ function parseEtime(s) {
|
|
|
108
114
|
return 0;
|
|
109
115
|
}
|
|
110
116
|
export class SessionManager {
|
|
117
|
+
/** Max completed sessions kept in memory. Older sessions are evicted to
|
|
118
|
+
* prevent unbounded memory growth in the internal tracking maps. The data
|
|
119
|
+
* survives on disk via sessions.jsonl + thread.jsonl. */
|
|
120
|
+
static MAX_COMPLETED_SESSIONS = 100;
|
|
111
121
|
sessions = new Map();
|
|
122
|
+
/** Spawn order FIFO — used to evict oldest completed sessions when the cap
|
|
123
|
+
* is reached. Pushed on spawn, referenced by evictOldCompleted(). */
|
|
124
|
+
spawnOrder = [];
|
|
112
125
|
/** Read-only sessions replayed from ~/.codehere/sessions.jsonl on daemon start.
|
|
113
126
|
* These subprocesses are long-dead; we surface them so the user doesn't lose
|
|
114
127
|
* their Final reports across restarts. */
|
|
@@ -142,16 +155,16 @@ export class SessionManager {
|
|
|
142
155
|
* reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
|
|
143
156
|
failoverOffers;
|
|
144
157
|
/** Tracks the failover error code per session so acceptFailover can emit
|
|
145
|
-
|
|
158
|
+
* the correct reason in the thread event. Populated by handleFailover. */
|
|
146
159
|
failoverReasonBySession = new Map();
|
|
147
160
|
/** Session threading — tracks which session continued as which after failover.
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
* continuedAsBySession: original → new session ID
|
|
162
|
+
* continuedFromBySession: new → original session ID */
|
|
150
163
|
continuedAsBySession = new Map();
|
|
151
164
|
continuedFromBySession = new Map();
|
|
152
165
|
/** Final Report failure tracking — when generateFinalReport rejects, we
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
* record it so the API can return a definitive "failed" status instead
|
|
167
|
+
* of leaving the UI showing "Generating report…" forever. */
|
|
155
168
|
failedReportBySession = new Set();
|
|
156
169
|
spawn(prompt, agentId = 'claude-code', options) {
|
|
157
170
|
const adapter = getAdapter(agentId);
|
|
@@ -187,6 +200,7 @@ export class SessionManager {
|
|
|
187
200
|
const enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
|
|
188
201
|
const session = adapter.spawn(enrichedPrompt, options);
|
|
189
202
|
this.sessions.set(session.sessionId, session);
|
|
203
|
+
this.spawnOrder.push(session.sessionId);
|
|
190
204
|
// Persistence: write the spawned record immediately so a daemon crash still
|
|
191
205
|
// leaves an audit trail of what was attempted. Subscribe to exit so we can
|
|
192
206
|
// capture the final status + steps + artifacts when the session settles.
|
|
@@ -297,7 +311,10 @@ export class SessionManager {
|
|
|
297
311
|
// or pack compilation may fail. All degrade silently.
|
|
298
312
|
void Promise.resolve().then(() => {
|
|
299
313
|
try {
|
|
300
|
-
const stepsStr = session.steps
|
|
314
|
+
const stepsStr = session.steps
|
|
315
|
+
?.map((s) => `${s.label}: ${s.output || s.state}`)
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.join('\n') ?? '';
|
|
301
318
|
const pack = compileSessionPackSync(cwd, {
|
|
302
319
|
prompt: session.prompt,
|
|
303
320
|
artifacts: JSON.stringify(session.artifacts, null, 2),
|
|
@@ -317,6 +334,7 @@ export class SessionManager {
|
|
|
317
334
|
clearInterval(t);
|
|
318
335
|
this.narrationTimers.delete(session.sessionId);
|
|
319
336
|
}
|
|
337
|
+
this.evictOldCompleted();
|
|
320
338
|
unsubscribe();
|
|
321
339
|
});
|
|
322
340
|
// v0.6 M3 — PM Narrator. Mid-task plain-English summaries fired
|
|
@@ -388,29 +406,10 @@ export class SessionManager {
|
|
|
388
406
|
});
|
|
389
407
|
// Track this new session in the same way
|
|
390
408
|
this.sessions.set(newSession.sessionId, newSession);
|
|
409
|
+
this.spawnOrder.push(newSession.sessionId);
|
|
391
410
|
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
411
|
+
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
412
|
+
// adapter exits synchronously (command not found, instant crash, etc).
|
|
414
413
|
const unsubscribe = newSession.subscribe((event) => {
|
|
415
414
|
if (event.type !== 'exit')
|
|
416
415
|
return;
|
|
@@ -456,14 +455,21 @@ export class SessionManager {
|
|
|
456
455
|
try {
|
|
457
456
|
printFinalReportToTerminal(reportInput, report);
|
|
458
457
|
}
|
|
459
|
-
catch {
|
|
458
|
+
catch {
|
|
459
|
+
/* defensive */
|
|
460
|
+
}
|
|
460
461
|
}
|
|
461
462
|
})
|
|
462
|
-
.catch(() => {
|
|
463
|
+
.catch(() => {
|
|
464
|
+
/* defensive */
|
|
465
|
+
});
|
|
463
466
|
// P1 — persist context pack at failover-replacement session exit.
|
|
464
467
|
void Promise.resolve().then(() => {
|
|
465
468
|
try {
|
|
466
|
-
const stepsStr = newSession.steps
|
|
469
|
+
const stepsStr = newSession.steps
|
|
470
|
+
?.map((s) => `${s.label}: ${s.output || s.state}`)
|
|
471
|
+
.filter(Boolean)
|
|
472
|
+
.join('\n') ?? '';
|
|
467
473
|
const pack = compileSessionPackSync(cwd, {
|
|
468
474
|
prompt: newSession.prompt,
|
|
469
475
|
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
@@ -482,8 +488,36 @@ export class SessionManager {
|
|
|
482
488
|
clearInterval(t);
|
|
483
489
|
this.narrationTimers.delete(newSession.sessionId);
|
|
484
490
|
}
|
|
491
|
+
this.evictOldCompleted();
|
|
485
492
|
unsubscribe();
|
|
486
493
|
});
|
|
494
|
+
// Persistence + threading — safe after subscribe because the exit
|
|
495
|
+
// handler is already registered. These can run even if the session
|
|
496
|
+
// already exited (spawned record still useful, exit handler handles
|
|
497
|
+
// the completion).
|
|
498
|
+
appendSpawned({
|
|
499
|
+
id: newSession.sessionId,
|
|
500
|
+
agent: newSession.agent,
|
|
501
|
+
prompt: promptWithHandoff,
|
|
502
|
+
cwd,
|
|
503
|
+
startedAt: newSession.startedAt,
|
|
504
|
+
});
|
|
505
|
+
mirrorSpawnedToThread({
|
|
506
|
+
sessionId: newSession.sessionId,
|
|
507
|
+
agent: newSession.agent,
|
|
508
|
+
prompt: promptWithHandoff,
|
|
509
|
+
cwd,
|
|
510
|
+
startedAt: newSession.startedAt,
|
|
511
|
+
});
|
|
512
|
+
mirrorFailoverToThread({
|
|
513
|
+
sessionId: newSession.sessionId,
|
|
514
|
+
fromAgent: signal.fromAgent,
|
|
515
|
+
toAgent: target,
|
|
516
|
+
reason: signal.errorCode,
|
|
517
|
+
cwd,
|
|
518
|
+
});
|
|
519
|
+
this.continuedAsBySession.set(session.sessionId, newSession.sessionId);
|
|
520
|
+
this.continuedFromBySession.set(newSession.sessionId, session.sessionId);
|
|
487
521
|
this.startNarrationTimer(newSession);
|
|
488
522
|
return newSession;
|
|
489
523
|
}
|
|
@@ -501,13 +535,12 @@ export class SessionManager {
|
|
|
501
535
|
/** Emitted when a failover offer is generated. The API layer listens
|
|
502
536
|
* to this to surface the banner in the web UI. */
|
|
503
537
|
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
538
|
if (!this.failoverOffers) {
|
|
507
539
|
this.failoverOffers = new Map();
|
|
508
540
|
}
|
|
509
541
|
const key = `${offer.session.sessionId}-${offer.target}`;
|
|
510
|
-
|
|
542
|
+
const expiresAt = Date.now() + 60_000;
|
|
543
|
+
this.failoverOffers.set(key, { offer, createdAt: Date.now() });
|
|
511
544
|
// Auto-expire offers after 60 seconds to keep the map bounded
|
|
512
545
|
setTimeout(() => {
|
|
513
546
|
this.failoverOffers?.delete(key);
|
|
@@ -518,20 +551,20 @@ export class SessionManager {
|
|
|
518
551
|
if (!this.failoverOffers)
|
|
519
552
|
return [];
|
|
520
553
|
const offers = [];
|
|
521
|
-
for (const [key, offer] of this.failoverOffers) {
|
|
554
|
+
for (const [key, { offer, createdAt }] of this.failoverOffers) {
|
|
522
555
|
offers.push({
|
|
523
556
|
sessionId: offer.session.sessionId,
|
|
524
557
|
signal: offer.signal,
|
|
525
558
|
target: offer.target,
|
|
526
|
-
expiresAt:
|
|
559
|
+
expiresAt: createdAt + 60_000,
|
|
527
560
|
});
|
|
528
561
|
}
|
|
529
562
|
return offers;
|
|
530
563
|
}
|
|
531
564
|
/** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
565
|
+
* session on the target agent, preserving the original prompt + cwd.
|
|
566
|
+
* P2 — injects handoff context for cross-agent continuity.
|
|
567
|
+
* Returns the new session or undefined if the target is unavailable. */
|
|
535
568
|
acceptFailover(sessionId, targetAgent) {
|
|
536
569
|
const session = this.sessions.get(sessionId);
|
|
537
570
|
if (!session)
|
|
@@ -565,32 +598,10 @@ export class SessionManager {
|
|
|
565
598
|
});
|
|
566
599
|
// Track the new session
|
|
567
600
|
this.sessions.set(newSession.sessionId, newSession);
|
|
601
|
+
this.spawnOrder.push(newSession.sessionId);
|
|
568
602
|
this.cwdBySession.set(newSession.sessionId, cwd);
|
|
569
|
-
//
|
|
570
|
-
|
|
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
|
|
603
|
+
// Subscribe BEFORE persistence so exit events are never lost if the
|
|
604
|
+
// adapter exits synchronously.
|
|
594
605
|
const unsubscribe = newSession.subscribe((event) => {
|
|
595
606
|
if (event.type !== 'exit')
|
|
596
607
|
return;
|
|
@@ -636,14 +647,21 @@ export class SessionManager {
|
|
|
636
647
|
try {
|
|
637
648
|
printFinalReportToTerminal(reportInput, report);
|
|
638
649
|
}
|
|
639
|
-
catch {
|
|
650
|
+
catch {
|
|
651
|
+
/* defensive */
|
|
652
|
+
}
|
|
640
653
|
}
|
|
641
654
|
})
|
|
642
|
-
.catch(() => {
|
|
655
|
+
.catch(() => {
|
|
656
|
+
/* defensive */
|
|
657
|
+
});
|
|
643
658
|
// P1 — persist context pack at accept-failover replacement session exit.
|
|
644
659
|
void Promise.resolve().then(() => {
|
|
645
660
|
try {
|
|
646
|
-
const stepsStr = newSession.steps
|
|
661
|
+
const stepsStr = newSession.steps
|
|
662
|
+
?.map((s) => `${s.label}: ${s.output || s.state}`)
|
|
663
|
+
.filter(Boolean)
|
|
664
|
+
.join('\n') ?? '';
|
|
647
665
|
const pack = compileSessionPackSync(cwd, {
|
|
648
666
|
prompt: newSession.prompt,
|
|
649
667
|
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
@@ -662,9 +680,34 @@ export class SessionManager {
|
|
|
662
680
|
clearInterval(t);
|
|
663
681
|
this.narrationTimers.delete(newSession.sessionId);
|
|
664
682
|
}
|
|
665
|
-
this.
|
|
683
|
+
this.evictOldCompleted();
|
|
666
684
|
unsubscribe();
|
|
667
685
|
});
|
|
686
|
+
// Persistence + threading — safe after subscribe because the exit
|
|
687
|
+
// handler is already registered.
|
|
688
|
+
this.continuedAsBySession.set(sessionId, newSession.sessionId);
|
|
689
|
+
this.continuedFromBySession.set(newSession.sessionId, sessionId);
|
|
690
|
+
appendSpawned({
|
|
691
|
+
id: newSession.sessionId,
|
|
692
|
+
agent: newSession.agent,
|
|
693
|
+
prompt: promptWithHandoff,
|
|
694
|
+
cwd,
|
|
695
|
+
startedAt: newSession.startedAt,
|
|
696
|
+
});
|
|
697
|
+
mirrorSpawnedToThread({
|
|
698
|
+
sessionId: newSession.sessionId,
|
|
699
|
+
agent: newSession.agent,
|
|
700
|
+
prompt: promptWithHandoff,
|
|
701
|
+
cwd,
|
|
702
|
+
startedAt: newSession.startedAt,
|
|
703
|
+
});
|
|
704
|
+
mirrorFailoverToThread({
|
|
705
|
+
sessionId: newSession.sessionId,
|
|
706
|
+
fromAgent: session.agent,
|
|
707
|
+
toAgent: targetAgent,
|
|
708
|
+
reason: this.failoverReasonBySession.get(session.sessionId) ?? 'error',
|
|
709
|
+
cwd,
|
|
710
|
+
});
|
|
668
711
|
this.startNarrationTimer(newSession);
|
|
669
712
|
return newSession;
|
|
670
713
|
}
|
|
@@ -698,7 +741,9 @@ export class SessionManager {
|
|
|
698
741
|
if (out)
|
|
699
742
|
this.narrationBySession.set(session.sessionId, out);
|
|
700
743
|
})
|
|
701
|
-
.catch(() => {
|
|
744
|
+
.catch(() => {
|
|
745
|
+
/* defensive — generateNarration never throws */
|
|
746
|
+
});
|
|
702
747
|
}, intervalMs);
|
|
703
748
|
this.narrationTimers.set(session.sessionId, timer);
|
|
704
749
|
}
|
|
@@ -730,20 +775,52 @@ export class SessionManager {
|
|
|
730
775
|
return this.continuedAsBySession.get(sessionId);
|
|
731
776
|
}
|
|
732
777
|
/** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
778
|
+
* Returns undefined while generation is in flight OR if the provider chain
|
|
779
|
+
* failed (no claude-free, no claude, no credit, etc). The web UI reads this
|
|
780
|
+
* through the /api/tasks serializer to render the prose summary inline. */
|
|
736
781
|
getFinalReport(sessionId) {
|
|
737
782
|
return this.finalReportBySession.get(sessionId);
|
|
738
783
|
}
|
|
739
784
|
/** Returns true if Final Report generation explicitly failed for this session.
|
|
740
|
-
|
|
785
|
+
* Lets the API distinguish "not yet" from "never coming". */
|
|
741
786
|
hasFailedReport(sessionId) {
|
|
742
787
|
return this.failedReportBySession.has(sessionId);
|
|
743
788
|
}
|
|
744
789
|
get(sessionId) {
|
|
745
790
|
return this.sessions.get(sessionId);
|
|
746
791
|
}
|
|
792
|
+
/** Evict the oldest completed sessions when the in-memory cap is reached.
|
|
793
|
+
* Prevents unbounded memory growth in the internal tracking maps. The
|
|
794
|
+
* data survives on disk via sessions.jsonl + thread.jsonl. */
|
|
795
|
+
evictOldCompleted() {
|
|
796
|
+
const completed = [];
|
|
797
|
+
for (const id of this.spawnOrder) {
|
|
798
|
+
const at = this.completedAtBySession.get(id);
|
|
799
|
+
if (at !== undefined)
|
|
800
|
+
completed.push({ id, completedAt: at });
|
|
801
|
+
}
|
|
802
|
+
if (completed.length <= SessionManager.MAX_COMPLETED_SESSIONS)
|
|
803
|
+
return;
|
|
804
|
+
const toEvict = completed.slice(0, completed.length - SessionManager.MAX_COMPLETED_SESSIONS);
|
|
805
|
+
const evictSet = new Set(toEvict.map((e) => e.id));
|
|
806
|
+
for (const id of evictSet) {
|
|
807
|
+
const t = this.narrationTimers.get(id);
|
|
808
|
+
if (t) {
|
|
809
|
+
clearInterval(t);
|
|
810
|
+
this.narrationTimers.delete(id);
|
|
811
|
+
}
|
|
812
|
+
this.sessions.delete(id);
|
|
813
|
+
this.completedAtBySession.delete(id);
|
|
814
|
+
this.cwdBySession.delete(id);
|
|
815
|
+
this.finalReportBySession.delete(id);
|
|
816
|
+
this.narrationBySession.delete(id);
|
|
817
|
+
this.failoverReasonBySession.delete(id);
|
|
818
|
+
this.continuedAsBySession.delete(id);
|
|
819
|
+
this.continuedFromBySession.delete(id);
|
|
820
|
+
this.failedReportBySession.delete(id);
|
|
821
|
+
this.failoverTriedBySession.delete(id);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
747
824
|
list() {
|
|
748
825
|
return Array.from(this.sessions.values());
|
|
749
826
|
}
|
|
@@ -754,6 +831,17 @@ export class SessionManager {
|
|
|
754
831
|
session.kill();
|
|
755
832
|
return true;
|
|
756
833
|
}
|
|
834
|
+
/** Graceful shutdown: kill all running sessions and clear all timers. */
|
|
835
|
+
shutdown() {
|
|
836
|
+
for (const [id, timer] of this.narrationTimers) {
|
|
837
|
+
clearInterval(timer);
|
|
838
|
+
}
|
|
839
|
+
this.narrationTimers.clear();
|
|
840
|
+
for (const session of this.sessions.values()) {
|
|
841
|
+
if (session.status === 'running')
|
|
842
|
+
session.kill();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
757
845
|
/**
|
|
758
846
|
* Active Claude Code sessions on this machine, derived from the audit log.
|
|
759
847
|
* A session is "active" if any tool call from it was recorded within the
|
|
@@ -879,5 +967,19 @@ export class SessionManager {
|
|
|
879
967
|
static getInstance() {
|
|
880
968
|
return (this._instance ??= new SessionManager());
|
|
881
969
|
}
|
|
970
|
+
/** @internal Resets the singleton for test isolation. */
|
|
971
|
+
static __testOnlyReset() {
|
|
972
|
+
this._instance = undefined;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// ── Test seam ─────────────────────────────────────────────────────────────────
|
|
976
|
+
// Avoids refactoring the module-level REGISTRY const to DI. Usage:
|
|
977
|
+
// import { __testOnlyRegisterAdapter, SessionManager } from '...';
|
|
978
|
+
// __testOnlyRegisterAdapter(mockAdapter);
|
|
979
|
+
// SessionManager.__testOnlyReset();
|
|
980
|
+
// const sm = SessionManager.getInstance();
|
|
981
|
+
/** @internal Exported for testing only — allows mock adapter injection. */
|
|
982
|
+
export function __testOnlyRegisterAdapter(adapter) {
|
|
983
|
+
REGISTRY.push(adapter);
|
|
882
984
|
}
|
|
883
985
|
//# sourceMappingURL=session-manager.js.map
|
|
@@ -62,7 +62,7 @@ export function collectSessionMetadata() {
|
|
|
62
62
|
}
|
|
63
63
|
try {
|
|
64
64
|
const content = readFileSync(SESSIONS_FILE, 'utf-8');
|
|
65
|
-
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
65
|
+
const lines = content.split('\n').filter((l) => l.trim().length > 0);
|
|
66
66
|
// Parse records and pair spawned + completed
|
|
67
67
|
const spawnedMap = new Map();
|
|
68
68
|
const completedMap = new Map();
|
|
@@ -89,9 +89,13 @@ export function collectSessionMetadata() {
|
|
|
89
89
|
const completed = completedMap.get(id);
|
|
90
90
|
const finalReport = finalReportMap.get(id);
|
|
91
91
|
const status = completed
|
|
92
|
-
?
|
|
92
|
+
? completed.status === 'completed'
|
|
93
|
+
? 'done'
|
|
94
|
+
: completed.status === 'failed'
|
|
95
|
+
? 'failed'
|
|
96
|
+
: 'done'
|
|
93
97
|
: 'running';
|
|
94
|
-
const durationMs = completed?.durationMs ??
|
|
98
|
+
const durationMs = completed?.durationMs ?? Date.now() - spawned.startedAt;
|
|
95
99
|
const tools = completed?.steps?.length ?? 0;
|
|
96
100
|
const files = completed?.artifacts?.filesChanged?.length ?? 0;
|
|
97
101
|
const trustScore = extractTrustScore(completed?.artifacts);
|
|
@@ -151,7 +155,7 @@ export function startCloudSync(machineName) {
|
|
|
151
155
|
console.error(` Cloud sync failed: ${res.status} ${body}`);
|
|
152
156
|
}
|
|
153
157
|
else {
|
|
154
|
-
const data = await res.json();
|
|
158
|
+
const data = (await res.json());
|
|
155
159
|
if (data.sessionsPushed && data.sessionsPushed > 0) {
|
|
156
160
|
console.log(` Cloud sync: ${data.sessionsPushed} session(s) pushed`);
|
|
157
161
|
}
|
|
@@ -166,7 +170,7 @@ export function startCloudSync(machineName) {
|
|
|
166
170
|
const res = await fetch(`${CLOUD_URL}?action=pull-tasks&machineId=${machineId}`);
|
|
167
171
|
if (!res.ok)
|
|
168
172
|
return;
|
|
169
|
-
const data = await res.json();
|
|
173
|
+
const data = (await res.json());
|
|
170
174
|
const tasks = data.tasks || [];
|
|
171
175
|
for (const task of tasks) {
|
|
172
176
|
if (task.prompt) {
|