@unpolarize/code-sessions 0.1.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/code-sessions.mjs +20 -0
- package/dist/chunk-ZJG2DWAK.js +2321 -0
- package/dist/cli.js +308 -0
- package/dist/index.js +162 -0
- package/package.json +21 -0
- package/src/adapters/adapters.test.ts +121 -0
- package/src/adapters/codex.ts +228 -0
- package/src/adapters/grok.ts +179 -0
- package/src/adapters/import.ts +79 -0
- package/src/adapters/index.ts +3 -0
- package/src/analytics/analytics.test.ts +94 -0
- package/src/analytics/command.ts +38 -0
- package/src/analytics/digest.ts +48 -0
- package/src/analytics/rollup.ts +114 -0
- package/src/analytics/site.ts +41 -0
- package/src/capture.test.ts +103 -0
- package/src/capture.ts +121 -0
- package/src/cli.ts +118 -0
- package/src/cliargs.test.ts +31 -0
- package/src/cliargs.ts +77 -0
- package/src/commands.test.ts +99 -0
- package/src/commands.ts +266 -0
- package/src/config.test.ts +36 -0
- package/src/config.ts +158 -0
- package/src/daemon.test.ts +130 -0
- package/src/daemon.ts +216 -0
- package/src/hooks/install.test.ts +47 -0
- package/src/hooks/install.ts +81 -0
- package/src/hooks/shim.test.ts +57 -0
- package/src/hooks/shim.ts +26 -0
- package/src/hygiene.test.ts +78 -0
- package/src/hygiene.ts +107 -0
- package/src/index.ts +21 -0
- package/src/index_store/db.test.ts +108 -0
- package/src/index_store/db.ts +289 -0
- package/src/index_store/index.ts +2 -0
- package/src/index_store/sync.test.ts +88 -0
- package/src/index_store/sync.ts +83 -0
- package/src/insights/heuristics.test.ts +71 -0
- package/src/insights/heuristics.ts +106 -0
- package/src/insights/index.ts +4 -0
- package/src/insights/labeler.test.ts +105 -0
- package/src/insights/labeler.ts +136 -0
- package/src/insights/llm.test.ts +77 -0
- package/src/insights/llm.ts +130 -0
- package/src/insights/provider.ts +37 -0
- package/src/ipc.test.ts +35 -0
- package/src/ipc.ts +70 -0
- package/src/pricing.test.ts +28 -0
- package/src/pricing.ts +45 -0
- package/src/state.test.ts +46 -0
- package/src/state.ts +89 -0
- package/src/store/git.test.ts +99 -0
- package/src/store/git.ts +138 -0
- package/src/store/paths.ts +45 -0
- package/src/store/scan.ts +39 -0
- package/src/store/writer.test.ts +93 -0
- package/src/store/writer.ts +135 -0
- package/src/tail.test.ts +50 -0
- package/src/tail.ts +47 -0
- package/src/telemetry/exporter.test.ts +104 -0
- package/src/telemetry/exporter.ts +64 -0
- package/src/telemetry/index.ts +2 -0
- package/src/telemetry/otlp.test.ts +123 -0
- package/src/telemetry/otlp.ts +215 -0
- package/src/test/e2e.test.ts +112 -0
- package/src/test/tmp.ts +36 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createServer, type Server, type Socket } from 'node:net';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CaptureEngine } from './capture';
|
|
5
|
+
import type { CodeSessionsConfig } from './config';
|
|
6
|
+
import { isSessionEndEvent, parseHookEvent, type HookAck, type HookEvent } from './ipc';
|
|
7
|
+
import { StateStore } from './state';
|
|
8
|
+
import { GitStore } from './store/git';
|
|
9
|
+
import { readEntries } from './store/scan';
|
|
10
|
+
|
|
11
|
+
export type SessionEndHook = (sessionId: string, sessionDir: string) => void | Promise<void>;
|
|
12
|
+
|
|
13
|
+
export interface DaemonDeps {
|
|
14
|
+
capture?: CaptureEngine;
|
|
15
|
+
state?: StateStore;
|
|
16
|
+
git?: GitStore;
|
|
17
|
+
/** invoked on Stop/SubagentStop — the insights labeler hooks in here */
|
|
18
|
+
onSessionEnd?: SessionEndHook;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DaemonStatus {
|
|
22
|
+
running: boolean;
|
|
23
|
+
socketPath: string;
|
|
24
|
+
storeDir: string;
|
|
25
|
+
events: number;
|
|
26
|
+
turns: number;
|
|
27
|
+
commits: number;
|
|
28
|
+
sessions: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Find a Claude transcript file by session id under the projects dir (bounded scan). */
|
|
32
|
+
export function findTranscript(projectsDir: string, sessionId: string, maxDepth = 3): string | undefined {
|
|
33
|
+
const target = `${sessionId}.jsonl`;
|
|
34
|
+
const walk = (dir: string, depth: number): string | undefined => {
|
|
35
|
+
if (depth > maxDepth || !existsSync(dir)) return undefined;
|
|
36
|
+
const entries = readEntries(dir);
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
if (e.isFile() && String(e.name) === target) return join(dir, String(e.name));
|
|
39
|
+
}
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (e.isDirectory()) {
|
|
42
|
+
const found = walk(join(dir, String(e.name)), depth + 1);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
};
|
|
48
|
+
return walk(projectsDir, 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The headless capture daemon. Listens on a unix socket for hook events,
|
|
53
|
+
* captures appended turns immediately, and batches git commits (flush on
|
|
54
|
+
* session-end, on a turn threshold, or after an interval).
|
|
55
|
+
*/
|
|
56
|
+
export class Daemon {
|
|
57
|
+
private server?: Server;
|
|
58
|
+
private readonly capture: CaptureEngine;
|
|
59
|
+
private readonly state: StateStore;
|
|
60
|
+
private readonly git?: GitStore;
|
|
61
|
+
private readonly onSessionEnd?: SessionEndHook;
|
|
62
|
+
|
|
63
|
+
private dirty = false;
|
|
64
|
+
private pendingTurns = 0;
|
|
65
|
+
private commitTimer?: ReturnType<typeof setTimeout>;
|
|
66
|
+
private running = false;
|
|
67
|
+
private readonly stats = { events: 0, turns: 0, commits: 0 };
|
|
68
|
+
private readonly sessions = new Set<string>();
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly cfg: CodeSessionsConfig,
|
|
72
|
+
deps: DaemonDeps = {},
|
|
73
|
+
) {
|
|
74
|
+
this.state = deps.state ?? new StateStore(cfg.statePath);
|
|
75
|
+
this.capture = deps.capture ?? new CaptureEngine(cfg, this.state);
|
|
76
|
+
if (cfg.git.autoCommit) {
|
|
77
|
+
this.git =
|
|
78
|
+
deps.git ??
|
|
79
|
+
new GitStore(cfg.storeDir, {
|
|
80
|
+
...(cfg.git.remote ? { remote: cfg.git.remote } : {}),
|
|
81
|
+
autoPush: cfg.git.autoPush,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (deps.onSessionEnd) this.onSessionEnd = deps.onSessionEnd;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async start(): Promise<void> {
|
|
88
|
+
mkdirSync(this.cfg.storeDir, { recursive: true });
|
|
89
|
+
mkdirSync(this.cfg.runtimeDir, { recursive: true });
|
|
90
|
+
this.git?.init();
|
|
91
|
+
if (existsSync(this.cfg.socketPath)) rmSync(this.cfg.socketPath);
|
|
92
|
+
await new Promise<void>((resolve, reject) => {
|
|
93
|
+
const server = createServer((sock) => this.onConnection(sock));
|
|
94
|
+
server.on('error', reject);
|
|
95
|
+
server.listen(this.cfg.socketPath, () => {
|
|
96
|
+
this.running = true;
|
|
97
|
+
resolve();
|
|
98
|
+
});
|
|
99
|
+
this.server = server;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private onConnection(sock: Socket): void {
|
|
104
|
+
let buf = '';
|
|
105
|
+
sock.on('data', async (chunk) => {
|
|
106
|
+
buf += chunk.toString('utf8');
|
|
107
|
+
let nl: number;
|
|
108
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
109
|
+
const line = buf.slice(0, nl);
|
|
110
|
+
buf = buf.slice(nl + 1);
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
let ack: HookAck;
|
|
113
|
+
try {
|
|
114
|
+
const evt = parseHookEvent(JSON.parse(line));
|
|
115
|
+
ack = evt ? await this.handleEvent(evt) : { ok: false, error: 'invalid event' };
|
|
116
|
+
} catch {
|
|
117
|
+
ack = { ok: false, error: 'parse error' };
|
|
118
|
+
}
|
|
119
|
+
if (!sock.destroyed) sock.write(`${JSON.stringify(ack)}\n`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
sock.on('error', () => {
|
|
123
|
+
/* client hangup — ignore */
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Process a single hook event: capture, then decide whether to flush a commit. */
|
|
128
|
+
async handleEvent(evt: HookEvent): Promise<HookAck> {
|
|
129
|
+
this.stats.events++;
|
|
130
|
+
const transcript =
|
|
131
|
+
evt.transcript_path && existsSync(evt.transcript_path)
|
|
132
|
+
? evt.transcript_path
|
|
133
|
+
: findTranscript(this.cfg.claudeProjectsDir, evt.session_id);
|
|
134
|
+
if (!transcript) return { ok: false, error: 'transcript not found' };
|
|
135
|
+
|
|
136
|
+
const res = this.capture.captureSession(evt.session_id, transcript);
|
|
137
|
+
this.sessions.add(evt.session_id);
|
|
138
|
+
if (res.newTurns > 0 || res.writtenPaths.length > 0) {
|
|
139
|
+
this.dirty = true;
|
|
140
|
+
this.pendingTurns += res.newTurns;
|
|
141
|
+
this.stats.turns += res.newTurns;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const end = isSessionEndEvent(evt.event);
|
|
145
|
+
let flushed = false;
|
|
146
|
+
if (end && this.onSessionEnd) {
|
|
147
|
+
await this.onSessionEnd(evt.session_id, res.sessionDir);
|
|
148
|
+
this.dirty = true; // insights wrote derived artifacts
|
|
149
|
+
}
|
|
150
|
+
if (end || this.pendingTurns >= this.cfg.batch.maxTurns) {
|
|
151
|
+
flushed = this.flush(`capture ${evt.session_id}`);
|
|
152
|
+
} else {
|
|
153
|
+
this.scheduleFlush();
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, newTurns: res.newTurns, flushed };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private scheduleFlush(): void {
|
|
159
|
+
if (this.commitTimer) return;
|
|
160
|
+
this.commitTimer = setTimeout(() => {
|
|
161
|
+
this.commitTimer = undefined;
|
|
162
|
+
this.flush('batch interval');
|
|
163
|
+
}, this.cfg.batch.maxIntervalMs);
|
|
164
|
+
this.commitTimer.unref?.();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Commit (and push when configured) all buffered store changes. Returns whether a commit landed. */
|
|
168
|
+
flush(message: string): boolean {
|
|
169
|
+
if (this.commitTimer) {
|
|
170
|
+
clearTimeout(this.commitTimer);
|
|
171
|
+
this.commitTimer = undefined;
|
|
172
|
+
}
|
|
173
|
+
if (!this.dirty || !this.git) {
|
|
174
|
+
this.dirty = false;
|
|
175
|
+
this.pendingTurns = 0;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const r = this.git.sync(message);
|
|
179
|
+
this.dirty = false;
|
|
180
|
+
this.pendingTurns = 0;
|
|
181
|
+
if (r.commit.committed) this.stats.commits++;
|
|
182
|
+
return r.commit.committed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
status(): DaemonStatus {
|
|
186
|
+
return {
|
|
187
|
+
running: this.running,
|
|
188
|
+
socketPath: this.cfg.socketPath,
|
|
189
|
+
storeDir: this.cfg.storeDir,
|
|
190
|
+
events: this.stats.events,
|
|
191
|
+
turns: this.stats.turns,
|
|
192
|
+
commits: this.stats.commits,
|
|
193
|
+
sessions: [...this.sessions],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async stop(): Promise<void> {
|
|
198
|
+
if (this.commitTimer) {
|
|
199
|
+
clearTimeout(this.commitTimer);
|
|
200
|
+
this.commitTimer = undefined;
|
|
201
|
+
}
|
|
202
|
+
this.flush('daemon shutdown');
|
|
203
|
+
if (this.server) {
|
|
204
|
+
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
|
205
|
+
this.server = undefined;
|
|
206
|
+
}
|
|
207
|
+
if (existsSync(this.cfg.socketPath)) {
|
|
208
|
+
try {
|
|
209
|
+
rmSync(this.cfg.socketPath);
|
|
210
|
+
} catch {
|
|
211
|
+
/* ignore */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
this.running = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { withTempDir } from '../test/tmp';
|
|
5
|
+
import { DEFAULT_HOOK_EVENTS, installHooks, mergeHooks } from './install';
|
|
6
|
+
|
|
7
|
+
describe('mergeHooks', () => {
|
|
8
|
+
it('adds our command to each event without clobbering existing hooks', () => {
|
|
9
|
+
const existing = {
|
|
10
|
+
model: 'opus',
|
|
11
|
+
hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command' as const, command: 'other-tool' }] }] },
|
|
12
|
+
};
|
|
13
|
+
const { settings, added } = mergeHooks(existing, 'code-sessions hook');
|
|
14
|
+
expect(added).toEqual([...DEFAULT_HOOK_EVENTS]);
|
|
15
|
+
// preserves unrelated settings
|
|
16
|
+
expect((settings as { model: string }).model).toBe('opus');
|
|
17
|
+
// preserves the pre-existing Stop hook AND adds ours
|
|
18
|
+
const stop = settings.hooks!.Stop!;
|
|
19
|
+
expect(stop.some((g) => g.hooks.some((h) => h.command === 'other-tool'))).toBe(true);
|
|
20
|
+
expect(stop.some((g) => g.hooks.some((h) => h.command === 'code-sessions hook'))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('is idempotent (no duplicate command on re-run)', () => {
|
|
24
|
+
const first = mergeHooks({}, 'code-sessions hook');
|
|
25
|
+
const second = mergeHooks(first.settings, 'code-sessions hook');
|
|
26
|
+
expect(second.added).toEqual([]);
|
|
27
|
+
for (const event of DEFAULT_HOOK_EVENTS) {
|
|
28
|
+
const groups = second.settings.hooks![event]!;
|
|
29
|
+
const count = groups.filter((g) =>
|
|
30
|
+
g.hooks.some((h) => h.command === 'code-sessions hook'),
|
|
31
|
+
).length;
|
|
32
|
+
expect(count).toBe(1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('installHooks', () => {
|
|
38
|
+
it('writes a merged settings.json file', () => {
|
|
39
|
+
withTempDir((dir) => {
|
|
40
|
+
const settingsPath = join(dir, 'settings.json');
|
|
41
|
+
const res = installHooks(settingsPath, 'code-sessions hook');
|
|
42
|
+
expect(res.added.length).toBeGreaterThan(0);
|
|
43
|
+
const written = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
44
|
+
expect(written.hooks.PostToolUse[0].hooks[0].command).toBe('code-sessions hook');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Install code-sessions hook shims into Claude Code settings.json. Each event
|
|
6
|
+
* runs `code-sessions hook`, which forwards the hook payload to the daemon
|
|
7
|
+
* socket. Existing hooks are preserved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_HOOK_EVENTS = [
|
|
11
|
+
'SessionStart',
|
|
12
|
+
'UserPromptSubmit',
|
|
13
|
+
'PostToolUse',
|
|
14
|
+
'Stop',
|
|
15
|
+
'SubagentStop',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
interface HookEntry {
|
|
19
|
+
type: 'command';
|
|
20
|
+
command: string;
|
|
21
|
+
}
|
|
22
|
+
interface HookGroup {
|
|
23
|
+
matcher?: string;
|
|
24
|
+
hooks: HookEntry[];
|
|
25
|
+
}
|
|
26
|
+
type SettingsHooks = Record<string, HookGroup[]>;
|
|
27
|
+
interface Settings {
|
|
28
|
+
hooks?: SettingsHooks;
|
|
29
|
+
[k: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function groupHasCommand(groups: HookGroup[], command: string): boolean {
|
|
33
|
+
return groups.some((g) => g.hooks?.some((h) => h.command === command));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pure merge: add our command to each requested event if not already present. */
|
|
37
|
+
export function mergeHooks(
|
|
38
|
+
settings: Settings,
|
|
39
|
+
command: string,
|
|
40
|
+
events: readonly string[] = DEFAULT_HOOK_EVENTS,
|
|
41
|
+
): { settings: Settings; added: string[] } {
|
|
42
|
+
const next: Settings = { ...settings, hooks: { ...(settings.hooks ?? {}) } };
|
|
43
|
+
const hooks = next.hooks as SettingsHooks;
|
|
44
|
+
const added: string[] = [];
|
|
45
|
+
for (const event of events) {
|
|
46
|
+
const groups = hooks[event] ? [...hooks[event]!] : [];
|
|
47
|
+
if (groupHasCommand(groups, command)) {
|
|
48
|
+
hooks[event] = groups;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
groups.push({ matcher: '', hooks: [{ type: 'command', command }] });
|
|
52
|
+
hooks[event] = groups;
|
|
53
|
+
added.push(event);
|
|
54
|
+
}
|
|
55
|
+
return { settings: next, added };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface InstallResult {
|
|
59
|
+
settingsPath: string;
|
|
60
|
+
command: string;
|
|
61
|
+
added: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function installHooks(
|
|
65
|
+
settingsPath: string,
|
|
66
|
+
command: string,
|
|
67
|
+
events: readonly string[] = DEFAULT_HOOK_EVENTS,
|
|
68
|
+
): InstallResult {
|
|
69
|
+
let settings: Settings = {};
|
|
70
|
+
if (existsSync(settingsPath)) {
|
|
71
|
+
try {
|
|
72
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as Settings;
|
|
73
|
+
} catch {
|
|
74
|
+
settings = {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const { settings: merged, added } = mergeHooks(settings, command, events);
|
|
78
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
79
|
+
writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
80
|
+
return { settingsPath, command, added };
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { Daemon } from '../daemon';
|
|
5
|
+
import { makeConfig, withTempDirAsync } from '../test/tmp';
|
|
6
|
+
import { handleHookInput } from './shim';
|
|
7
|
+
|
|
8
|
+
const LINE =
|
|
9
|
+
'{"type":"user","sessionId":"sess-1","timestamp":"2026-06-20T08:00:00Z","message":{"role":"user","content":"hi"}}';
|
|
10
|
+
|
|
11
|
+
describe('handleHookInput', () => {
|
|
12
|
+
it('is a silent no-op when the daemon is not running', async () => {
|
|
13
|
+
const ack = await handleHookInput('/no/such/daemon.sock', JSON.stringify({ event: 'Stop', session_id: 's' }));
|
|
14
|
+
expect(ack.ok).toBe(false);
|
|
15
|
+
expect(ack.error).toMatch(/not running/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('forwards a valid hook payload to a running daemon', async () => {
|
|
19
|
+
await withTempDirAsync(async (root) => {
|
|
20
|
+
const store = join(root, 'store');
|
|
21
|
+
const src = join(root, 'src');
|
|
22
|
+
mkdirSync(src, { recursive: true });
|
|
23
|
+
const transcript = join(src, 'sess-1.jsonl');
|
|
24
|
+
writeFileSync(transcript, `${LINE}\n`);
|
|
25
|
+
const socketPath = join(root, 'd.sock');
|
|
26
|
+
const d = new Daemon(makeConfig(store, { socketPath, batch: { maxTurns: 1 } }));
|
|
27
|
+
await d.start();
|
|
28
|
+
try {
|
|
29
|
+
const payload = JSON.stringify({
|
|
30
|
+
hook_event_name: 'PostToolUse',
|
|
31
|
+
session_id: 'sess-1',
|
|
32
|
+
transcript_path: transcript,
|
|
33
|
+
});
|
|
34
|
+
const ack = await handleHookInput(socketPath, payload);
|
|
35
|
+
expect(ack.ok).toBe(true);
|
|
36
|
+
expect(ack.newTurns).toBe(1);
|
|
37
|
+
} finally {
|
|
38
|
+
await d.stop();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects malformed JSON once a daemon socket exists', async () => {
|
|
44
|
+
await withTempDirAsync(async (root) => {
|
|
45
|
+
const socketPath = join(root, 'd.sock');
|
|
46
|
+
const d = new Daemon(makeConfig(join(root, 'store'), { socketPath }));
|
|
47
|
+
await d.start();
|
|
48
|
+
try {
|
|
49
|
+
const ack = await handleHookInput(socketPath, 'not json');
|
|
50
|
+
expect(ack.ok).toBe(false);
|
|
51
|
+
expect(ack.error).toMatch(/invalid hook json/);
|
|
52
|
+
} finally {
|
|
53
|
+
await d.stop();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { parseHookEvent, sendEvent, type HookAck } from '../ipc';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook shim: forward a Claude Code hook payload (JSON on stdin) to the daemon
|
|
6
|
+
* socket. Must NEVER block or fail the agent — a missing/unreachable daemon is
|
|
7
|
+
* a silent no-op.
|
|
8
|
+
*/
|
|
9
|
+
export async function handleHookInput(socketPath: string, rawInput: string): Promise<HookAck> {
|
|
10
|
+
if (!existsSync(socketPath)) return { ok: false, error: 'daemon not running' };
|
|
11
|
+
let parsed: unknown;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(rawInput);
|
|
14
|
+
} catch {
|
|
15
|
+
return { ok: false, error: 'invalid hook json' };
|
|
16
|
+
}
|
|
17
|
+
const evt = parseHookEvent(parsed);
|
|
18
|
+
if (!evt) return { ok: false, error: 'unrecognized hook payload' };
|
|
19
|
+
return sendEvent(socketPath, evt);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readStdin(): Promise<string> {
|
|
23
|
+
const chunks: Buffer[] = [];
|
|
24
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
25
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
26
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseTurn } from '@unpolarize/code-sessions-schema';
|
|
3
|
+
import { applyHygiene, scrubSecrets, sha256 } from './hygiene';
|
|
4
|
+
|
|
5
|
+
function turn(text: string): ReturnType<typeof parseTurn> {
|
|
6
|
+
return parseTurn({
|
|
7
|
+
schema: 'session-store/turn@1',
|
|
8
|
+
session_id: 's',
|
|
9
|
+
host: 'h',
|
|
10
|
+
agent: 'claude-code',
|
|
11
|
+
turn_index: 0,
|
|
12
|
+
ts: 't',
|
|
13
|
+
role: 'assistant',
|
|
14
|
+
text,
|
|
15
|
+
raw: { original: text },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('scrubSecrets', () => {
|
|
20
|
+
it('redacts common secret shapes', () => {
|
|
21
|
+
const samples = [
|
|
22
|
+
'AKIAIOSFODNN7EXAMPLE',
|
|
23
|
+
'ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD',
|
|
24
|
+
'sk-ant-api03-abcdefghij_klmnopqrstuvwxyz1234567890',
|
|
25
|
+
'AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZ0123456',
|
|
26
|
+
];
|
|
27
|
+
for (const s of samples) {
|
|
28
|
+
const { text, matches } = scrubSecrets(`token=${s} end`);
|
|
29
|
+
expect(text).not.toContain(s);
|
|
30
|
+
expect(text).toContain('[REDACTED:');
|
|
31
|
+
expect(matches.reduce((a, m) => a + m.count, 0)).toBeGreaterThan(0);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('leaves clean text untouched', () => {
|
|
36
|
+
const { text, matches } = scrubSecrets('just a normal sentence about foo.ts');
|
|
37
|
+
expect(text).toBe('just a normal sentence about foo.ts');
|
|
38
|
+
expect(matches).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('applyHygiene', () => {
|
|
43
|
+
it('scrubs and drops raw when a secret is found', () => {
|
|
44
|
+
const res = applyHygiene(turn('key ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD here'), {
|
|
45
|
+
maxTurnBytes: 64 * 1024,
|
|
46
|
+
scrubSecrets: true,
|
|
47
|
+
});
|
|
48
|
+
expect(res.turn.scrubbed).toBe(true);
|
|
49
|
+
expect(res.turn.text).toContain('[REDACTED:github-token]');
|
|
50
|
+
expect(res.turn.raw).toBeUndefined();
|
|
51
|
+
expect(res.redactions.length).toBeGreaterThan(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('externalizes oversized text to a content-addressed blob', () => {
|
|
55
|
+
const big = 'x'.repeat(2000);
|
|
56
|
+
const res = applyHygiene(turn(big), { maxTurnBytes: 512, scrubSecrets: true });
|
|
57
|
+
expect(res.blob).toBeDefined();
|
|
58
|
+
expect(res.blob!.sha).toBe(sha256(big));
|
|
59
|
+
expect(res.turn.raw_ref).toBe(res.blob!.sha);
|
|
60
|
+
expect(res.turn.text.length).toBeLessThan(big.length);
|
|
61
|
+
expect(res.turn.text).toContain('externalized');
|
|
62
|
+
expect(res.turn.raw).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('is a no-op for small clean turns (keeps raw)', () => {
|
|
66
|
+
const res = applyHygiene(turn('hello world'), { maxTurnBytes: 64 * 1024, scrubSecrets: true });
|
|
67
|
+
expect(res.turn.scrubbed).toBe(false);
|
|
68
|
+
expect(res.blob).toBeUndefined();
|
|
69
|
+
expect(res.turn.raw).toEqual({ original: 'hello world' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not mutate the input turn', () => {
|
|
73
|
+
const t = turn('ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD');
|
|
74
|
+
applyHygiene(t, { maxTurnBytes: 64 * 1024, scrubSecrets: true });
|
|
75
|
+
expect(t.text).toContain('ghp_');
|
|
76
|
+
expect(t.scrubbed).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/hygiene.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { Turn } from '@unpolarize/code-sessions-schema';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hygiene at the door (the Codex 1.95 GB lesson): secret-scrub, cap per-turn
|
|
6
|
+
* size, externalize giant tool outputs. Runs before any write.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SecretMatch {
|
|
10
|
+
kind: string;
|
|
11
|
+
count: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Pattern {
|
|
15
|
+
kind: string;
|
|
16
|
+
re: RegExp;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Ordered, specific-first. All global so we can count + replace every hit.
|
|
20
|
+
const PATTERNS: Pattern[] = [
|
|
21
|
+
{ kind: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
22
|
+
{ kind: 'github-token', re: /\bgh[posru]_[A-Za-z0-9]{36,255}\b/g },
|
|
23
|
+
{ kind: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
24
|
+
{ kind: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
25
|
+
{ kind: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
26
|
+
{ kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
27
|
+
{ kind: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g },
|
|
28
|
+
{ kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._-]{20,}\b/g },
|
|
29
|
+
{ kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Redact known secret shapes in text. Returns redacted text + per-kind counts. */
|
|
33
|
+
export function scrubSecrets(text: string): { text: string; matches: SecretMatch[] } {
|
|
34
|
+
let out = text;
|
|
35
|
+
const matches: SecretMatch[] = [];
|
|
36
|
+
for (const { kind, re } of PATTERNS) {
|
|
37
|
+
let count = 0;
|
|
38
|
+
out = out.replace(re, () => {
|
|
39
|
+
count++;
|
|
40
|
+
return `[REDACTED:${kind}]`;
|
|
41
|
+
});
|
|
42
|
+
if (count > 0) matches.push({ kind, count });
|
|
43
|
+
}
|
|
44
|
+
return { text: out, matches };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function sha256(content: string): string {
|
|
48
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HygieneOptions {
|
|
52
|
+
maxTurnBytes: number;
|
|
53
|
+
scrubSecrets: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HygieneResult {
|
|
57
|
+
turn: Turn;
|
|
58
|
+
/** present when the turn's text was externalized: persist content at raw/<sha> */
|
|
59
|
+
blob?: { sha: string; content: string };
|
|
60
|
+
redactions: SecretMatch[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PREVIEW_CHARS = 280;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply hygiene to a turn (pure: returns a new turn, never mutates input).
|
|
67
|
+
* - scrub secrets in text (and drop verbatim `raw` if anything was redacted, so
|
|
68
|
+
* the unredacted secret is not preserved);
|
|
69
|
+
* - externalize oversized text to a content-addressed blob, leaving a preview.
|
|
70
|
+
*/
|
|
71
|
+
export function applyHygiene(turn: Turn, opts: HygieneOptions): HygieneResult {
|
|
72
|
+
let text = turn.text;
|
|
73
|
+
let scrubbed = turn.scrubbed;
|
|
74
|
+
let raw = turn.raw;
|
|
75
|
+
const redactions: SecretMatch[] = [];
|
|
76
|
+
|
|
77
|
+
if (opts.scrubSecrets) {
|
|
78
|
+
const res = scrubSecrets(text);
|
|
79
|
+
if (res.matches.length > 0) {
|
|
80
|
+
text = res.text;
|
|
81
|
+
scrubbed = true;
|
|
82
|
+
raw = undefined; // never retain the unredacted copy
|
|
83
|
+
redactions.push(...res.matches);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let blob: { sha: string; content: string } | undefined;
|
|
88
|
+
let rawRef = turn.raw_ref;
|
|
89
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
90
|
+
if (bytes > opts.maxTurnBytes) {
|
|
91
|
+
const sha = sha256(text);
|
|
92
|
+
blob = { sha, content: text };
|
|
93
|
+
rawRef = sha;
|
|
94
|
+
raw = undefined; // the big content lives in the blob, not duplicated in raw
|
|
95
|
+
const preview = text.slice(0, PREVIEW_CHARS);
|
|
96
|
+
text = `${preview}\n…[externalized ${bytes}B → raw/${sha}]`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const next: Turn = {
|
|
100
|
+
...turn,
|
|
101
|
+
text,
|
|
102
|
+
scrubbed,
|
|
103
|
+
raw_ref: rawRef,
|
|
104
|
+
...(raw === undefined ? { raw: undefined } : { raw }),
|
|
105
|
+
};
|
|
106
|
+
return { turn: next, ...(blob ? { blob } : {}), redactions };
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Public library API for the code-sessions agent (the CLI lives in cli.ts).
|
|
2
|
+
export * from './config';
|
|
3
|
+
export * from './capture';
|
|
4
|
+
export * from './state';
|
|
5
|
+
export * from './tail';
|
|
6
|
+
export * from './hygiene';
|
|
7
|
+
export * from './pricing';
|
|
8
|
+
export * from './ipc';
|
|
9
|
+
export * from './daemon';
|
|
10
|
+
export * from './commands';
|
|
11
|
+
export { parseFlags, overridesFromFlags } from './cliargs';
|
|
12
|
+
export * from './store/paths';
|
|
13
|
+
export * from './store/writer';
|
|
14
|
+
export * from './store/git';
|
|
15
|
+
export * from './store/scan';
|
|
16
|
+
export * from './insights/index';
|
|
17
|
+
export * from './telemetry/index';
|
|
18
|
+
export * from './adapters/index';
|
|
19
|
+
export * from './index_store/index';
|
|
20
|
+
export * from './hooks/install';
|
|
21
|
+
export * from './hooks/shim';
|