@assethub/cli 0.1.30 → 0.1.32
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 +181 -0
- package/dist/hooks/graph.d.ts +40 -0
- package/dist/hooks/graph.d.ts.map +1 -0
- package/dist/hooks/graph.js +269 -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 +58 -0
- package/dist/hooks/install.d.ts.map +1 -0
- package/dist/hooks/install.js +286 -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 +62 -0
- package/dist/hooks/save.d.ts.map +1 -0
- package/dist/hooks/save.js +331 -0
- package/dist/hooks/transcript.d.ts +23 -0
- package/dist/hooks/transcript.d.ts.map +1 -0
- package/dist/hooks/transcript.js +78 -0
- package/dist/hooks/trim.d.ts +25 -0
- package/dist/hooks/trim.d.ts.map +1 -0
- package/dist/hooks/trim.js +220 -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 +1569 -165
- 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 +130 -0
- package/dist/setupCommand.d.ts.map +1 -0
- package/dist/setupCommand.js +569 -0
- package/dist/setupHooksBridge.d.ts +12 -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 @@
|
|
|
1
|
+
{"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/hooks/install.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,YAAY,CAAA;AAE1C,eAAO,MAAM,WAAW,+DAAgE,CAAA;AACxF,eAAO,MAAM,oBAAoB,KAAK,CAAA;AACtC;;;GAGG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAA;AAC7C,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,wFAAwF;AACxF,MAAM,MAAM,SAAS,GAAG;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAC,CAAA;AAE7C,wEAAwE;AACxE,KAAK,WAAW,GAAG;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,CAAA;AAkBtD,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,GAAI,OAAO,SAAS,EAAE,SAAQ,UAAqB,KAAG,MAmBvF,CAAA;AAWD,eAAO,MAAM,YAAY,GAAI,SAAS,OAAO,KAAG,OACa,CAAA;AAI7D,gFAAgF;AAChF,eAAO,MAAM,WAAW,GAAI,UAAU,MAAM,EAAE,SAAQ,UAAqB,KAAG,MACsB,CAAA;AAMpG;;;;GAIG;AACH,eAAO,MAAM,cAAc,GACzB,MAAM,MAAM,GAAG,SAAS,EACxB,OAAO,SAAS,EAChB,SAAQ,UAAqB,EAC7B,YAAY,MAAM,KACjB,OAAO,CAAC,MAAM,GAAG,SAAS,CAY5B,CAAA;AA6GD,KAAK,SAAS,GAAG;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,IAAI,CAAA;CAAC,CAAA;AAElF;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,GACpB,OAAO,CAAC,OAAO,WAAW,EAAE,MAAM,CAAC,EACnC,SAAS,MAAM,EACf,SAAQ,UAAqB,KAC5B,SAK2E,CAAA;AAE9E,eAAO,MAAM,YAAY,GAAU,SAAS,SAAS,GAAG,WAAW,GAAG;IACpE,MAAM,EAAE,UAAU,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,KAAG,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAC,CA8C/C,CAAA;AAED,eAAO,MAAM,cAAc,GAAU,SAAS,SAAS,GAAG,WAAW,GAAG;IACtE,MAAM,EAAE,UAAU,CAAA;CACnB,KAAG,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAC,CAqC7C,CAAA"}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Register / remove our session hooks.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code: one project's .claude/settings.local.json (the default for
|
|
4
|
+
// `hooks install`), or with --global the user's ~/.claude/settings.json.
|
|
5
|
+
// Codex: one project's .codex/hooks.json, or with --global
|
|
6
|
+
// $CODEX_HOME/hooks.json. Codex has no personal per-project file, so the
|
|
7
|
+
// project file is kept out of git (info/exclude) and a tracked one is refused.
|
|
8
|
+
//
|
|
9
|
+
// Merge rules: keep every existing key and hook, identify our entry by its
|
|
10
|
+
// command string (`<assethub> hooks save`), never duplicate it, and back the
|
|
11
|
+
// original file up before any change.
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { appendFile, copyFile, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
import { resolveHome } from './paths.js';
|
|
17
|
+
export const HOOK_EVENTS = ['SessionStart', 'Stop', 'SessionEnd', 'PreCompact'];
|
|
18
|
+
export const HOOK_TIMEOUT_SECONDS = 30;
|
|
19
|
+
/**
|
|
20
|
+
* SessionEnd hooks share a 1.5 s budget that a longer per-hook `timeout` raises
|
|
21
|
+
* (up to 60 s). The save itself is local; the upload runs in a detached process.
|
|
22
|
+
*/
|
|
23
|
+
export const SESSION_END_TIMEOUT_SECONDS = 10;
|
|
24
|
+
export const MAX_SETTINGS_BACKUPS = 3;
|
|
25
|
+
const CLIENT_NAME = { claude: 'Claude Code', codex: 'Codex' };
|
|
26
|
+
const PROJECT_DIR = { claude: '.claude', codex: '.codex' };
|
|
27
|
+
const PROJECT_FILE = { claude: 'settings.local.json', codex: 'hooks.json' };
|
|
28
|
+
const projectSettingsPath = (projectDir, client = 'claude') => join(resolve(projectDir), PROJECT_DIR[client], PROJECT_FILE[client]);
|
|
29
|
+
/** The client's user-level folder: ~/.claude, or $CODEX_HOME (default ~/.codex). */
|
|
30
|
+
const userConfigDir = (client, paths) => client === 'codex' ? (paths.codexHome ?? join(resolveHome(paths.home), '.codex')) : join(resolveHome(paths.home), '.claude');
|
|
31
|
+
const settingsPath = (client, paths, scope) => scope.projectDir
|
|
32
|
+
? projectSettingsPath(scope.projectDir, client)
|
|
33
|
+
: join(userConfigDir(client, paths), client === 'codex' ? 'hooks.json' : 'settings.json');
|
|
34
|
+
/** What `hooks install` and `setup` must tell the user before recording starts. */
|
|
35
|
+
export const sessionSaveDisclosure = (scope, client = 'claude') => {
|
|
36
|
+
const name = CLIENT_NAME[client];
|
|
37
|
+
const where = scope.projectDir
|
|
38
|
+
? `${projectSettingsPath(scope.projectDir, client)}. ` +
|
|
39
|
+
`Every ${name} session opened in ${resolve(scope.projectDir)} (and only there)`
|
|
40
|
+
: `${client === 'codex' ? '$CODEX_HOME/hooks.json' : '~/.claude/settings.json'} (--global). Every ${name} session on this machine, in any project,`;
|
|
41
|
+
const remove = scope.projectDir
|
|
42
|
+
? `\`assethub hooks uninstall --client ${client}\` from that folder`
|
|
43
|
+
: `\`assethub hooks uninstall --client ${client} --global\``;
|
|
44
|
+
return (`Session saving adds SessionStart, Stop, PreCompact and SessionEnd hooks to ${where} ` +
|
|
45
|
+
'is then copied to ~/.assethub/sessions/ when it ends ' +
|
|
46
|
+
'(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), ' +
|
|
47
|
+
'and uploaded to AssetHub as a coding-agent session only you can read (internal accounts only for now): the masked transcript, ' +
|
|
48
|
+
'your prompts, the agent\'s replies, its tool calls and images from the project. Uploads that fail are retried in the background ' +
|
|
49
|
+
'when a later session starts or ends. Secret masking is best effort. ' +
|
|
50
|
+
'Opt a project out with .assethub/no-session-save, or turn it off with ASSETHUB_SESSION_SAVE=off / ASSETHUB_SESSION_UPLOAD=off; ' +
|
|
51
|
+
`remove it with ${remove}.`);
|
|
52
|
+
};
|
|
53
|
+
const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
54
|
+
// Only a command that runs the CLI itself: `assethub hooks save`, optionally a
|
|
55
|
+
// (quoted) path to it. `echo assethub hooks save` is somebody else's hook.
|
|
56
|
+
const OUR_COMMAND_RE = /^\s*(?:"(?:[^"]*[/\\])?assethub(?:\.js)?"|'(?:[^']*[/\\])?assethub(?:\.js)?'|(?:[^\s"']*[/\\])?assethub(?:\.js)?)\s+hooks\s+save(?:\s|$)/;
|
|
57
|
+
export const isOurCommand = (command) => typeof command === 'string' && OUR_COMMAND_RE.test(command);
|
|
58
|
+
const quoteIfNeeded = (value) => (/\s/.test(value) ? JSON.stringify(value) : value);
|
|
59
|
+
/** Codex hooks name their client, so the saved session is recorded as Codex. */
|
|
60
|
+
export const hookCommand = (cliPath, client = 'claude') => `${quoteIfNeeded(cliPath ?? 'assethub')} hooks save${client === 'codex' ? ' --client codex' : ''}`;
|
|
61
|
+
// Symlinks are followed: a project path or a client folder that leads to the
|
|
62
|
+
// home folder is the home folder. A path that does not exist yet is taken as is.
|
|
63
|
+
const realOrResolved = async (path) => realpath(path).catch(() => resolve(path));
|
|
64
|
+
/**
|
|
65
|
+
* A project install in the home folder would land in ~/.claude (or ~/.codex),
|
|
66
|
+
* which the client also reads for every project, so it is refused rather than
|
|
67
|
+
* silently global.
|
|
68
|
+
*/
|
|
69
|
+
export const hookScopeError = async (home, scope, client = 'claude', codexHome) => {
|
|
70
|
+
if (!scope.projectDir)
|
|
71
|
+
return undefined;
|
|
72
|
+
const homeDir = resolveHome(home);
|
|
73
|
+
const [project, projectConfig, realHome, userConfig] = await Promise.all([
|
|
74
|
+
realOrResolved(scope.projectDir),
|
|
75
|
+
realOrResolved(join(scope.projectDir, PROJECT_DIR[client])),
|
|
76
|
+
realOrResolved(homeDir),
|
|
77
|
+
realOrResolved(userConfigDir(client, { home, codexHome })),
|
|
78
|
+
]);
|
|
79
|
+
return project === realHome || projectConfig === userConfig
|
|
80
|
+
? 'Run this inside a project folder: in your home folder the hooks would apply to every project. Use --global if that is what you want.'
|
|
81
|
+
: undefined;
|
|
82
|
+
};
|
|
83
|
+
const run = promisify(execFile);
|
|
84
|
+
const git = async (cwd, ...args) => run('git', ['-C', cwd, ...args]).then(({ stdout }) => stdout, () => undefined);
|
|
85
|
+
/**
|
|
86
|
+
* Codex reads hooks only from .codex/hooks.json, which a repository may share.
|
|
87
|
+
* Ours stay personal: a tracked file is refused, an untracked one goes into
|
|
88
|
+
* the repository's info/exclude (never the shared .gitignore).
|
|
89
|
+
*/
|
|
90
|
+
const keepCodexHooksOutOfGit = async (path) => {
|
|
91
|
+
const dir = dirname(dirname(path));
|
|
92
|
+
const found = await git(dir, 'rev-parse', '--show-toplevel', '--git-path', 'info/exclude');
|
|
93
|
+
if (!found)
|
|
94
|
+
return undefined; // not a repository, or no git
|
|
95
|
+
const [top, excludePath] = found.trim().split('\n');
|
|
96
|
+
const file = relative(await realOrResolved(top), join(await realOrResolved(dir), PROJECT_DIR.codex, PROJECT_FILE.codex));
|
|
97
|
+
if (file.startsWith('..'))
|
|
98
|
+
return undefined;
|
|
99
|
+
if ((await git(top, 'ls-files', '--', file))?.trim())
|
|
100
|
+
return `${path} is tracked by git, so the hooks would be shared with everyone using the repository; left untouched.`;
|
|
101
|
+
const exclude = resolve(dir, excludePath);
|
|
102
|
+
// The file and the backups an install leaves next to it.
|
|
103
|
+
const pattern = `/${file.split(sep).join('/')}*`;
|
|
104
|
+
const current = await readFile(exclude, 'utf8').catch(() => '');
|
|
105
|
+
if (!current.split('\n').includes(pattern)) {
|
|
106
|
+
await mkdir(dirname(exclude), { recursive: true });
|
|
107
|
+
await appendFile(exclude, `${current && !current.endsWith('\n') ? '\n' : ''}${pattern}\n`);
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
};
|
|
111
|
+
/** `[projects."<path>"]` sections with `trust_level = "trusted"` in Codex's config.toml. */
|
|
112
|
+
const codexTrustedProjects = async (codexDir) => {
|
|
113
|
+
const toml = await readFile(join(codexDir, 'config.toml'), 'utf8').catch(() => '');
|
|
114
|
+
const trusted = new Set();
|
|
115
|
+
const sections = toml.split(/^(?=\[)/m);
|
|
116
|
+
for (const section of sections) {
|
|
117
|
+
const header = /^\[projects\."((?:[^"\\]|\\.)*)"\]/.exec(section);
|
|
118
|
+
if (header && /^\s*trust_level\s*=\s*"trusted"/m.test(section))
|
|
119
|
+
trusted.add(header[1].replace(/\\(.)/g, '$1'));
|
|
120
|
+
}
|
|
121
|
+
return trusted;
|
|
122
|
+
};
|
|
123
|
+
/** What Codex still needs before the hooks run. */
|
|
124
|
+
const codexNotes = async (codexDir, scope) => {
|
|
125
|
+
const review = ' Codex runs a new or changed hook only after you approve it: open /hooks in Codex.';
|
|
126
|
+
if (!scope.projectDir)
|
|
127
|
+
return review;
|
|
128
|
+
const dir = scope.projectDir;
|
|
129
|
+
const top = (await git(dir, 'rev-parse', '--show-toplevel'))?.trim();
|
|
130
|
+
const candidates = [resolve(dir), await realOrResolved(dir), ...(top ? [top, await realOrResolved(top)] : [])];
|
|
131
|
+
const trusted = await codexTrustedProjects(codexDir);
|
|
132
|
+
if (candidates.some(path => trusted.has(path)))
|
|
133
|
+
return review;
|
|
134
|
+
return (` Codex loads a project's hooks only when the project is trusted: add [projects."${resolve(dir)}"] ` +
|
|
135
|
+
`with trust_level = "trusted" to ${join(codexDir, 'config.toml')}, or trust the folder when Codex asks.` +
|
|
136
|
+
review);
|
|
137
|
+
};
|
|
138
|
+
const load = async (client, paths, scope) => {
|
|
139
|
+
const path = settingsPath(client, paths, scope);
|
|
140
|
+
let raw;
|
|
141
|
+
try {
|
|
142
|
+
raw = await readFile(path, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error?.code === 'ENOENT') {
|
|
146
|
+
return { path, exists: false, settings: {}, raw: '' };
|
|
147
|
+
}
|
|
148
|
+
return { path, error: `Could not read ${path}: ${error.message}` };
|
|
149
|
+
}
|
|
150
|
+
if (!raw.trim())
|
|
151
|
+
return { path, exists: true, settings: {}, raw };
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(raw);
|
|
154
|
+
if (!isObject(parsed))
|
|
155
|
+
return { path, error: `${path} is not a JSON object; left untouched.` };
|
|
156
|
+
return { path, exists: true, settings: parsed, raw };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { path, error: `${path} is not valid JSON; left untouched.` };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
163
|
+
const pruneBackups = async (path) => {
|
|
164
|
+
const dir = dirname(path);
|
|
165
|
+
const backupRe = new RegExp(`^${escapeRe(basename(path))}\\.bak-\\d+$`);
|
|
166
|
+
const ours = (await readdir(dir)).filter(name => backupRe.test(name));
|
|
167
|
+
const byAge = ours.sort((a, b) => Number(b.split('-').pop()) - Number(a.split('-').pop()));
|
|
168
|
+
for (const name of byAge.slice(MAX_SETTINGS_BACKUPS))
|
|
169
|
+
await rm(join(dir, name), { force: true });
|
|
170
|
+
};
|
|
171
|
+
// Write through a temp file and rename, so a concurrent reader (Claude Code)
|
|
172
|
+
// never sees a half-written settings.json.
|
|
173
|
+
const backupAndWrite = async (loaded, next) => {
|
|
174
|
+
await mkdir(dirname(loaded.path), { recursive: true });
|
|
175
|
+
if (loaded.exists) {
|
|
176
|
+
await copyFile(loaded.path, `${loaded.path}.bak-${Date.now()}`);
|
|
177
|
+
}
|
|
178
|
+
const tmp = join(dirname(loaded.path), `.${basename(loaded.path)}.${process.pid}.tmp`);
|
|
179
|
+
await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
180
|
+
await rename(tmp, loaded.path);
|
|
181
|
+
await pruneBackups(loaded.path);
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* SessionStart (sweep and retry), Stop (every turn) and PreCompact only mark or
|
|
185
|
+
* spawn work, and run in the background so they never delay Claude Code.
|
|
186
|
+
* SessionEnd stays synchronous, because Claude Code is exiting and a
|
|
187
|
+
* background hook could be cut off. Codex hooks all run synchronously; the
|
|
188
|
+
* work is the same few local file writes.
|
|
189
|
+
*/
|
|
190
|
+
export const hookEntry = (event, command, client = 'claude') => event === 'SessionEnd'
|
|
191
|
+
? { type: 'command', command, timeout: SESSION_END_TIMEOUT_SECONDS }
|
|
192
|
+
: client === 'codex'
|
|
193
|
+
? { type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS }
|
|
194
|
+
: { type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS, async: true };
|
|
195
|
+
export const installHooks = async (options) => {
|
|
196
|
+
const { client } = options;
|
|
197
|
+
const refused = await hookScopeError(options.home, options, client, options.codexHome);
|
|
198
|
+
if (refused)
|
|
199
|
+
return { installed: false, detail: refused };
|
|
200
|
+
const loaded = await load(client, options, options);
|
|
201
|
+
if ('error' in loaded)
|
|
202
|
+
return { installed: false, detail: loaded.error };
|
|
203
|
+
if (client === 'codex' && options.projectDir) {
|
|
204
|
+
const shared = await keepCodexHooksOutOfGit(loaded.path);
|
|
205
|
+
if (shared)
|
|
206
|
+
return { installed: false, detail: shared };
|
|
207
|
+
}
|
|
208
|
+
const notes = client === 'codex' ? await codexNotes(userConfigDir(client, options), options) : '';
|
|
209
|
+
const command = hookCommand(options.cliPath, client);
|
|
210
|
+
const settings = structuredClone(loaded.settings);
|
|
211
|
+
const hooks = isObject(settings.hooks) ? settings.hooks : {};
|
|
212
|
+
let changed = false;
|
|
213
|
+
for (const event of HOOK_EVENTS) {
|
|
214
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
215
|
+
const wanted = hookEntry(event, command, client);
|
|
216
|
+
let found = false;
|
|
217
|
+
for (const group of groups) {
|
|
218
|
+
if (!isObject(group) || !Array.isArray(group.hooks))
|
|
219
|
+
continue;
|
|
220
|
+
group.hooks = group.hooks.map((hook) => {
|
|
221
|
+
if (!isObject(hook) || !isOurCommand(hook.command))
|
|
222
|
+
return hook;
|
|
223
|
+
found = true;
|
|
224
|
+
// Rewrite entries from older installs (no async, old timeout, old path).
|
|
225
|
+
if (JSON.stringify(hook) === JSON.stringify(wanted))
|
|
226
|
+
return hook;
|
|
227
|
+
changed = true;
|
|
228
|
+
return { ...wanted };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (!found) {
|
|
232
|
+
groups.push({ hooks: [{ ...wanted }] });
|
|
233
|
+
changed = true;
|
|
234
|
+
}
|
|
235
|
+
hooks[event] = groups;
|
|
236
|
+
}
|
|
237
|
+
settings.hooks = hooks;
|
|
238
|
+
if (!changed) {
|
|
239
|
+
return { installed: true, detail: `Hooks already installed in ${loaded.path}.${notes}` };
|
|
240
|
+
}
|
|
241
|
+
await backupAndWrite(loaded, settings);
|
|
242
|
+
return { installed: true, detail: `Installed ${HOOK_EVENTS.join(', ')} hooks in ${loaded.path}.${notes}` };
|
|
243
|
+
};
|
|
244
|
+
export const uninstallHooks = async (options) => {
|
|
245
|
+
const loaded = await load(options.client, options, options);
|
|
246
|
+
if ('error' in loaded)
|
|
247
|
+
return { removed: false, detail: loaded.error };
|
|
248
|
+
if (!loaded.exists || !isObject(loaded.settings.hooks)) {
|
|
249
|
+
return { removed: false, detail: `No assethub hooks found in ${loaded.path}.` };
|
|
250
|
+
}
|
|
251
|
+
const settings = structuredClone(loaded.settings);
|
|
252
|
+
const hooks = settings.hooks;
|
|
253
|
+
let changed = false;
|
|
254
|
+
for (const event of Object.keys(hooks)) {
|
|
255
|
+
const groups = hooks[event];
|
|
256
|
+
if (!Array.isArray(groups))
|
|
257
|
+
continue;
|
|
258
|
+
const keptGroups = [];
|
|
259
|
+
let eventChanged = false;
|
|
260
|
+
for (const group of groups) {
|
|
261
|
+
if (!isObject(group) || !Array.isArray(group.hooks)) {
|
|
262
|
+
keptGroups.push(group);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const kept = group.hooks.filter(hook => !(isObject(hook) && isOurCommand(hook.command)));
|
|
266
|
+
if (kept.length === group.hooks.length) {
|
|
267
|
+
keptGroups.push(group);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
changed = true;
|
|
271
|
+
eventChanged = true;
|
|
272
|
+
if (kept.length > 0)
|
|
273
|
+
keptGroups.push({ ...group, hooks: kept });
|
|
274
|
+
}
|
|
275
|
+
if (keptGroups.length > 0)
|
|
276
|
+
hooks[event] = keptGroups;
|
|
277
|
+
else if (eventChanged)
|
|
278
|
+
delete hooks[event];
|
|
279
|
+
}
|
|
280
|
+
if (!changed)
|
|
281
|
+
return { removed: false, detail: `No assethub hooks found in ${loaded.path}.` };
|
|
282
|
+
if (Object.keys(hooks).length === 0)
|
|
283
|
+
delete settings.hooks;
|
|
284
|
+
await backupAndWrite(loaded, settings);
|
|
285
|
+
return { removed: true, detail: `Removed assethub hooks from ${loaded.path}` };
|
|
286
|
+
};
|
|
@@ -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,62 @@
|
|
|
1
|
+
import type { HookClient, 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
|
+
/** The agent the hook runs in; its hook command says so (`--client codex`). */
|
|
12
|
+
client?: HookClient;
|
|
13
|
+
home?: string;
|
|
14
|
+
now?: () => Date;
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
upload?: Pick<UploadOptions, 'profile'>;
|
|
17
|
+
/**
|
|
18
|
+
* Starts `assethub hooks upload --session <dir> --auto` outside the hook.
|
|
19
|
+
* SessionEnd runs while Claude Code exits under a short budget, so the
|
|
20
|
+
* network upload must not run inside it.
|
|
21
|
+
*/
|
|
22
|
+
startUpload?: (sessionDir: string, profile?: string) => void;
|
|
23
|
+
/** Starts the background retry of pending sessions on SessionStart. */
|
|
24
|
+
startSweep?: (profile?: string) => void;
|
|
25
|
+
};
|
|
26
|
+
export type SaveSessionResult = {
|
|
27
|
+
sessionDir: string;
|
|
28
|
+
uploadStarted?: boolean;
|
|
29
|
+
pruned?: number;
|
|
30
|
+
abandoned?: number;
|
|
31
|
+
event?: string;
|
|
32
|
+
skipped?: 'opt-out' | 'no-input';
|
|
33
|
+
};
|
|
34
|
+
/** SessionEnd: upload this session, then retry up to SWEEP_LIMIT older pending ones. */
|
|
35
|
+
export declare const spawnDetachedUpload: (sessionDir: string, profile?: string) => void;
|
|
36
|
+
/** SessionStart: retry up to SWEEP_LIMIT pending sessions. */
|
|
37
|
+
export declare const spawnDetachedSweep: (profile?: string) => void;
|
|
38
|
+
export declare const retentionDays: (env: NodeJS.ProcessEnv) => number | null;
|
|
39
|
+
/** Delete saved sessions last written more than `days` ago, whatever their upload state. */
|
|
40
|
+
export declare const pruneSessions: (root: string, now: Date, days: number, keep: string) => Promise<number>;
|
|
41
|
+
/** A session with no hook for this long, and never ended, was abandoned (crash, killed terminal). */
|
|
42
|
+
export declare const ABANDONED_AFTER_MS: number;
|
|
43
|
+
/** Sessions retried in the background per SessionStart / SessionEnd. */
|
|
44
|
+
export declare const SWEEP_LIMIT = 3;
|
|
45
|
+
/**
|
|
46
|
+
* Copy + redact the transcript Claude Code keeps at `meta.transcriptPath`, and
|
|
47
|
+
* collect its images. Runs once per SessionEnd and before an upload, never per
|
|
48
|
+
* turn. A copy shorter than the saved one is an older read (a transcript only
|
|
49
|
+
* grows) and never replaces it.
|
|
50
|
+
*/
|
|
51
|
+
export declare const materializeSession: (sessionDir: string, options?: {
|
|
52
|
+
env?: NodeJS.ProcessEnv;
|
|
53
|
+
transcriptPath?: string;
|
|
54
|
+
}) => Promise<SessionMeta | null>;
|
|
55
|
+
/**
|
|
56
|
+
* On SessionStart: a session that never reached SessionEnd and has been quiet
|
|
57
|
+
* for ABANDONED_AFTER_MS is marked for upload, so a crash or a killed terminal
|
|
58
|
+
* does not lose it.
|
|
59
|
+
*/
|
|
60
|
+
export declare const markAbandonedSessions: (root: string, now: Date, currentSessionId?: string) => Promise<number>;
|
|
61
|
+
export declare const saveSession: (options?: SaveSessionOptions) => Promise<SaveSessionResult>;
|
|
62
|
+
//# 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,UAAU,EAAE,SAAS,EAAgB,WAAW,EAAC,MAAM,YAAY,CAAA;AAChF,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,+EAA+E;IAC/E,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,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,CAkF7F,CAAA"}
|