@assethub/cli 0.1.29 → 0.1.31
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 +282 -8
- package/dist/appEnv.d.ts +20 -0
- package/dist/appEnv.d.ts.map +1 -0
- package/dist/appEnv.js +62 -0
- package/dist/canvasGraphDownload.d.ts +11 -0
- package/dist/canvasGraphDownload.d.ts.map +1 -0
- package/dist/canvasGraphDownload.js +108 -0
- package/dist/execution.d.ts +5 -2
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +11 -12
- package/dist/hooks/command.d.ts +14 -0
- package/dist/hooks/command.d.ts.map +1 -0
- package/dist/hooks/command.js +178 -0
- package/dist/hooks/graph.d.ts +40 -0
- package/dist/hooks/graph.d.ts.map +1 -0
- package/dist/hooks/graph.js +267 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/install.d.ts +43 -0
- package/dist/hooks/install.d.ts.map +1 -0
- package/dist/hooks/install.js +174 -0
- package/dist/hooks/paths.d.ts +31 -0
- package/dist/hooks/paths.d.ts.map +1 -0
- package/dist/hooks/paths.js +101 -0
- package/dist/hooks/redact.d.ts +5 -0
- package/dist/hooks/redact.d.ts.map +1 -0
- package/dist/hooks/redact.js +67 -0
- package/dist/hooks/save.d.ts +60 -0
- package/dist/hooks/save.d.ts.map +1 -0
- package/dist/hooks/save.js +330 -0
- package/dist/hooks/transcript.d.ts +23 -0
- package/dist/hooks/transcript.d.ts.map +1 -0
- package/dist/hooks/transcript.js +74 -0
- package/dist/hooks/trim.d.ts +15 -0
- package/dist/hooks/trim.d.ts.map +1 -0
- package/dist/hooks/trim.js +108 -0
- package/dist/hooks/types.d.ts +56 -0
- package/dist/hooks/types.d.ts.map +1 -0
- package/dist/hooks/types.js +0 -0
- package/dist/hooks/upload.d.ts +52 -0
- package/dist/hooks/upload.d.ts.map +1 -0
- package/dist/hooks/upload.js +252 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1579 -166
- package/dist/nodeMeshBatch.d.ts.map +1 -1
- package/dist/nodeMeshBatch.js +5 -4
- package/dist/productionBatch.d.ts +77 -0
- package/dist/productionBatch.d.ts.map +1 -0
- package/dist/productionBatch.js +204 -0
- package/dist/runProgress.d.ts +4 -0
- package/dist/runProgress.d.ts.map +1 -0
- package/dist/runProgress.js +16 -0
- package/dist/runsUpload/controlEndpoints.d.ts +1 -1
- package/dist/runsUpload/controlEndpoints.d.ts.map +1 -1
- package/dist/runsUpload/controlEndpoints.js +4 -2
- package/dist/runsUpload/uploadRun.d.ts +7 -1
- package/dist/runsUpload/uploadRun.d.ts.map +1 -1
- package/dist/runsUpload/uploadRun.js +7 -4
- package/dist/setupCommand.d.ts +127 -0
- package/dist/setupCommand.d.ts.map +1 -0
- package/dist/setupCommand.js +534 -0
- package/dist/setupHooksBridge.d.ts +8 -0
- package/dist/setupHooksBridge.d.ts.map +1 -0
- package/dist/setupHooksBridge.js +4 -0
- package/dist/update.d.ts +74 -0
- package/dist/update.d.ts.map +1 -0
- package/dist/update.js +205 -0
- package/package.json +2 -2
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Register / remove our Claude Code hooks in the user's settings.json.
|
|
2
|
+
//
|
|
3
|
+
// Merge rules: keep every existing key and hook, identify our entry by its
|
|
4
|
+
// command string (`<assethub> hooks save`), never duplicate it, and back the
|
|
5
|
+
// original file up before any change.
|
|
6
|
+
import { copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { basename, dirname, join } from 'node:path';
|
|
8
|
+
import { resolveHome } from './paths.js';
|
|
9
|
+
export const HOOK_EVENTS = ['SessionStart', 'Stop', 'SessionEnd', 'PreCompact'];
|
|
10
|
+
export const HOOK_TIMEOUT_SECONDS = 30;
|
|
11
|
+
/**
|
|
12
|
+
* SessionEnd hooks share a 1.5 s budget that a longer per-hook `timeout` raises
|
|
13
|
+
* (up to 60 s). The save itself is local; the upload runs in a detached process.
|
|
14
|
+
*/
|
|
15
|
+
export const SESSION_END_TIMEOUT_SECONDS = 10;
|
|
16
|
+
export const MAX_SETTINGS_BACKUPS = 3;
|
|
17
|
+
/** What `hooks install` and `setup` must tell the user before recording starts. */
|
|
18
|
+
export const SESSION_SAVE_DISCLOSURE = 'Session saving adds SessionStart, Stop, PreCompact and SessionEnd hooks to ~/.claude/settings.json. ' +
|
|
19
|
+
'Every Claude Code session on this machine, in any project, is then copied to ~/.assethub/sessions/ when it ends ' +
|
|
20
|
+
'(the conversation only: your prompts, the agent\'s replies and tool calls, with recognised secrets masked, readable only by you; copies older than 30 days are deleted), ' +
|
|
21
|
+
'and uploaded to AssetHub as a coding-agent session only you can read (internal accounts only for now): the masked transcript, ' +
|
|
22
|
+
'your prompts, the agent\'s replies, its tool calls and images from the project. Uploads that fail are retried in the background ' +
|
|
23
|
+
'when a later session starts or ends. Secret masking is best effort. ' +
|
|
24
|
+
'Opt a project out with .assethub/no-session-save, or turn it off with ASSETHUB_SESSION_SAVE=off / ASSETHUB_SESSION_UPLOAD=off; ' +
|
|
25
|
+
'remove it with `assethub hooks uninstall --client claude`.';
|
|
26
|
+
const CODEX_DETAIL = 'Codex: run `assethub hooks save --transcript <path>` manually for now';
|
|
27
|
+
const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
28
|
+
// Only a command that runs the CLI itself: `assethub hooks save`, optionally a
|
|
29
|
+
// (quoted) path to it. `echo assethub hooks save` is somebody else's hook.
|
|
30
|
+
const OUR_COMMAND_RE = /^\s*(?:"(?:[^"]*[/\\])?assethub(?:\.js)?"|'(?:[^']*[/\\])?assethub(?:\.js)?'|(?:[^\s"']*[/\\])?assethub(?:\.js)?)\s+hooks\s+save(?:\s|$)/;
|
|
31
|
+
export const isOurCommand = (command) => typeof command === 'string' && OUR_COMMAND_RE.test(command);
|
|
32
|
+
const quoteIfNeeded = (value) => (/\s/.test(value) ? JSON.stringify(value) : value);
|
|
33
|
+
export const hookCommand = (cliPath) => `${quoteIfNeeded(cliPath ?? 'assethub')} hooks save`;
|
|
34
|
+
const settingsPath = (home) => join(resolveHome(home), '.claude', 'settings.json');
|
|
35
|
+
const load = async (home) => {
|
|
36
|
+
const path = settingsPath(home);
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = await readFile(path, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error?.code === 'ENOENT') {
|
|
43
|
+
return { path, exists: false, settings: {}, raw: '' };
|
|
44
|
+
}
|
|
45
|
+
return { path, error: `Could not read ${path}: ${error.message}` };
|
|
46
|
+
}
|
|
47
|
+
if (!raw.trim())
|
|
48
|
+
return { path, exists: true, settings: {}, raw };
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (!isObject(parsed))
|
|
52
|
+
return { path, error: `${path} is not a JSON object; left untouched.` };
|
|
53
|
+
return { path, exists: true, settings: parsed, raw };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { path, error: `${path} is not valid JSON; left untouched.` };
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const BACKUP_RE = /^settings\.json\.bak-\d+$/;
|
|
60
|
+
const pruneBackups = async (path) => {
|
|
61
|
+
const dir = dirname(path);
|
|
62
|
+
const ours = (await readdir(dir)).filter(name => BACKUP_RE.test(name));
|
|
63
|
+
const byAge = ours.sort((a, b) => Number(b.split('-').pop()) - Number(a.split('-').pop()));
|
|
64
|
+
for (const name of byAge.slice(MAX_SETTINGS_BACKUPS))
|
|
65
|
+
await rm(join(dir, name), { force: true });
|
|
66
|
+
};
|
|
67
|
+
// Write through a temp file and rename, so a concurrent reader (Claude Code)
|
|
68
|
+
// never sees a half-written settings.json.
|
|
69
|
+
const backupAndWrite = async (loaded, next) => {
|
|
70
|
+
await mkdir(dirname(loaded.path), { recursive: true });
|
|
71
|
+
if (loaded.exists) {
|
|
72
|
+
await copyFile(loaded.path, `${loaded.path}.bak-${Date.now()}`);
|
|
73
|
+
}
|
|
74
|
+
const tmp = join(dirname(loaded.path), `.${basename(loaded.path)}.${process.pid}.tmp`);
|
|
75
|
+
await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
76
|
+
await rename(tmp, loaded.path);
|
|
77
|
+
await pruneBackups(loaded.path);
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* SessionStart (sweep and retry), Stop (every turn) and PreCompact only mark or
|
|
81
|
+
* spawn work, and run in the background so they never delay the agent.
|
|
82
|
+
* SessionEnd stays synchronous, because Claude Code is exiting and a
|
|
83
|
+
* background hook could be cut off.
|
|
84
|
+
*/
|
|
85
|
+
export const hookEntry = (event, command) => event === 'SessionEnd'
|
|
86
|
+
? { type: 'command', command, timeout: SESSION_END_TIMEOUT_SECONDS }
|
|
87
|
+
: { type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS, async: true };
|
|
88
|
+
export const installHooks = async (options) => {
|
|
89
|
+
if (options.client === 'codex')
|
|
90
|
+
return { installed: false, detail: CODEX_DETAIL };
|
|
91
|
+
const loaded = await load(options.home);
|
|
92
|
+
if ('error' in loaded)
|
|
93
|
+
return { installed: false, detail: loaded.error };
|
|
94
|
+
const command = hookCommand(options.cliPath);
|
|
95
|
+
const settings = structuredClone(loaded.settings);
|
|
96
|
+
const hooks = isObject(settings.hooks) ? settings.hooks : {};
|
|
97
|
+
let changed = false;
|
|
98
|
+
for (const event of HOOK_EVENTS) {
|
|
99
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
100
|
+
const wanted = hookEntry(event, command);
|
|
101
|
+
let found = false;
|
|
102
|
+
for (const group of groups) {
|
|
103
|
+
if (!isObject(group) || !Array.isArray(group.hooks))
|
|
104
|
+
continue;
|
|
105
|
+
group.hooks = group.hooks.map((hook) => {
|
|
106
|
+
if (!isObject(hook) || !isOurCommand(hook.command))
|
|
107
|
+
return hook;
|
|
108
|
+
found = true;
|
|
109
|
+
// Rewrite entries from older installs (no async, old timeout, old path).
|
|
110
|
+
if (JSON.stringify(hook) === JSON.stringify(wanted))
|
|
111
|
+
return hook;
|
|
112
|
+
changed = true;
|
|
113
|
+
return { ...wanted };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (!found) {
|
|
117
|
+
groups.push({ hooks: [{ ...wanted }] });
|
|
118
|
+
changed = true;
|
|
119
|
+
}
|
|
120
|
+
hooks[event] = groups;
|
|
121
|
+
}
|
|
122
|
+
settings.hooks = hooks;
|
|
123
|
+
if (!changed) {
|
|
124
|
+
return { installed: true, detail: `Hooks already installed in ${loaded.path}` };
|
|
125
|
+
}
|
|
126
|
+
await backupAndWrite(loaded, settings);
|
|
127
|
+
return { installed: true, detail: `Installed ${HOOK_EVENTS.join(', ')} hooks in ${loaded.path}` };
|
|
128
|
+
};
|
|
129
|
+
export const uninstallHooks = async (options) => {
|
|
130
|
+
if (options.client === 'codex') {
|
|
131
|
+
return { removed: false, detail: 'Codex: no hooks were installed by assethub.' };
|
|
132
|
+
}
|
|
133
|
+
const loaded = await load(options.home);
|
|
134
|
+
if ('error' in loaded)
|
|
135
|
+
return { removed: false, detail: loaded.error };
|
|
136
|
+
if (!loaded.exists || !isObject(loaded.settings.hooks)) {
|
|
137
|
+
return { removed: false, detail: 'No assethub hooks found.' };
|
|
138
|
+
}
|
|
139
|
+
const settings = structuredClone(loaded.settings);
|
|
140
|
+
const hooks = settings.hooks;
|
|
141
|
+
let changed = false;
|
|
142
|
+
for (const event of Object.keys(hooks)) {
|
|
143
|
+
const groups = hooks[event];
|
|
144
|
+
if (!Array.isArray(groups))
|
|
145
|
+
continue;
|
|
146
|
+
const keptGroups = [];
|
|
147
|
+
let eventChanged = false;
|
|
148
|
+
for (const group of groups) {
|
|
149
|
+
if (!isObject(group) || !Array.isArray(group.hooks)) {
|
|
150
|
+
keptGroups.push(group);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const kept = group.hooks.filter(hook => !(isObject(hook) && isOurCommand(hook.command)));
|
|
154
|
+
if (kept.length === group.hooks.length) {
|
|
155
|
+
keptGroups.push(group);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
changed = true;
|
|
159
|
+
eventChanged = true;
|
|
160
|
+
if (kept.length > 0)
|
|
161
|
+
keptGroups.push({ ...group, hooks: kept });
|
|
162
|
+
}
|
|
163
|
+
if (keptGroups.length > 0)
|
|
164
|
+
hooks[event] = keptGroups;
|
|
165
|
+
else if (eventChanged)
|
|
166
|
+
delete hooks[event];
|
|
167
|
+
}
|
|
168
|
+
if (!changed)
|
|
169
|
+
return { removed: false, detail: 'No assethub hooks found.' };
|
|
170
|
+
if (Object.keys(hooks).length === 0)
|
|
171
|
+
delete settings.hooks;
|
|
172
|
+
await backupAndWrite(loaded, settings);
|
|
173
|
+
return { removed: true, detail: `Removed assethub hooks from ${loaded.path}` };
|
|
174
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SessionMeta } from './types.js';
|
|
2
|
+
export declare const resolveHome: (home?: string) => string;
|
|
3
|
+
export declare const sessionsRoot: (home?: string) => string;
|
|
4
|
+
export declare const errorsLogPath: (home?: string) => string;
|
|
5
|
+
export declare const PRIVATE_DIR_MODE = 448;
|
|
6
|
+
export declare const PRIVATE_FILE_MODE = 384;
|
|
7
|
+
/** mkdir -p with private modes; also tightens a directory an older version made. */
|
|
8
|
+
export declare const ensurePrivateDir: (dir: string) => Promise<void>;
|
|
9
|
+
/** Write via a temp file and rename, so a reader never sees a half-written file. */
|
|
10
|
+
export declare const writePrivateFileAtomic: (target: string, content: string) => Promise<void>;
|
|
11
|
+
export declare const readMeta: (sessionDir: string) => Promise<SessionMeta | null>;
|
|
12
|
+
export declare const writeMeta: (sessionDir: string, meta: SessionMeta) => Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* A lock is a directory, because creating one is atomic. A lock older than
|
|
15
|
+
* `staleMs` belongs to a process that died, and is taken over. Returns the
|
|
16
|
+
* release function, or null when the lock is still held after `waitMs`.
|
|
17
|
+
*/
|
|
18
|
+
export declare const acquireLock: (path: string, { waitMs, staleMs }: {
|
|
19
|
+
waitMs: number;
|
|
20
|
+
staleMs: number;
|
|
21
|
+
}) => Promise<(() => Promise<void>) | null>;
|
|
22
|
+
/**
|
|
23
|
+
* Read, update and write meta under a per-session lock, so a hook and an
|
|
24
|
+
* upload running at the same time cannot overwrite each other's fields.
|
|
25
|
+
* If the lock cannot be had in time the write still happens: a hook must
|
|
26
|
+
* never hang on a stuck lock.
|
|
27
|
+
*/
|
|
28
|
+
export declare const updateMeta: (sessionDir: string, fallback: SessionMeta, update: (latest: SessionMeta) => SessionMeta) => Promise<SessionMeta>;
|
|
29
|
+
export declare const listSessionDirs: (home?: string) => Promise<string[]>;
|
|
30
|
+
export declare const logError: (home: string | undefined, error: unknown) => Promise<void>;
|
|
31
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/hooks/paths.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,YAAY,CAAA;AAE3C,eAAO,MAAM,WAAW,GAAI,OAAO,MAAM,KAAG,MAA2B,CAAA;AACvE,eAAO,MAAM,YAAY,GAAI,OAAO,MAAM,KAAG,MACK,CAAA;AAClD,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAAgD,CAAA;AAI9F,eAAO,MAAM,gBAAgB,MAAQ,CAAA;AACrC,eAAO,MAAM,iBAAiB,MAAQ,CAAA;AAEtC,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,GAAU,KAAK,MAAM,KAAG,OAAO,CAAC,IAAI,CAGhE,CAAA;AAED,oFAAoF;AACpF,eAAO,MAAM,sBAAsB,GAAU,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG,OAAO,CAAC,IAAI,CAI1F,CAAA;AAED,eAAO,MAAM,QAAQ,GAAU,YAAY,MAAM,KAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAM7E,CAAA;AAED,eAAO,MAAM,SAAS,GAAU,YAAY,MAAM,EAAE,MAAM,WAAW,KAAG,OAAO,CAAC,IAAI,CAEnF,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,GACtB,MAAM,MAAM,EACZ,qBAAmB;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,KACnD,OAAO,CAAC,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,CAiBtC,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GACrB,YAAY,MAAM,EAClB,UAAU,WAAW,EACrB,QAAQ,CAAC,MAAM,EAAE,WAAW,KAAK,WAAW,KAC3C,OAAO,CAAC,WAAW,CASrB,CAAA;AAED,eAAO,MAAM,eAAe,GAAU,OAAO,MAAM,KAAG,OAAO,CAAC,MAAM,EAAE,CAWrE,CAAA;AAED,eAAO,MAAM,QAAQ,GAAU,MAAM,MAAM,GAAG,SAAS,EAAE,OAAO,OAAO,KAAG,OAAO,CAAC,IAAI,CAUrF,CAAA"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { redactText } from './redact.js';
|
|
5
|
+
export const resolveHome = (home) => home ?? homedir();
|
|
6
|
+
export const sessionsRoot = (home) => join(resolveHome(home), '.assethub', 'sessions');
|
|
7
|
+
export const errorsLogPath = (home) => join(sessionsRoot(home), 'errors.log');
|
|
8
|
+
// Sessions hold the artist's conversation and images, and redaction is best
|
|
9
|
+
// effort, so everything under ~/.assethub/sessions is private to the user.
|
|
10
|
+
export const PRIVATE_DIR_MODE = 0o700;
|
|
11
|
+
export const PRIVATE_FILE_MODE = 0o600;
|
|
12
|
+
/** mkdir -p with private modes; also tightens a directory an older version made. */
|
|
13
|
+
export const ensurePrivateDir = async (dir) => {
|
|
14
|
+
await mkdir(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
15
|
+
await chmod(dir, PRIVATE_DIR_MODE);
|
|
16
|
+
};
|
|
17
|
+
/** Write via a temp file and rename, so a reader never sees a half-written file. */
|
|
18
|
+
export const writePrivateFileAtomic = async (target, content) => {
|
|
19
|
+
const tmp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
20
|
+
await writeFile(tmp, content, { mode: PRIVATE_FILE_MODE });
|
|
21
|
+
await rename(tmp, target);
|
|
22
|
+
};
|
|
23
|
+
export const readMeta = async (sessionDir) => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(await readFile(join(sessionDir, 'meta.json'), 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
export const writeMeta = async (sessionDir, meta) => {
|
|
32
|
+
await writePrivateFileAtomic(join(sessionDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A lock is a directory, because creating one is atomic. A lock older than
|
|
36
|
+
* `staleMs` belongs to a process that died, and is taken over. Returns the
|
|
37
|
+
* release function, or null when the lock is still held after `waitMs`.
|
|
38
|
+
*/
|
|
39
|
+
export const acquireLock = async (path, { waitMs, staleMs }) => {
|
|
40
|
+
const deadline = Date.now() + waitMs;
|
|
41
|
+
for (;;) {
|
|
42
|
+
try {
|
|
43
|
+
await mkdir(path, { mode: PRIVATE_DIR_MODE });
|
|
44
|
+
return () => rm(path, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code !== 'EEXIST')
|
|
48
|
+
throw error;
|
|
49
|
+
const age = await stat(path).then(info => Date.now() - info.mtimeMs, () => 0);
|
|
50
|
+
if (age > staleMs) {
|
|
51
|
+
await rm(path, { recursive: true, force: true });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (Date.now() >= deadline)
|
|
55
|
+
return null;
|
|
56
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Read, update and write meta under a per-session lock, so a hook and an
|
|
62
|
+
* upload running at the same time cannot overwrite each other's fields.
|
|
63
|
+
* If the lock cannot be had in time the write still happens: a hook must
|
|
64
|
+
* never hang on a stuck lock.
|
|
65
|
+
*/
|
|
66
|
+
export const updateMeta = async (sessionDir, fallback, update) => {
|
|
67
|
+
const release = await acquireLock(join(sessionDir, '.meta.lock'), { waitMs: 5_000, staleMs: 30_000 });
|
|
68
|
+
try {
|
|
69
|
+
const next = update((await readMeta(sessionDir)) ?? fallback);
|
|
70
|
+
await writeMeta(sessionDir, next);
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await release?.();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
export const listSessionDirs = async (home) => {
|
|
78
|
+
const root = sessionsRoot(home);
|
|
79
|
+
try {
|
|
80
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
81
|
+
return entries
|
|
82
|
+
.filter(entry => entry.isDirectory())
|
|
83
|
+
.map(entry => join(root, entry.name))
|
|
84
|
+
.sort();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
export const logError = async (home, error) => {
|
|
91
|
+
try {
|
|
92
|
+
await ensurePrivateDir(sessionsRoot(home));
|
|
93
|
+
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
94
|
+
await appendFile(errorsLogPath(home), `${new Date().toISOString()} ${redactText(message)}\n`, {
|
|
95
|
+
mode: PRIVATE_FILE_MODE,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// The hook must never fail the agent, even when logging fails.
|
|
100
|
+
}
|
|
101
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../../src/hooks/redact.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,QAAQ,eAAe,CAAA;AAuEpC,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,KAAG,MAUzC,CAAA;AAED,yDAAyD;AACzD,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAoD,CAAA"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Secret redaction for saved agent transcripts.
|
|
2
|
+
//
|
|
3
|
+
// Applied to raw JSONL text line by line. Every replacement is the literal
|
|
4
|
+
// `[REDACTED]`, and every matcher stops at quotes, backslashes and whitespace,
|
|
5
|
+
// so redacting a JSON line never breaks its JSON.
|
|
6
|
+
export const REDACTED = '[REDACTED]';
|
|
7
|
+
// Token bodies never contain quotes/backslashes/whitespace, so a JSON string
|
|
8
|
+
// boundary always ends a match.
|
|
9
|
+
const TOKEN = String.raw `[A-Za-z0-9._~+/=-]`;
|
|
10
|
+
const WHOLE_MATCH_PATTERNS = [
|
|
11
|
+
// AssetHub keys: ah_live_..., ah_test_..., ah_pat_...
|
|
12
|
+
new RegExp(String.raw `\bah_(?:live|test|pat)_[A-Za-z0-9_-]{6,}`, 'g'),
|
|
13
|
+
// Anthropic before the generic sk- rule.
|
|
14
|
+
new RegExp(String.raw `\bsk-ant-[A-Za-z0-9_-]{8,}`, 'g'),
|
|
15
|
+
// OpenAI (sk-..., sk-proj-...).
|
|
16
|
+
new RegExp(String.raw `\bsk-[A-Za-z0-9_-]{16,}`, 'g'),
|
|
17
|
+
// Stripe secret/restricted/publishable keys and webhook secrets (sk_live_, rk_test_, whsec_...).
|
|
18
|
+
new RegExp(String.raw `\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{8,}`, 'g'),
|
|
19
|
+
new RegExp(String.raw `\bwhsec_[A-Za-z0-9]{16,}`, 'g'),
|
|
20
|
+
// Supabase secret keys.
|
|
21
|
+
new RegExp(String.raw `\bsb_secret_[A-Za-z0-9_-]{16,}`, 'g'),
|
|
22
|
+
// AssetHub legacy / ElevenLabs style sk_<hex or alnum>.
|
|
23
|
+
new RegExp(String.raw `\bsk_[A-Za-z0-9]{16,}`, 'g'),
|
|
24
|
+
// JWTs, including Supabase anon/service-role keys.
|
|
25
|
+
new RegExp(String.raw `\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`, 'g'),
|
|
26
|
+
// Slack tokens.
|
|
27
|
+
new RegExp(String.raw `\bxox[abposr]-[A-Za-z0-9-]{10,}`, 'g'),
|
|
28
|
+
// Google API keys.
|
|
29
|
+
new RegExp(String.raw `\bAIza[0-9A-Za-z_-]{30,}`, 'g'),
|
|
30
|
+
// AWS access key ids.
|
|
31
|
+
new RegExp(String.raw `\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`, 'g'),
|
|
32
|
+
// GitHub tokens.
|
|
33
|
+
new RegExp(String.raw `\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}`, 'g'),
|
|
34
|
+
// Bearer tokens, anywhere.
|
|
35
|
+
new RegExp(String.raw `\bBearer\s+${TOKEN}{8,}`, 'gi'),
|
|
36
|
+
];
|
|
37
|
+
// PEM private keys. In JSONL the newlines are escaped (`\n`), so the block is one line.
|
|
38
|
+
const PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
39
|
+
// Credentials inside a URL: scheme://user:password@host keeps the user and host.
|
|
40
|
+
const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@"'\\]+:)[^\s@/"'\\]+@/gi;
|
|
41
|
+
// `Authorization: <scheme> <value>` / `"Authorization": "..."` / xi-api-key.
|
|
42
|
+
const HEADER_PATTERN = new RegExp(String.raw `((?:authorization|x-api-key|xi-api-key)\\?["']?\s*[:=]\s*\\?["']?)(?:(?:Bearer|Basic|Token)\s+)?[^\s"'\\,;]+`, 'gi');
|
|
43
|
+
// NAME=value where NAME looks like a secret variable (ASSETHUB_API_KEY, AWS_SECRET_ACCESS_KEY, ...).
|
|
44
|
+
const ASSIGNMENT_PATTERN = new RegExp(String.raw `\b([A-Za-z0-9_]*(?:API_KEY|APIKEY|SECRET|_KEY|TOKEN|PASSWORD|PASSWD|CREDENTIALS?|DSN|DATABASE_URL)[A-Za-z0-9_]*)(\s*=\s*)(?:\\?["'])?[^\s"'\\]+`, 'gi');
|
|
45
|
+
// A JSON key that names a secret: password, secret, api_key/apiKey, token,
|
|
46
|
+
// clientSecret, service_role_key, AWS_SECRET_ACCESS_KEY, ... ("input_tokens" does not match).
|
|
47
|
+
const SECRET_KEY = String.raw `[A-Za-z0-9_-]*(?:api[_-]?key|secret|token|password|passwd|[_-]key|credentials?)`;
|
|
48
|
+
// "key": "value" at the top level of a JSON line. The value may contain
|
|
49
|
+
// escapes; only the string contents are replaced, so the JSON stays valid.
|
|
50
|
+
const JSON_FIELD_PATTERN = new RegExp(String.raw `("${SECRET_KEY}"\s*:\s*")((?:[^"\\]|\\.)*)(")`, 'gi');
|
|
51
|
+
// The same field inside a JSON string that itself holds JSON (tool inputs are
|
|
52
|
+
// often serialised twice): \"key\": \"value\".
|
|
53
|
+
const ESCAPED_JSON_FIELD_PATTERN = new RegExp(String.raw `(\\"${SECRET_KEY}\\"\s*:\s*\\")((?:[^"\\]|\\\\|\\[^"\\])*)(\\")`, 'gi');
|
|
54
|
+
export const redactText = (text) => {
|
|
55
|
+
let out = text;
|
|
56
|
+
out = out.replace(PRIVATE_KEY_PATTERN, REDACTED);
|
|
57
|
+
out = out.replace(URL_CREDENTIALS_PATTERN, (_m, prefix) => `${prefix}${REDACTED}@`);
|
|
58
|
+
out = out.replace(ESCAPED_JSON_FIELD_PATTERN, (_m, open, _v, close) => `${open}${REDACTED}${close}`);
|
|
59
|
+
out = out.replace(JSON_FIELD_PATTERN, (_m, open, _v, close) => `${open}${REDACTED}${close}`);
|
|
60
|
+
out = out.replace(HEADER_PATTERN, (_m, prefix) => `${prefix}${REDACTED}`);
|
|
61
|
+
out = out.replace(ASSIGNMENT_PATTERN, (_m, name, eq) => `${name}${eq}${REDACTED}`);
|
|
62
|
+
for (const pattern of WHOLE_MATCH_PATTERNS)
|
|
63
|
+
out = out.replace(pattern, REDACTED);
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
/** Redact a whole JSONL document, one line at a time. */
|
|
67
|
+
export const redactJsonl = (raw) => raw.split('\n').map(redactText).join('\n');
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { HookInput, SessionMeta } from './types.js';
|
|
2
|
+
import type { UploadOptions } from './upload.js';
|
|
3
|
+
export declare const MAX_IMAGES = 50;
|
|
4
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
5
|
+
export declare const DEFAULT_RETENTION_DAYS = 30;
|
|
6
|
+
export type SaveSessionOptions = {
|
|
7
|
+
/** Raw hook JSON as read from stdin. */
|
|
8
|
+
stdin?: string;
|
|
9
|
+
/** Or the already-parsed fields. */
|
|
10
|
+
input?: HookInput;
|
|
11
|
+
home?: string;
|
|
12
|
+
now?: () => Date;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
upload?: Pick<UploadOptions, 'profile'>;
|
|
15
|
+
/**
|
|
16
|
+
* Starts `assethub hooks upload --session <dir> --auto` outside the hook.
|
|
17
|
+
* SessionEnd runs while Claude Code exits under a short budget, so the
|
|
18
|
+
* network upload must not run inside it.
|
|
19
|
+
*/
|
|
20
|
+
startUpload?: (sessionDir: string, profile?: string) => void;
|
|
21
|
+
/** Starts the background retry of pending sessions on SessionStart. */
|
|
22
|
+
startSweep?: (profile?: string) => void;
|
|
23
|
+
};
|
|
24
|
+
export type SaveSessionResult = {
|
|
25
|
+
sessionDir: string;
|
|
26
|
+
uploadStarted?: boolean;
|
|
27
|
+
pruned?: number;
|
|
28
|
+
abandoned?: number;
|
|
29
|
+
event?: string;
|
|
30
|
+
skipped?: 'opt-out' | 'no-input';
|
|
31
|
+
};
|
|
32
|
+
/** SessionEnd: upload this session, then retry up to SWEEP_LIMIT older pending ones. */
|
|
33
|
+
export declare const spawnDetachedUpload: (sessionDir: string, profile?: string) => void;
|
|
34
|
+
/** SessionStart: retry up to SWEEP_LIMIT pending sessions. */
|
|
35
|
+
export declare const spawnDetachedSweep: (profile?: string) => void;
|
|
36
|
+
export declare const retentionDays: (env: NodeJS.ProcessEnv) => number | null;
|
|
37
|
+
/** Delete saved sessions last written more than `days` ago, whatever their upload state. */
|
|
38
|
+
export declare const pruneSessions: (root: string, now: Date, days: number, keep: string) => Promise<number>;
|
|
39
|
+
/** A session with no hook for this long, and never ended, was abandoned (crash, killed terminal). */
|
|
40
|
+
export declare const ABANDONED_AFTER_MS: number;
|
|
41
|
+
/** Sessions retried in the background per SessionStart / SessionEnd. */
|
|
42
|
+
export declare const SWEEP_LIMIT = 3;
|
|
43
|
+
/**
|
|
44
|
+
* Copy + redact the transcript Claude Code keeps at `meta.transcriptPath`, and
|
|
45
|
+
* collect its images. Runs once per SessionEnd and before an upload, never per
|
|
46
|
+
* turn. A copy shorter than the saved one is an older read (a transcript only
|
|
47
|
+
* grows) and never replaces it.
|
|
48
|
+
*/
|
|
49
|
+
export declare const materializeSession: (sessionDir: string, options?: {
|
|
50
|
+
env?: NodeJS.ProcessEnv;
|
|
51
|
+
transcriptPath?: string;
|
|
52
|
+
}) => Promise<SessionMeta | null>;
|
|
53
|
+
/**
|
|
54
|
+
* On SessionStart: a session that never reached SessionEnd and has been quiet
|
|
55
|
+
* for ABANDONED_AFTER_MS is marked for upload, so a crash or a killed terminal
|
|
56
|
+
* does not lose it.
|
|
57
|
+
*/
|
|
58
|
+
export declare const markAbandonedSessions: (root: string, now: Date, currentSessionId?: string) => Promise<number>;
|
|
59
|
+
export declare const saveSession: (options?: SaveSessionOptions) => Promise<SaveSessionResult>;
|
|
60
|
+
//# sourceMappingURL=save.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../../src/hooks/save.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAC,SAAS,EAAgB,WAAW,EAAC,MAAM,YAAY,CAAA;AACpE,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,aAAa,CAAA;AAE9C,eAAO,MAAM,UAAU,KAAK,CAAA;AAC5B,eAAO,MAAM,eAAe,QAAmB,CAAA;AAC/C,eAAO,MAAM,sBAAsB,KAAK,CAAA;AASxC,MAAM,MAAM,kBAAkB,GAAG;IAC/B,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,oCAAoC;IACpC,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,MAAM,CAAC,EAAE,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,CAAA;IACvC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5D,uEAAuE;IACvE,UAAU,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACxC,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,SAAS,GAAG,UAAU,CAAA;CACjC,CAAA;AAYD,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,EAAE,UAAU,MAAM,KAAG,IACgC,CAAA;AAE3G,8DAA8D;AAC9D,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,IAC+B,CAAA;AAErF,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,CAAC,UAAU,KAAG,MAAM,GAAG,IAM/D,CAAA;AAED,4FAA4F;AAC5F,eAAO,MAAM,aAAa,GACxB,MAAM,MAAM,EACZ,KAAK,IAAI,EACT,MAAM,MAAM,EACZ,MAAM,MAAM,KACX,OAAO,CAAC,MAAM,CAwBhB,CAAA;AAgFD,qGAAqG;AACrG,eAAO,MAAM,kBAAkB,QAAqB,CAAA;AACpD,wEAAwE;AACxE,eAAO,MAAM,WAAW,IAAI,CAAA;AAK5B;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAC7B,YAAY,MAAM,EAClB,UAAS;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAM,KAC/D,OAAO,CAAC,WAAW,GAAG,IAAI,CA6C5B,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAChC,MAAM,MAAM,EACZ,KAAK,IAAI,EACT,mBAAmB,MAAM,KACxB,OAAO,CAAC,MAAM,CAmBhB,CAAA;AAED,eAAO,MAAM,WAAW,GAAU,UAAS,kBAAuB,KAAG,OAAO,CAAC,iBAAiB,CAiF7F,CAAA"}
|