codehere 0.6.0 → 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 CHANGED
@@ -5,12 +5,12 @@
5
5
 
6
6
  [![npm](https://img.shields.io/npm/v/codehere)](https://www.npmjs.com/package/codehere)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](../LICENSE)
8
- [![Tests](https://img.shields.io/badge/tests-668%2F668-brightgreen)]()
8
+ [![Tests](https://img.shields.io/badge/tests-1196%2F1196-brightgreen)]()
9
9
  [![Vulnerabilities](https://img.shields.io/badge/vulnerabilities-0-brightgreen)]()
10
10
 
11
11
  **Codehere is the vendor-neutral layer between you and the coding agents you already use** — Claude Code, Codex, Aider, Gemini, OpenCode. One `npm install`. Local-first, OSS, no account, no IAM, no compliance prereqs. Switch agent, keep your memory. End the day with one Final Report covering everything they did.
12
12
 
13
- Today, Claude Code gets full pre-execution safety-hook enforcement on every tool call; the other adapters are session-traced and reported, with per-tool hook parity in progress for v0.6. The vendor-neutral *memory*, *audit log*, and *Final Report* surfaces ship across all 5 adapters today.
13
+ All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode) get session-tracing, Final Reports, and trust scoring. Claude Code additionally gets full pre-execution safety-hook enforcement on every tool call. The *memory*, *audit log*, *failover*, and *Final Report* surfaces ship across all 5 adapters today.
14
14
 
15
15
  The enterprise tier is owned by [OpenAI Frontier](https://openai.com/index/introducing-openai-frontier/) (8-14 weeks of deploy prereqs, sales-team gated) and [Anthropic Cowork](https://www.anthropic.com/product/claude-cowork) (paid Claude subscription, single-vendor). Codehere fills the gap they structurally can't reach: **the individual / small-team / no-IT-department tier, vendor-neutral**.
16
16
 
@@ -29,7 +29,7 @@ One layer, every agent, your work stays portable.
29
29
 
30
30
  | | | |
31
31
  |---|---|---|
32
- | 1 | **Install** | `npm install -g codehere@alpha` (Node 20+) — current alpha track is v0.5; `@latest` still on v0.4 |
32
+ | 1 | **Install** | `npm install -g codehere` (Node 20+) — v0.6.0 is the current stable |
33
33
  | 2 | **Detect environment** | `codehere setup` — checks API key, finds your repo |
34
34
  | 3 | **Install the safety hook** | `codehere hook install` — every Claude Code tool call now audited |
35
35
  | 4 | **Install at least one specialist** | Claude Code: `npm i -g @anthropic-ai/claude-code` (Codex / Aider / OpenCode / Gemini also supported · see [docs/integrations/](../docs/integrations/) for Cursor MCP setup) |
@@ -51,7 +51,7 @@ Context + End State + Constraints
51
51
 
52
52
  ### Folder convention (what `codehere init` writes)
53
53
 
54
- The v0.5 layout — F3 project memory:
54
+ The v0.6 layout — F3 project memory:
55
55
 
56
56
  | Path | Purpose | Committed? |
57
57
  |---|---|---|
@@ -90,9 +90,9 @@ codehere report # generates a shareable markdown audit report
90
90
  codehere status # quick dashboard · sessions, blocks, trust trends
91
91
  ```
92
92
 
93
- The hook intercepts every `Bash`, `Edit`, `Write`, `MultiEdit`, and `NotebookEdit` call from any Claude Code session running on your machine, runs it through the trust-scorer pattern bank in under 5 ms, blocks anything matching the destructive / credential / injection patterns, and appends every decision to an append-only JSONL audit log at `~/.codehere/audit/`. (Hook coverage today is Claude Code-only — Codex / Aider / OpenCode adapters spawn through their native CLIs and emit to `~/.codehere/sessions.jsonl`; full hook parity across all adapters is v0.4 work.)
93
+ The hook intercepts every `Bash`, `Edit`, `Write`, `MultiEdit`, and `NotebookEdit` call from any Claude Code session running on your machine, runs it through the trust-scorer pattern bank in under 5 ms, blocks anything matching the destructive / credential / injection patterns, and appends every decision to an append-only JSONL audit log at `~/.codehere/audit/`. Codex, Aider, OpenCode, and Gemini adapters spawn through their native CLIs and emit to `~/.codehere/sessions.jsonl` with per-tool tracing via the cross-vendor mirror hook.
94
94
 
95
- ## Memory · F3 (v0.5)
95
+ ## Memory · F3 (v0.6)
96
96
 
97
97
  Once you've run `codehere init` in a project, every spawned agent — Claude Code, Codex, Aider, OpenCode, or Gemini — automatically reads the project's memory on entry. The agent starts warm with the briefing, recent lessons, and the most-referenced symbols in your codebase, instead of cold-grepping every session.
98
98
 
@@ -124,7 +124,7 @@ codehere memory show protocols # safety patterns by category
124
124
 
125
125
  **Other memory commands:**
126
126
  - `codehere memory init` — migrate the v0.4.1 layout (`CONTEXT.md` / `HANDOVER.md` / `PROMPTS.md`) into the F3 layout (one shot, leaves back-compat symlinks; `--dry-run` previews)
127
- - `codehere memory repo-map [--rebuild]` — build the symbol map; v0.5 ships a TS-native MVP, v0.6 swaps in tree-sitter
127
+ - `codehere memory repo-map [--rebuild]` — build the symbol map; currently TS-native MVP
128
128
  - `codehere protocols export [--rebuild]` — write the trust-scorer pattern bank to `.codehere/protocols/hook_patterns.json` for inspection / cross-tool ingestion
129
129
 
130
130
  ## Going deeper
@@ -204,7 +204,7 @@ For depth beyond this README:
204
204
  ## Requirements
205
205
 
206
206
  - **Node.js 20+** (pinned in `.nvmrc` and `engines.node`)
207
- - **macOS or Linux** · Windows via WSL works for development; native Windows is on the v0.4 roadmap
207
+ - **macOS or Linux** · Windows via WSL works for development
208
208
  - **Optional but recommended:** at least one supported agent (Claude Code, Codex, Aider, OpenCode, or Gemini) installed before running `codehere start`
209
209
 
210
210
  ## License
package/dist/index.js CHANGED
@@ -15,6 +15,16 @@ import { config } from 'dotenv';
15
15
  import { join, dirname } from 'path';
16
16
  import { fileURLToPath } from 'url';
17
17
  import { homedir } from 'os';
18
+ // ── Global crash safety ───────────────────────────────────────────────────
19
+ // Unhandled rejections and exceptions must never silently kill the daemon.
20
+ process.on('unhandledRejection', (reason) => {
21
+ console.error('[codehere] unhandledRejection:', reason instanceof Error ? reason.stack : reason);
22
+ });
23
+ process.on('uncaughtException', (err) => {
24
+ console.error('[codehere] uncaughtException:', err.stack);
25
+ // persist what we can before dying
26
+ process.exitCode = 1;
27
+ });
18
28
  import { executeSetupCommand } from './presentation/cli/commands/setup-command.js';
19
29
  import { executeInitCommand } from './presentation/cli/commands/init-command.js';
20
30
  import { executeMemoryCommand } from './presentation/cli/commands/memory-command.js';
@@ -464,6 +474,7 @@ program
464
474
  console.log(colors.dim('\ncodehere MCP server stopped'));
465
475
  process.exit(0);
466
476
  });
477
+ process.on('SIGTERM', () => process.exit(0));
467
478
  await new Promise((resolve) => {
468
479
  server.on('close', resolve);
469
480
  });
@@ -565,15 +576,30 @@ program
565
576
  startCloudSync();
566
577
  }
567
578
  await openBrowser();
568
- process.on('SIGINT', () => {
579
+ const shutdown = () => {
580
+ try {
581
+ const { SessionManager } = require('./infrastructure/orchestration/session-manager.js');
582
+ SessionManager.getInstance().shutdown();
583
+ }
584
+ catch { /* graceful degradation — daemon may not have started fully */ }
569
585
  if (cloud) {
570
- const { stopCloudSync } = require('./infrastructure/protocols/cloud-sync.js');
571
- stopCloudSync();
586
+ try {
587
+ const { stopCloudSync } = require('./infrastructure/protocols/cloud-sync.js');
588
+ stopCloudSync();
589
+ }
590
+ catch { /* cloud sync may not be loaded */ }
572
591
  }
573
592
  server.close();
593
+ };
594
+ process.on('SIGINT', () => {
595
+ shutdown();
574
596
  console.log(colors.dim('\ncodehere stopped'));
575
597
  process.exit(0);
576
598
  });
599
+ process.on('SIGTERM', () => {
600
+ shutdown();
601
+ process.exit(0);
602
+ });
577
603
  await new Promise((resolve) => {
578
604
  server.on('close', resolve);
579
605
  });
@@ -13,13 +13,20 @@
13
13
  *
14
14
  * Docs: https://docs.anthropic.com/en/docs/claude-code/hooks
15
15
  */
16
- import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readSync, statSync, readFileSync, } from 'fs';
16
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readSync, statSync, readFileSync, unlinkSync, } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { homedir } from 'os';
19
19
  import { createHash } from 'crypto';
20
20
  // P18 slice 2 — Tier 1 hook honors per-cwd permissions.yaml.
21
21
  import { filterAllowed } from '../security/permissions-config.js';
22
- const PATTERNS = [
22
+ /**
23
+ * Instruction-level scan pattern bank used by `scanPayload()`. Exported so
24
+ * docs and the landing page can quote `PATTERNS.length` instead of hardcoding
25
+ * a number that silently drifts (May 2026 audit found 50 / 47 / 60+ all in
26
+ * the same PRD). Pair this with `CODE_PATTERNS` from `trust-scorer.ts` — the
27
+ * two banks are scanned in different passes and counted separately.
28
+ */
29
+ export const PATTERNS = [
23
30
  // Prompt Injection
24
31
  // Allow chained modifiers: "ignore all previous instructions", "ignore all your prior rules", etc.
25
32
  {
@@ -494,6 +501,38 @@ function readChainTip(filepath) {
494
501
  return null;
495
502
  }
496
503
  export function appendAuditLog(entry) {
504
+ // Cross-process file locking. Multiple Claude Code sessions (e.g., two
505
+ // terminals, or IDE + terminal) can trigger PreToolUse concurrently.
506
+ // Without a lock, two hook processes read the same chain tip and both
507
+ // write seq 43 — corrupting the hash chain. We use an exclusive-create
508
+ // lock file (+O_EXCL) which is atomic on all POSIX systems.
509
+ const LOCK_FILE = AUDIT_FILE + '.lock';
510
+ const lockAcquired = () => {
511
+ for (let attempt = 0; attempt < 20; attempt++) {
512
+ try {
513
+ const fd = openSync(LOCK_FILE, 'wx');
514
+ closeSync(fd);
515
+ return true;
516
+ }
517
+ catch {
518
+ // Lock held by another process — spin-wait ~10ms
519
+ const deadline = Date.now() + 10;
520
+ while (Date.now() < deadline) { /* busy-wait — sync context */ }
521
+ }
522
+ }
523
+ return false;
524
+ };
525
+ const lockReleased = () => {
526
+ try {
527
+ unlinkSync(LOCK_FILE);
528
+ }
529
+ catch { /* best effort */ }
530
+ };
531
+ if (!lockAcquired()) {
532
+ // Fall through unlocked rather than drop the entry. The entry still
533
+ // appends; verification may flag a chain break, but the audit data
534
+ // is preserved.
535
+ }
497
536
  try {
498
537
  const dir = AUDIT_FILE.substring(0, AUDIT_FILE.lastIndexOf('/'));
499
538
  if (dir && !existsSync(dir)) {
@@ -526,6 +565,9 @@ export function appendAuditLog(entry) {
526
565
  catch {
527
566
  // Audit log failure must not block tool execution
528
567
  }
568
+ finally {
569
+ lockReleased();
570
+ }
529
571
  }
530
572
  /**
531
573
  * Walk the audit file and verify the hash chain. Returns:
@@ -19,6 +19,7 @@ import { join } from 'path';
19
19
  import { promisify } from 'util';
20
20
  import { ProgressAggregator } from './progress-aggregator.js';
21
21
  import { applyMemoryPrefix } from './memory-injector.js';
22
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
22
23
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
23
24
  const execFileAsync = promisify(execFile);
24
25
  const HEADLESS_FLAGS = ['run', '--format', 'json'];
@@ -142,6 +143,46 @@ export function spawnOpenCode(prompt, options = {}) {
142
143
  if (txt)
143
144
  textAccum += txt + '\n';
144
145
  }
146
+ // #114 — cross-vendor audit mirror for opencode (closes the gap left by
147
+ // #110 slice 4, which wired Codex/Aider/Gemini but deferred opencode).
148
+ // OpenCode's `run --format json` emits `tool_use` events with shape
149
+ // { type: 'tool_use', part: { tool: 'bash'|'read'|'write'|..., state:
150
+ // { input: {...}, output, status, ... } } } (verified via deepwiki +
151
+ // takopi cheatsheet). Same post-hoc visibility surface as Codex/Aider/
152
+ // Gemini — no synchronous block until upstream pre-tool hooks land.
153
+ if (event.type === 'tool_use') {
154
+ const part = event.data?.part;
155
+ const rawTool = String(part?.tool ?? 'tool').toLowerCase();
156
+ // Normalize to Claude-cased tool names so /history filter chips group
157
+ // cross-vendor (a bash call from claude-code and opencode show under
158
+ // the same "Bash" filter chip).
159
+ const toolNameMap = {
160
+ bash: 'Bash',
161
+ read: 'Read',
162
+ write: 'Write',
163
+ edit: 'Edit',
164
+ grep: 'Grep',
165
+ glob: 'Glob',
166
+ webfetch: 'WebFetch',
167
+ websearch: 'WebSearch',
168
+ task: 'Task',
169
+ };
170
+ const toolName = toolNameMap[rawTool] ?? rawTool;
171
+ const input = part?.state?.input ?? {};
172
+ const inputStr = (typeof input.command === 'string' && input.command) ||
173
+ (typeof input.file_path === 'string' && input.file_path) ||
174
+ (typeof input.path === 'string' && input.path) ||
175
+ (typeof input.pattern === 'string' && input.pattern) ||
176
+ (typeof input.query === 'string' && input.query) ||
177
+ JSON.stringify(input).slice(0, 500);
178
+ mirrorToolCallToAudit({
179
+ sessionId,
180
+ agent: 'opencode',
181
+ toolName,
182
+ toolInput: String(inputStr),
183
+ cwd,
184
+ });
185
+ }
145
186
  if (!errorEmitted) {
146
187
  const data = event.data;
147
188
  let text = '';
@@ -108,7 +108,14 @@ function parseEtime(s) {
108
108
  return 0;
109
109
  }
110
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;
111
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 = [];
112
119
  /** Read-only sessions replayed from ~/.codehere/sessions.jsonl on daemon start.
113
120
  * These subprocesses are long-dead; we surface them so the user doesn't lose
114
121
  * their Final reports across restarts. */
@@ -150,7 +157,7 @@ export class SessionManager {
150
157
  continuedAsBySession = new Map();
151
158
  continuedFromBySession = new Map();
152
159
  /** Final Report failure tracking — when generateFinalReport rejects, we
153
- * record it so the API can return a definitive "failed" state instead
160
+ * record it so the API can return a definitive "failed" status instead
154
161
  * of leaving the UI showing "Generating report…" forever. */
155
162
  failedReportBySession = new Set();
156
163
  spawn(prompt, agentId = 'claude-code', options) {
@@ -187,6 +194,7 @@ export class SessionManager {
187
194
  const enrichedPrompt = enrichPromptWithPackSync(prompt, cwd);
188
195
  const session = adapter.spawn(enrichedPrompt, options);
189
196
  this.sessions.set(session.sessionId, session);
197
+ this.spawnOrder.push(session.sessionId);
190
198
  // Persistence: write the spawned record immediately so a daemon crash still
191
199
  // leaves an audit trail of what was attempted. Subscribe to exit so we can
192
200
  // capture the final status + steps + artifacts when the session settles.
@@ -317,6 +325,7 @@ export class SessionManager {
317
325
  clearInterval(t);
318
326
  this.narrationTimers.delete(session.sessionId);
319
327
  }
328
+ this.evictOldCompleted();
320
329
  unsubscribe();
321
330
  });
322
331
  // v0.6 M3 — PM Narrator. Mid-task plain-English summaries fired
@@ -388,29 +397,10 @@ export class SessionManager {
388
397
  });
389
398
  // Track this new session in the same way
390
399
  this.sessions.set(newSession.sessionId, newSession);
400
+ this.spawnOrder.push(newSession.sessionId);
391
401
  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
402
+ // Subscribe BEFORE persistence so exit events are never lost if the
403
+ // adapter exits synchronously (command not found, instant crash, etc).
414
404
  const unsubscribe = newSession.subscribe((event) => {
415
405
  if (event.type !== 'exit')
416
406
  return;
@@ -482,8 +472,36 @@ export class SessionManager {
482
472
  clearInterval(t);
483
473
  this.narrationTimers.delete(newSession.sessionId);
484
474
  }
475
+ this.evictOldCompleted();
485
476
  unsubscribe();
486
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);
487
505
  this.startNarrationTimer(newSession);
488
506
  return newSession;
489
507
  }
@@ -501,13 +519,12 @@ export class SessionManager {
501
519
  /** Emitted when a failover offer is generated. The API layer listens
502
520
  * to this to surface the banner in the web UI. */
503
521
  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
522
  if (!this.failoverOffers) {
507
523
  this.failoverOffers = new Map();
508
524
  }
509
525
  const key = `${offer.session.sessionId}-${offer.target}`;
510
- this.failoverOffers.set(key, offer);
526
+ const expiresAt = Date.now() + 60_000;
527
+ this.failoverOffers.set(key, { offer, createdAt: Date.now() });
511
528
  // Auto-expire offers after 60 seconds to keep the map bounded
512
529
  setTimeout(() => {
513
530
  this.failoverOffers?.delete(key);
@@ -518,12 +535,12 @@ export class SessionManager {
518
535
  if (!this.failoverOffers)
519
536
  return [];
520
537
  const offers = [];
521
- for (const [key, offer] of this.failoverOffers) {
538
+ for (const [key, { offer, createdAt }] of this.failoverOffers) {
522
539
  offers.push({
523
540
  sessionId: offer.session.sessionId,
524
541
  signal: offer.signal,
525
542
  target: offer.target,
526
- expiresAt: Date.now() + 60_000,
543
+ expiresAt: createdAt + 60_000,
527
544
  });
528
545
  }
529
546
  return offers;
@@ -565,32 +582,10 @@ export class SessionManager {
565
582
  });
566
583
  // Track the new session
567
584
  this.sessions.set(newSession.sessionId, newSession);
585
+ this.spawnOrder.push(newSession.sessionId);
568
586
  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
587
+ // Subscribe BEFORE persistence so exit events are never lost if the
588
+ // adapter exits synchronously.
594
589
  const unsubscribe = newSession.subscribe((event) => {
595
590
  if (event.type !== 'exit')
596
591
  return;
@@ -662,9 +657,34 @@ export class SessionManager {
662
657
  clearInterval(t);
663
658
  this.narrationTimers.delete(newSession.sessionId);
664
659
  }
665
- this.startNarrationTimer(newSession);
660
+ this.evictOldCompleted();
666
661
  unsubscribe();
667
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
+ });
668
688
  this.startNarrationTimer(newSession);
669
689
  return newSession;
670
690
  }
@@ -744,6 +764,38 @@ export class SessionManager {
744
764
  get(sessionId) {
745
765
  return this.sessions.get(sessionId);
746
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
+ }
747
799
  list() {
748
800
  return Array.from(this.sessions.values());
749
801
  }
@@ -754,6 +806,17 @@ export class SessionManager {
754
806
  session.kill();
755
807
  return true;
756
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
+ }
757
820
  /**
758
821
  * Active Claude Code sessions on this machine, derived from the audit log.
759
822
  * A session is "active" if any tool call from it was recorded within the
@@ -879,5 +942,19 @@ export class SessionManager {
879
942
  static getInstance() {
880
943
  return (this._instance ??= new SessionManager());
881
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);
882
959
  }
883
960
  //# sourceMappingURL=session-manager.js.map