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 +8 -8
- package/dist/index.js +29 -3
- package/dist/infrastructure/hooks/claude-sdk-hook.js +44 -2
- package/dist/infrastructure/orchestration/opencode-adapter.js +41 -0
- package/dist/infrastructure/orchestration/session-manager.js +131 -54
- package/dist/infrastructure/protocols/mcp-server-impl.js +355 -331
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/codehere)
|
|
7
7
|
[](../LICENSE)
|
|
8
|
-
[]()
|
|
9
9
|
[]()
|
|
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
|
-
|
|
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
|
|
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.
|
|
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/`.
|
|
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.
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|