@hellcoder/companion 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdtempSync,
|
|
3
|
+
rmSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
utimesSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { SessionRecorder, RecorderManager } from "./recorder.js";
|
|
13
|
+
|
|
14
|
+
let tempDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tempDir = mkdtempSync(join(tmpdir(), "recorder-test-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function readDirSafe(dir: string): string[] {
|
|
27
|
+
try {
|
|
28
|
+
return readdirSync(dir) as string[];
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a fake JSONL recording file with a given number of entry lines.
|
|
36
|
+
* Returns the full path. The header counts as 1 line, so total lines = 1 + entryCount.
|
|
37
|
+
*/
|
|
38
|
+
function createFakeRecording(
|
|
39
|
+
dir: string,
|
|
40
|
+
filename: string,
|
|
41
|
+
entryCount: number,
|
|
42
|
+
mtime?: Date,
|
|
43
|
+
): string {
|
|
44
|
+
const header = JSON.stringify({
|
|
45
|
+
_header: true,
|
|
46
|
+
version: 1,
|
|
47
|
+
session_id: "fake",
|
|
48
|
+
backend_type: "claude",
|
|
49
|
+
started_at: Date.now(),
|
|
50
|
+
cwd: "/fake",
|
|
51
|
+
});
|
|
52
|
+
const entry = JSON.stringify({ ts: Date.now(), dir: "in", raw: "x", ch: "cli" });
|
|
53
|
+
const lines = [header, ...Array(entryCount).fill(entry)];
|
|
54
|
+
const filePath = join(dir, filename);
|
|
55
|
+
writeFileSync(filePath, lines.join("\n") + "\n");
|
|
56
|
+
if (mtime) {
|
|
57
|
+
utimesSync(filePath, mtime, mtime);
|
|
58
|
+
}
|
|
59
|
+
return filePath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── SessionRecorder ─────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe("SessionRecorder", () => {
|
|
65
|
+
it("writes a header as the first line with correct metadata", () => {
|
|
66
|
+
const rec = new SessionRecorder("sess-1", "claude", "/project", tempDir);
|
|
67
|
+
rec.close();
|
|
68
|
+
|
|
69
|
+
const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
|
|
70
|
+
expect(lines.length).toBe(1);
|
|
71
|
+
|
|
72
|
+
const header = JSON.parse(lines[0]);
|
|
73
|
+
expect(header._header).toBe(true);
|
|
74
|
+
expect(header.version).toBe(1);
|
|
75
|
+
expect(header.session_id).toBe("sess-1");
|
|
76
|
+
expect(header.backend_type).toBe("claude");
|
|
77
|
+
expect(header.cwd).toBe("/project");
|
|
78
|
+
expect(typeof header.started_at).toBe("number");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("preserves raw strings exactly without re-serialization", () => {
|
|
82
|
+
// The raw string has intentional formatting (extra spaces, specific order)
|
|
83
|
+
// that must be preserved verbatim — not re-parsed and re-serialized.
|
|
84
|
+
const rawMsg = '{"type":"system", "subtype":"init", "extra_field": true}';
|
|
85
|
+
const rec = new SessionRecorder("sess-2", "claude", "/project", tempDir);
|
|
86
|
+
rec.record("in", rawMsg, "cli");
|
|
87
|
+
rec.close();
|
|
88
|
+
|
|
89
|
+
const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
|
|
90
|
+
expect(lines.length).toBe(2);
|
|
91
|
+
|
|
92
|
+
const entry = JSON.parse(lines[1]);
|
|
93
|
+
expect(entry.raw).toBe(rawMsg);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("records entries with monotonically increasing timestamps", () => {
|
|
97
|
+
const rec = new SessionRecorder("sess-3", "codex", "/project", tempDir);
|
|
98
|
+
rec.record("in", "msg1", "cli");
|
|
99
|
+
rec.record("out", "msg2", "cli");
|
|
100
|
+
rec.record("in", "msg3", "browser");
|
|
101
|
+
rec.close();
|
|
102
|
+
|
|
103
|
+
const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
|
|
104
|
+
expect(lines.length).toBe(4);
|
|
105
|
+
|
|
106
|
+
const entries = lines.slice(1).map((l) => JSON.parse(l));
|
|
107
|
+
for (let i = 1; i < entries.length; i++) {
|
|
108
|
+
expect(entries[i].ts).toBeGreaterThanOrEqual(entries[i - 1].ts);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("records direction and channel correctly", () => {
|
|
113
|
+
const rec = new SessionRecorder("sess-4", "claude", "/cwd", tempDir);
|
|
114
|
+
rec.record("in", "hello", "cli");
|
|
115
|
+
rec.record("out", "world", "browser");
|
|
116
|
+
rec.close();
|
|
117
|
+
|
|
118
|
+
const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
|
|
119
|
+
const e1 = JSON.parse(lines[1]);
|
|
120
|
+
const e2 = JSON.parse(lines[2]);
|
|
121
|
+
|
|
122
|
+
expect(e1.dir).toBe("in");
|
|
123
|
+
expect(e1.ch).toBe("cli");
|
|
124
|
+
expect(e2.dir).toBe("out");
|
|
125
|
+
expect(e2.ch).toBe("browser");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not record after close()", () => {
|
|
129
|
+
const rec = new SessionRecorder("sess-5", "claude", "/cwd", tempDir);
|
|
130
|
+
rec.record("in", "before-close", "cli");
|
|
131
|
+
rec.close();
|
|
132
|
+
rec.record("in", "after-close", "cli");
|
|
133
|
+
|
|
134
|
+
const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
|
|
135
|
+
expect(lines.length).toBe(2);
|
|
136
|
+
expect(JSON.parse(lines[1]).raw).toBe("before-close");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("generates a filename with session ID and backend type", () => {
|
|
140
|
+
const rec = new SessionRecorder("my-session", "codex", "/cwd", tempDir);
|
|
141
|
+
rec.close();
|
|
142
|
+
|
|
143
|
+
expect(rec.filePath).toContain("my-session");
|
|
144
|
+
expect(rec.filePath).toContain("codex");
|
|
145
|
+
expect(rec.filePath).toMatch(/\.jsonl$/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("tracks lineCount correctly (header + entries)", () => {
|
|
149
|
+
// lineCount starts at 1 (the header), increments for each recorded entry
|
|
150
|
+
const rec = new SessionRecorder("sess-lc", "claude", "/cwd", tempDir);
|
|
151
|
+
expect(rec.lineCount).toBe(1);
|
|
152
|
+
|
|
153
|
+
rec.record("in", "a", "cli");
|
|
154
|
+
rec.record("in", "b", "cli");
|
|
155
|
+
rec.record("out", "c", "browser");
|
|
156
|
+
rec.record("in", "d", "cli");
|
|
157
|
+
rec.record("out", "e", "browser");
|
|
158
|
+
expect(rec.lineCount).toBe(6);
|
|
159
|
+
|
|
160
|
+
rec.close();
|
|
161
|
+
// lineCount doesn't change after close
|
|
162
|
+
expect(rec.lineCount).toBe(6);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ─── RecorderManager ─────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe("RecorderManager", () => {
|
|
169
|
+
it("enabled by default when no options provided", () => {
|
|
170
|
+
// Recording is always on unless explicitly disabled
|
|
171
|
+
const mgr = new RecorderManager({ recordingsDir: tempDir });
|
|
172
|
+
expect(mgr.isGloballyEnabled()).toBe(true);
|
|
173
|
+
expect(mgr.isRecording("any-session")).toBe(true);
|
|
174
|
+
mgr.closeAll();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("respects globalEnabled: true", () => {
|
|
178
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
179
|
+
expect(mgr.isGloballyEnabled()).toBe(true);
|
|
180
|
+
expect(mgr.isRecording("any-session")).toBe(true);
|
|
181
|
+
mgr.closeAll();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does not record when disabled globally and per-session", () => {
|
|
185
|
+
const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
|
|
186
|
+
expect(mgr.isRecording("sess-1")).toBe(false);
|
|
187
|
+
|
|
188
|
+
mgr.record("sess-1", "in", "test", "cli", "claude", "/cwd");
|
|
189
|
+
|
|
190
|
+
const files = readDirSafe(tempDir);
|
|
191
|
+
expect(files.length).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("supports per-session enable/disable", () => {
|
|
195
|
+
const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
|
|
196
|
+
|
|
197
|
+
expect(mgr.isRecording("sess-1")).toBe(false);
|
|
198
|
+
|
|
199
|
+
mgr.enableForSession("sess-1");
|
|
200
|
+
expect(mgr.isRecording("sess-1")).toBe(true);
|
|
201
|
+
expect(mgr.isRecording("sess-2")).toBe(false);
|
|
202
|
+
|
|
203
|
+
mgr.disableForSession("sess-1");
|
|
204
|
+
expect(mgr.isRecording("sess-1")).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("lazily creates a recorder on first record() call", () => {
|
|
208
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
209
|
+
|
|
210
|
+
expect(readDirSafe(tempDir).length).toBe(0);
|
|
211
|
+
|
|
212
|
+
mgr.record("sess-1", "in", "first-msg", "cli", "claude", "/cwd");
|
|
213
|
+
|
|
214
|
+
const files = readDirSafe(tempDir);
|
|
215
|
+
expect(files.length).toBe(1);
|
|
216
|
+
expect(files[0]).toMatch(/^sess-1_claude_.*\.jsonl$/);
|
|
217
|
+
mgr.closeAll();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("creates separate files for concurrent sessions", () => {
|
|
221
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
222
|
+
|
|
223
|
+
mgr.record("sess-a", "in", "msg-a", "cli", "claude", "/cwd");
|
|
224
|
+
mgr.record("sess-b", "in", "msg-b", "cli", "codex", "/cwd");
|
|
225
|
+
|
|
226
|
+
const files = readDirSafe(tempDir);
|
|
227
|
+
expect(files.length).toBe(2);
|
|
228
|
+
expect(files.some((f) => f.includes("sess-a"))).toBe(true);
|
|
229
|
+
expect(files.some((f) => f.includes("sess-b"))).toBe(true);
|
|
230
|
+
mgr.closeAll();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("stopRecording closes the recorder and removes it", () => {
|
|
234
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
235
|
+
mgr.record("sess-1", "in", "msg1", "cli", "claude", "/cwd");
|
|
236
|
+
|
|
237
|
+
mgr.stopRecording("sess-1");
|
|
238
|
+
|
|
239
|
+
mgr.record("sess-1", "in", "msg2", "cli", "claude", "/cwd");
|
|
240
|
+
|
|
241
|
+
const files = readDirSafe(tempDir);
|
|
242
|
+
expect(files.length).toBe(2);
|
|
243
|
+
mgr.closeAll();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("getRecordingStatus returns filePath when active", () => {
|
|
247
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
248
|
+
mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
|
|
249
|
+
|
|
250
|
+
const status = mgr.getRecordingStatus("sess-1");
|
|
251
|
+
expect(status.filePath).toBeDefined();
|
|
252
|
+
expect(status.filePath!).toMatch(/sess-1.*\.jsonl$/);
|
|
253
|
+
mgr.closeAll();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("getRecordingStatus returns empty when not active", () => {
|
|
257
|
+
const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
|
|
258
|
+
const status = mgr.getRecordingStatus("sess-1");
|
|
259
|
+
expect(status.filePath).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("listRecordings returns correct metadata and line counts", () => {
|
|
263
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
264
|
+
// sess-1: header + 1 entry = 2 lines
|
|
265
|
+
mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
|
|
266
|
+
// sess-2: header + 1 entry = 2 lines
|
|
267
|
+
mgr.record("sess-2", "in", "msg", "cli", "codex", "/cwd");
|
|
268
|
+
|
|
269
|
+
const recordings = mgr.listRecordings();
|
|
270
|
+
expect(recordings.length).toBe(2);
|
|
271
|
+
|
|
272
|
+
const r1 = recordings.find((r) => r.sessionId === "sess-1");
|
|
273
|
+
expect(r1).toBeDefined();
|
|
274
|
+
expect(r1!.backendType).toBe("claude");
|
|
275
|
+
expect(r1!.lines).toBe(2);
|
|
276
|
+
|
|
277
|
+
const r2 = recordings.find((r) => r.sessionId === "sess-2");
|
|
278
|
+
expect(r2).toBeDefined();
|
|
279
|
+
expect(r2!.backendType).toBe("codex");
|
|
280
|
+
expect(r2!.lines).toBe(2);
|
|
281
|
+
mgr.closeAll();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("listRecordings returns empty array when directory does not exist", () => {
|
|
285
|
+
const mgr = new RecorderManager({
|
|
286
|
+
globalEnabled: false,
|
|
287
|
+
recordingsDir: join(tempDir, "nonexistent"),
|
|
288
|
+
});
|
|
289
|
+
expect(mgr.listRecordings()).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("closeAll closes all active recorders and stops cleanup timer", () => {
|
|
293
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
294
|
+
mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
|
|
295
|
+
mgr.record("sess-2", "in", "msg", "cli", "codex", "/cwd");
|
|
296
|
+
|
|
297
|
+
mgr.closeAll();
|
|
298
|
+
|
|
299
|
+
expect(mgr.getRecordingStatus("sess-1").filePath).toBeUndefined();
|
|
300
|
+
expect(mgr.getRecordingStatus("sess-2").filePath).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("disableForSession also stops and closes the recorder", () => {
|
|
304
|
+
const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
|
|
305
|
+
mgr.enableForSession("sess-1");
|
|
306
|
+
mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
|
|
307
|
+
|
|
308
|
+
expect(mgr.getRecordingStatus("sess-1").filePath).toBeDefined();
|
|
309
|
+
|
|
310
|
+
mgr.disableForSession("sess-1");
|
|
311
|
+
|
|
312
|
+
expect(mgr.getRecordingStatus("sess-1").filePath).toBeUndefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("disableForSession overrides globalEnabled and prevents new recordings", () => {
|
|
316
|
+
// When globalEnabled is true, disableForSession must still stop recording
|
|
317
|
+
// for that specific session by adding it to the perSessionDisabled set.
|
|
318
|
+
const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
|
|
319
|
+
mgr.record("sess-1", "in", "msg1", "cli", "claude", "/cwd");
|
|
320
|
+
|
|
321
|
+
expect(mgr.isRecording("sess-1")).toBe(true);
|
|
322
|
+
|
|
323
|
+
mgr.disableForSession("sess-1");
|
|
324
|
+
|
|
325
|
+
// Session is no longer recording despite globalEnabled=true
|
|
326
|
+
expect(mgr.isRecording("sess-1")).toBe(false);
|
|
327
|
+
|
|
328
|
+
// New record() calls should be no-ops (no new file created)
|
|
329
|
+
const filesBefore = readDirSafe(tempDir).length;
|
|
330
|
+
mgr.record("sess-1", "in", "msg2", "cli", "claude", "/cwd");
|
|
331
|
+
expect(readDirSafe(tempDir).length).toBe(filesBefore);
|
|
332
|
+
|
|
333
|
+
// Re-enabling should work
|
|
334
|
+
mgr.enableForSession("sess-1");
|
|
335
|
+
expect(mgr.isRecording("sess-1")).toBe(true);
|
|
336
|
+
|
|
337
|
+
mgr.closeAll();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("getMaxLines returns configured limit", () => {
|
|
341
|
+
const mgr = new RecorderManager({
|
|
342
|
+
globalEnabled: false,
|
|
343
|
+
recordingsDir: tempDir,
|
|
344
|
+
maxLines: 42,
|
|
345
|
+
});
|
|
346
|
+
expect(mgr.getMaxLines()).toBe(42);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ─── Cleanup / Rotation ─────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
describe("cleanup / rotation", () => {
|
|
353
|
+
it("deletes oldest files when total lines exceed maxLines", () => {
|
|
354
|
+
// Create 3 files with 10 entries each (= 11 lines each including header, 33 total)
|
|
355
|
+
// Use different mtimes so we control which is "oldest"
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
createFakeRecording(tempDir, "old_claude_2025-01-01.jsonl", 10, new Date(now - 3000));
|
|
358
|
+
createFakeRecording(tempDir, "mid_claude_2025-01-02.jsonl", 10, new Date(now - 2000));
|
|
359
|
+
createFakeRecording(tempDir, "new_claude_2025-01-03.jsonl", 10, new Date(now - 1000));
|
|
360
|
+
|
|
361
|
+
// maxLines = 20 → total 33 lines exceeds limit → should delete oldest first
|
|
362
|
+
const mgr = new RecorderManager({
|
|
363
|
+
globalEnabled: false, // don't start auto-cleanup timer
|
|
364
|
+
recordingsDir: tempDir,
|
|
365
|
+
maxLines: 20,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const deleted = mgr.cleanup();
|
|
369
|
+
|
|
370
|
+
// Should have deleted at least the oldest file (11 lines), bringing total to 22,
|
|
371
|
+
// still > 20, so the mid file (11 lines) gets deleted too → total 11 lines
|
|
372
|
+
expect(deleted).toBe(2);
|
|
373
|
+
|
|
374
|
+
const remaining = readDirSafe(tempDir);
|
|
375
|
+
expect(remaining.length).toBe(1);
|
|
376
|
+
expect(remaining[0]).toContain("new_claude");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("does not delete files from active recording sessions", () => {
|
|
380
|
+
// Create an old file that would normally be deleted
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
createFakeRecording(tempDir, "stale_claude_2025-01-01.jsonl", 10, new Date(now - 3000));
|
|
383
|
+
|
|
384
|
+
// Start an active recording — this file's path will be in the active set
|
|
385
|
+
const mgr = new RecorderManager({
|
|
386
|
+
globalEnabled: true,
|
|
387
|
+
recordingsDir: tempDir,
|
|
388
|
+
maxLines: 5, // Very low limit to force cleanup
|
|
389
|
+
});
|
|
390
|
+
mgr.record("active-sess", "in", "msg", "cli", "claude", "/cwd");
|
|
391
|
+
|
|
392
|
+
// Now cleanup should delete the stale file but NOT the active recording's file
|
|
393
|
+
const deleted = mgr.cleanup();
|
|
394
|
+
|
|
395
|
+
// stale file deleted
|
|
396
|
+
expect(existsSync(join(tempDir, "stale_claude_2025-01-01.jsonl"))).toBe(false);
|
|
397
|
+
|
|
398
|
+
// active session's file should still exist
|
|
399
|
+
const status = mgr.getRecordingStatus("active-sess");
|
|
400
|
+
expect(status.filePath).toBeDefined();
|
|
401
|
+
expect(existsSync(status.filePath!)).toBe(true);
|
|
402
|
+
|
|
403
|
+
mgr.closeAll();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("is a no-op when total lines are under the limit", () => {
|
|
407
|
+
// 2 files × 3 entries = 2 × 4 lines = 8 total, well under 100
|
|
408
|
+
createFakeRecording(tempDir, "a_claude_2025-01-01.jsonl", 3);
|
|
409
|
+
createFakeRecording(tempDir, "b_claude_2025-01-02.jsonl", 3);
|
|
410
|
+
|
|
411
|
+
const mgr = new RecorderManager({
|
|
412
|
+
globalEnabled: false,
|
|
413
|
+
recordingsDir: tempDir,
|
|
414
|
+
maxLines: 100,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const deleted = mgr.cleanup();
|
|
418
|
+
expect(deleted).toBe(0);
|
|
419
|
+
|
|
420
|
+
expect(readDirSafe(tempDir).length).toBe(2);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("handles empty recordings directory gracefully", () => {
|
|
424
|
+
const mgr = new RecorderManager({
|
|
425
|
+
globalEnabled: false,
|
|
426
|
+
recordingsDir: tempDir,
|
|
427
|
+
maxLines: 10,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const deleted = mgr.cleanup();
|
|
431
|
+
expect(deleted).toBe(0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("runs cleanup at construction when globally enabled", () => {
|
|
435
|
+
// Pre-fill the directory over the limit
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
createFakeRecording(tempDir, "old_claude_2025-01-01.jsonl", 20, new Date(now - 2000));
|
|
438
|
+
createFakeRecording(tempDir, "new_claude_2025-01-02.jsonl", 5, new Date(now - 1000));
|
|
439
|
+
|
|
440
|
+
// Total = 21 + 6 = 27 lines, maxLines = 10
|
|
441
|
+
// Constructor should run cleanup immediately, deleting the old file
|
|
442
|
+
const mgr = new RecorderManager({
|
|
443
|
+
globalEnabled: true,
|
|
444
|
+
recordingsDir: tempDir,
|
|
445
|
+
maxLines: 10,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const remaining = readDirSafe(tempDir);
|
|
449
|
+
expect(remaining.length).toBe(1);
|
|
450
|
+
expect(remaining[0]).toContain("new_claude");
|
|
451
|
+
|
|
452
|
+
mgr.closeAll();
|
|
453
|
+
});
|
|
454
|
+
});
|