@assethub/cli 0.1.31 → 0.1.33

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 CHANGED
@@ -378,12 +378,19 @@ assembly was made. Follow a run started without `--wait` with
378
378
  While a V4 run works, `--wait`, `runs watch` and `runs get` show where it is.
379
379
  The receipt's `progress` field has the phase, a one-line summary, each part's
380
380
  state and the Blender assembly rounds, in the words of the canvas Part Composer
381
- card. `--wait` and `runs watch` print a stderr line only when it changes:
381
+ card. `--wait` and `runs watch` print a stderr line, with the local time, only
382
+ when the run really moves on (a new step, another mesh ready, a part kept with
383
+ an issue, the end); part states that come and go do not print:
382
384
 
383
385
  ```text
384
- [run] 557d2a58-… running · Step 3 of 4 · Making the parts · 3 of 9 meshes ready · 4 redoing, 2 checking
386
+ 19:52 run 557d2a58 3/4 Making the parts · 3 of 9 meshes ready
387
+ 19:58 run 557d2a58 ⚠ kept with an issue: Cropped top · open hem at the lower chest
388
+ 20:05 run 557d2a58 ✗ FAILED · at 3/4 Making the parts · Blender assembly failed
385
389
  ```
386
390
 
391
+ `production batch --wait` prints the same lines per image, named by letter and
392
+ file name, and a status table every 5 minutes; see `docs/guides/production-batch.md`.
393
+
387
394
  ```sh
388
395
  assethub runs watch <run-id> --timeout-ms 7200000 --download --out-dir ./character
389
396
  assethub runs get <run-id> # JSON receipt, progress included
@@ -1 +1 @@
1
- {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/hooks/command.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IACjC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;IAChB,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5D,UAAU,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACxC,CAAA;AA8CD,eAAO,MAAM,eAAe,GAC1B,MAAM,MAAM,EAAE,EACd,OAAM,gBAAqB,KAC1B,OAAO,CAAC,MAAM,CAkIhB,CAAA"}
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/hooks/command.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IACjC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;IAChB,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5D,UAAU,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACxC,CAAA;AA+CD,eAAO,MAAM,eAAe,GAC1B,MAAM,MAAM,EAAE,EACd,OAAM,gBAAqB,KAC1B,OAAO,CAAC,MAAM,CAiIhB,CAAA"}
@@ -1,11 +1,12 @@
1
1
  // `assethub hooks <subcommand>` — the registration in src/index.ts just calls
2
2
  // runHooksCommand(argv).
3
- import { installHooks, SESSION_SAVE_DISCLOSURE, uninstallHooks } from './install.js';
3
+ import { installHooks, sessionSaveDisclosure, uninstallHooks } from './install.js';
4
4
  import { listSessionDirs, readMeta } from './paths.js';
5
5
  import { saveSession } from './save.js';
6
6
  import { canUploadSessions, resolveUploadAuth, uploadPending, uploadSession } from './upload.js';
7
7
  const USAGE = 'Usage: assethub hooks <install|uninstall|save|upload|list|status> [--client claude|codex] [--json]\n' +
8
- ' save [--transcript <path> --session-id <id>] (reads hook JSON from stdin without flags)\n' +
8
+ ' install|uninstall [--global] (the current folder by default; --global for every project)\n' +
9
+ ' save [--client codex] [--transcript <path> --session-id <id>] (reads hook JSON from stdin without flags)\n' +
9
10
  ' upload [--session <dir> [--auto] [--sweep <n>]|--pending [--force] [--limit <n>]]\n' +
10
11
  ' --auto and --pending wait out earlier failures (1h, 2h, 4h … up to a day); --force retries now';
11
12
  const parseFlags = (args) => {
@@ -68,31 +69,32 @@ export const runHooksCommand = async (argv, deps = {}) => {
68
69
  write('--client claude|codex is required\n');
69
70
  return 2;
70
71
  }
72
+ // Sessions are saved only where the hooks are: this folder, unless --global.
73
+ const scope = flags.global === true ? {} : { projectDir: deps.cwd ?? process.cwd() };
74
+ const paths = { home: deps.home, codexHome: (deps.env ?? process.env).CODEX_HOME?.trim() || undefined };
71
75
  if (sub === 'install') {
72
76
  // Only accounts that may upload sessions (internal for now) can turn
73
77
  // session saving on; for anyone else the hooks would only pile up.
74
- if (client === 'claude') {
75
- const auth = await resolveUploadAuth(uploadOptions);
76
- if (!auth || !(await canUploadSessions(auth, deps.fetchImpl))) {
77
- write('Session saving is not available for this account.\n');
78
- return 1;
79
- }
78
+ const auth = await resolveUploadAuth(uploadOptions);
79
+ if (!auth || !(await canUploadSessions(auth, deps.fetchImpl))) {
80
+ write('Session saving is not available for this account.\n');
81
+ return 1;
80
82
  }
81
- const result = await installHooks({
82
- client: client,
83
- home: deps.home,
84
- cliPath: deps.cliPath,
85
- });
86
- out(result.installed && client === 'claude'
87
- ? `${result.detail}\n${SESSION_SAVE_DISCLOSURE}`
88
- : result.detail, result);
89
- return result.installed || client === 'codex' ? 0 : 1;
83
+ const result = await installHooks({ ...scope, ...paths, client, cliPath: deps.cliPath });
84
+ out(result.installed ? `${result.detail}\n${sessionSaveDisclosure(scope, client)}` : result.detail, result);
85
+ return result.installed ? 0 : 1;
90
86
  }
91
- const result = await uninstallHooks({ client: client, home: deps.home });
87
+ const result = await uninstallHooks({ ...scope, ...paths, client });
92
88
  out(result.detail, result);
93
89
  return 0;
94
90
  }
95
91
  case 'save': {
92
+ // A misspelt client must not be saved as Claude. Exit 1, not 2: a
93
+ // hook's exit 2 can block the agent.
94
+ if (flags.client !== undefined && flags.client !== 'claude' && flags.client !== 'codex') {
95
+ write('Nothing saved: use --client claude or --client codex.\n');
96
+ return 1;
97
+ }
96
98
  let stdin;
97
99
  let input;
98
100
  if (typeof flags.transcript === 'string') {
@@ -109,6 +111,7 @@ export const runHooksCommand = async (argv, deps = {}) => {
109
111
  const result = await saveSession({
110
112
  stdin,
111
113
  input,
114
+ client: flags.client === 'codex' ? 'codex' : 'claude',
112
115
  home: deps.home,
113
116
  env: deps.env,
114
117
  now: deps.now,
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../src/hooks/graph.ts"],"names":[],"mappings":"AAkCA,eAAO,MAAM,oBAAoB,iBAAiB,CAAA;AAClD,eAAO,MAAM,iBAAiB,4BAA4B,CAAA;AAC1D,eAAO,MAAM,oBAAoB,yBAAyB,CAAA;AAC1D,sDAAsD;AACtD,eAAO,MAAM,sBAAsB,QAAkB,CAAA;AAIrD;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,QAAoB,CAAA;AAIvD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAGlC,KAAK,MAAM,GAAG;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAC,CAAA;AAC9F,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAQ7C,CAAA;AA4BD,eAAO,MAAM,cAAc,GAAI,gBAAgB,MAAM,KAAG,MAMvD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,EAAE,YAA4B,KAAG,MAAM,EAanF,CAAA;AAED,eAAO,MAAM,uBAAuB,GAClC,YAAY,MAAM,EAClB,UAAS;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAM,KACpC,OAAO,CAAC,MAAM,CAiLhB,CAAA;AAED,oEAAoE;AACpE,eAAO,MAAM,uBAAuB,GAAU,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAE9E,CAAA"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../src/hooks/graph.ts"],"names":[],"mappings":"AAkCA,eAAO,MAAM,oBAAoB,iBAAiB,CAAA;AAClD,eAAO,MAAM,iBAAiB,4BAA4B,CAAA;AAC1D,eAAO,MAAM,oBAAoB,yBAAyB,CAAA;AAC1D,sDAAsD;AACtD,eAAO,MAAM,sBAAsB,QAAkB,CAAA;AAIrD;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,QAAoB,CAAA;AAIvD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAGlC,KAAK,MAAM,GAAG;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAC,CAAA;AAC9F,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAQ7C,CAAA;AA4BD,eAAO,MAAM,cAAc,GAAI,gBAAgB,MAAM,KAAG,MAMvD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,EAAE,YAA4B,KAAG,MAAM,EAanF,CAAA;AAED,eAAO,MAAM,uBAAuB,GAClC,YAAY,MAAM,EAClB,UAAS;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAM,KACpC,OAAO,CAAC,MAAM,CAmLhB,CAAA;AAED,oEAAoE;AACpE,eAAO,MAAM,uBAAuB,GAAU,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAE9E,CAAA"}
@@ -103,6 +103,8 @@ export const buildSessionGraphFolder = async (sessionDir, options = {}) => {
103
103
  if (!meta)
104
104
  throw new Error(`No meta.json in ${sessionDir}`);
105
105
  const limits = compactLimits(options.compactLevel ?? 0);
106
+ // A session an older CLI saved has no client: it came from Claude Code.
107
+ const agentTag = `agent:${meta.client ?? 'claude'}`;
106
108
  const transcriptBytes = await readFile(join(sessionDir, 'transcript.jsonl'));
107
109
  const transcript = parseTranscript(transcriptBytes.toString('utf8'));
108
110
  await resetSessionGraphFolder(sessionDir);
@@ -134,7 +136,7 @@ export const buildSessionGraphFolder = async (sessionDir, options = {}) => {
134
136
  const id = `transcript_${pad(index)}`;
135
137
  const blobRef = await addBlob(part, TRANSCRIPT_BLOB_MIME, `transcript-${pad(index)}.jsonl`);
136
138
  nodes.push({
137
- id, artifactKind: 'file', semanticType: 'transcript', tags: ['session', 'agent:claude'],
139
+ id, artifactKind: 'file', semanticType: 'transcript', tags: ['session', agentTag],
138
140
  metadata: { part: index, parts: transcriptParts.length },
139
141
  payload: { blobRef },
140
142
  });
@@ -155,12 +157,12 @@ export const buildSessionGraphFolder = async (sessionDir, options = {}) => {
155
157
  }
156
158
  : item.kind === 'assistant'
157
159
  ? {
158
- id, artifactKind: 'text', tags: ['session', 'agent:claude'],
160
+ id, artifactKind: 'text', tags: ['session', agentTag],
159
161
  metadata: { role: 'assistant', timestamp: createdAt },
160
162
  payload: clip(redactText(item.text), limits.textChars),
161
163
  }
162
164
  : {
163
- id, artifactKind: 'run', tags: ['session', 'agent:claude', `tool:${item.toolName}`],
165
+ id, artifactKind: 'run', tags: ['session', agentTag, `tool:${item.toolName}`],
164
166
  metadata: { toolUseId: item.toolUseId, timestamp: createdAt },
165
167
  payload: { toolName: item.toolName, input: redactedInput(item.input, limits.toolInputBytes) },
166
168
  };
@@ -207,7 +209,7 @@ export const buildSessionGraphFolder = async (sessionDir, options = {}) => {
207
209
  const blobRef = await addBlob(bytes, image.mime, image.file.slice(0, 200));
208
210
  const id = `image_${pad(index)}`;
209
211
  nodes.push({
210
- id, artifactKind: 'image', tags: ['session', 'agent:claude'],
212
+ id, artifactKind: 'image', tags: ['session', agentTag],
211
213
  metadata: { sourcePath: redactText(image.original) },
212
214
  payload: { blobRef },
213
215
  });
@@ -215,7 +217,7 @@ export const buildSessionGraphFolder = async (sessionDir, options = {}) => {
215
217
  }
216
218
  nodes.push({
217
219
  id: sessionNodeId, artifactKind: 'console', semanticType: 'agent-session',
218
- tags: ['session', 'agent:claude'],
220
+ tags: ['session', agentTag],
219
221
  metadata: {
220
222
  sessionId: meta.sessionId, client: meta.client, lastEvent: meta.lastEvent,
221
223
  messageCount: messageNodes.length, skippedImages,
@@ -7,10 +7,26 @@ export declare const HOOK_TIMEOUT_SECONDS = 30;
7
7
  */
8
8
  export declare const SESSION_END_TIMEOUT_SECONDS = 10;
9
9
  export declare const MAX_SETTINGS_BACKUPS = 3;
10
+ /** Where the hooks go: one project folder, or every project when omitted (--global). */
11
+ export type HookScope = {
12
+ projectDir?: string;
13
+ };
14
+ /** Where each client keeps its config, per project and for the user. */
15
+ type ClientPaths = {
16
+ home?: string;
17
+ codexHome?: string;
18
+ };
10
19
  /** What `hooks install` and `setup` must tell the user before recording starts. */
11
- export declare const SESSION_SAVE_DISCLOSURE: string;
20
+ export declare const sessionSaveDisclosure: (scope: HookScope, client?: HookClient) => string;
12
21
  export declare const isOurCommand: (command: unknown) => boolean;
13
- export declare const hookCommand: (cliPath?: string) => string;
22
+ /** Codex hooks name their client, so the saved session is recorded as Codex. */
23
+ export declare const hookCommand: (cliPath?: string, client?: HookClient) => string;
24
+ /**
25
+ * A project install in the home folder would land in ~/.claude (or ~/.codex),
26
+ * which the client also reads for every project, so it is refused rather than
27
+ * silently global.
28
+ */
29
+ export declare const hookScopeError: (home: string | undefined, scope: HookScope, client?: HookClient, codexHome?: string) => Promise<string | undefined>;
14
30
  type HookEntry = {
15
31
  type: 'command';
16
32
  command: string;
@@ -19,22 +35,21 @@ type HookEntry = {
19
35
  };
20
36
  /**
21
37
  * SessionStart (sweep and retry), Stop (every turn) and PreCompact only mark or
22
- * spawn work, and run in the background so they never delay the agent.
38
+ * spawn work, and run in the background so they never delay Claude Code.
23
39
  * SessionEnd stays synchronous, because Claude Code is exiting and a
24
- * background hook could be cut off.
40
+ * background hook could be cut off. Codex hooks all run synchronously; the
41
+ * work is the same few local file writes.
25
42
  */
26
- export declare const hookEntry: (event: (typeof HOOK_EVENTS)[number], command: string) => HookEntry;
27
- export declare const installHooks: (options: {
43
+ export declare const hookEntry: (event: (typeof HOOK_EVENTS)[number], command: string, client?: HookClient) => HookEntry;
44
+ export declare const installHooks: (options: HookScope & ClientPaths & {
28
45
  client: HookClient;
29
- home?: string;
30
46
  cliPath?: string;
31
47
  }) => Promise<{
32
48
  installed: boolean;
33
49
  detail: string;
34
50
  }>;
35
- export declare const uninstallHooks: (options: {
51
+ export declare const uninstallHooks: (options: HookScope & ClientPaths & {
36
52
  client: HookClient;
37
- home?: string;
38
53
  }) => Promise<{
39
54
  removed: boolean;
40
55
  detail: string;
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/hooks/install.ts"],"names":[],"mappings":"AAUA,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,mFAAmF;AACnF,eAAO,MAAM,uBAAuB,QAQ0B,CAAA;AAa9D,eAAO,MAAM,YAAY,GAAI,SAAS,OAAO,KAAG,OACa,CAAA;AAI7D,eAAO,MAAM,WAAW,GAAI,UAAU,MAAM,KAAG,MACO,CAAA;AAiDtD,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;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,OAAO,CAAC,OAAO,WAAW,EAAE,MAAM,CAAC,EAAE,SAAS,MAAM,KAAG,SAGL,CAAA;AAE5E,eAAO,MAAM,YAAY,GAAU,SAAS;IAC1C,MAAM,EAAE,UAAU,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,KAAG,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAC,CAuC/C,CAAA;AAED,eAAO,MAAM,cAAc,GAAU,SAAS;IAC5C,MAAM,EAAE,UAAU,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,KAAG,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAC,CAwC7C,CAAA"}
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"}
@@ -1,10 +1,18 @@
1
- // Register / remove our Claude Code hooks in the user's settings.json.
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.
2
8
  //
3
9
  // Merge rules: keep every existing key and hook, identify our entry by its
4
10
  // command string (`<assethub> hooks save`), never duplicate it, and back the
5
11
  // 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';
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';
8
16
  import { resolveHome } from './paths.js';
9
17
  export const HOOK_EVENTS = ['SessionStart', 'Stop', 'SessionEnd', 'PreCompact'];
10
18
  export const HOOK_TIMEOUT_SECONDS = 30;
@@ -14,26 +22,121 @@ export const HOOK_TIMEOUT_SECONDS = 30;
14
22
  */
15
23
  export const SESSION_END_TIMEOUT_SECONDS = 10;
16
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');
17
34
  /** 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';
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
+ };
27
53
  const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
28
54
  // Only a command that runs the CLI itself: `assethub hooks save`, optionally a
29
55
  // (quoted) path to it. `echo assethub hooks save` is somebody else's hook.
30
56
  const OUR_COMMAND_RE = /^\s*(?:"(?:[^"]*[/\\])?assethub(?:\.js)?"|'(?:[^']*[/\\])?assethub(?:\.js)?'|(?:[^\s"']*[/\\])?assethub(?:\.js)?)\s+hooks\s+save(?:\s|$)/;
31
57
  export const isOurCommand = (command) => typeof command === 'string' && OUR_COMMAND_RE.test(command);
32
58
  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);
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);
37
140
  let raw;
38
141
  try {
39
142
  raw = await readFile(path, 'utf8');
@@ -56,10 +159,11 @@ const load = async (home) => {
56
159
  return { path, error: `${path} is not valid JSON; left untouched.` };
57
160
  }
58
161
  };
59
- const BACKUP_RE = /^settings\.json\.bak-\d+$/;
162
+ const escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
163
  const pruneBackups = async (path) => {
61
164
  const dir = dirname(path);
62
- const ours = (await readdir(dir)).filter(name => BACKUP_RE.test(name));
165
+ const backupRe = new RegExp(`^${escapeRe(basename(path))}\\.bak-\\d+$`);
166
+ const ours = (await readdir(dir)).filter(name => backupRe.test(name));
63
167
  const byAge = ours.sort((a, b) => Number(b.split('-').pop()) - Number(a.split('-').pop()));
64
168
  for (const name of byAge.slice(MAX_SETTINGS_BACKUPS))
65
169
  await rm(join(dir, name), { force: true });
@@ -78,26 +182,37 @@ const backupAndWrite = async (loaded, next) => {
78
182
  };
79
183
  /**
80
184
  * 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.
185
+ * spawn work, and run in the background so they never delay Claude Code.
82
186
  * SessionEnd stays synchronous, because Claude Code is exiting and a
83
- * background hook could be cut off.
187
+ * background hook could be cut off. Codex hooks all run synchronously; the
188
+ * work is the same few local file writes.
84
189
  */
85
- export const hookEntry = (event, command) => event === 'SessionEnd'
190
+ export const hookEntry = (event, command, client = 'claude') => event === 'SessionEnd'
86
191
  ? { type: 'command', command, timeout: SESSION_END_TIMEOUT_SECONDS }
87
- : { type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS, async: true };
192
+ : client === 'codex'
193
+ ? { type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS }
194
+ : { type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS, async: true };
88
195
  export const installHooks = async (options) => {
89
- if (options.client === 'codex')
90
- return { installed: false, detail: CODEX_DETAIL };
91
- const loaded = await load(options.home);
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);
92
201
  if ('error' in loaded)
93
202
  return { installed: false, detail: loaded.error };
94
- const command = hookCommand(options.cliPath);
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);
95
210
  const settings = structuredClone(loaded.settings);
96
211
  const hooks = isObject(settings.hooks) ? settings.hooks : {};
97
212
  let changed = false;
98
213
  for (const event of HOOK_EVENTS) {
99
214
  const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
100
- const wanted = hookEntry(event, command);
215
+ const wanted = hookEntry(event, command, client);
101
216
  let found = false;
102
217
  for (const group of groups) {
103
218
  if (!isObject(group) || !Array.isArray(group.hooks))
@@ -121,20 +236,17 @@ export const installHooks = async (options) => {
121
236
  }
122
237
  settings.hooks = hooks;
123
238
  if (!changed) {
124
- return { installed: true, detail: `Hooks already installed in ${loaded.path}` };
239
+ return { installed: true, detail: `Hooks already installed in ${loaded.path}.${notes}` };
125
240
  }
126
241
  await backupAndWrite(loaded, settings);
127
- return { installed: true, detail: `Installed ${HOOK_EVENTS.join(', ')} hooks in ${loaded.path}` };
242
+ return { installed: true, detail: `Installed ${HOOK_EVENTS.join(', ')} hooks in ${loaded.path}.${notes}` };
128
243
  };
129
244
  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);
245
+ const loaded = await load(options.client, options, options);
134
246
  if ('error' in loaded)
135
247
  return { removed: false, detail: loaded.error };
136
248
  if (!loaded.exists || !isObject(loaded.settings.hooks)) {
137
- return { removed: false, detail: 'No assethub hooks found.' };
249
+ return { removed: false, detail: `No assethub hooks found in ${loaded.path}.` };
138
250
  }
139
251
  const settings = structuredClone(loaded.settings);
140
252
  const hooks = settings.hooks;
@@ -166,7 +278,7 @@ export const uninstallHooks = async (options) => {
166
278
  delete hooks[event];
167
279
  }
168
280
  if (!changed)
169
- return { removed: false, detail: 'No assethub hooks found.' };
281
+ return { removed: false, detail: `No assethub hooks found in ${loaded.path}.` };
170
282
  if (Object.keys(hooks).length === 0)
171
283
  delete settings.hooks;
172
284
  await backupAndWrite(loaded, settings);
@@ -1,4 +1,4 @@
1
- import type { HookInput, SessionMeta } from './types.js';
1
+ import type { HookClient, HookInput, SessionMeta } from './types.js';
2
2
  import type { UploadOptions } from './upload.js';
3
3
  export declare const MAX_IMAGES = 50;
4
4
  export declare const MAX_IMAGE_BYTES: number;
@@ -8,6 +8,8 @@ export type SaveSessionOptions = {
8
8
  stdin?: string;
9
9
  /** Or the already-parsed fields. */
10
10
  input?: HookInput;
11
+ /** The agent the hook runs in; its hook command says so (`--client codex`). */
12
+ client?: HookClient;
11
13
  home?: string;
12
14
  now?: () => Date;
13
15
  env?: NodeJS.ProcessEnv;
@@ -1 +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"}
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"}
@@ -281,11 +281,12 @@ export const saveSession = async (options = {}) => {
281
281
  const canvasId = await readCanvasId(cwd, env);
282
282
  const sessionId = input.session_id;
283
283
  const transcriptPath = input.transcript_path;
284
+ const client = options.client ?? 'claude';
284
285
  const full = FULL_SAVE_EVENTS.has(event);
285
286
  const fresh = {
286
287
  sessionId,
287
288
  cwd,
288
- client: 'claude',
289
+ client,
289
290
  lastEvent: event,
290
291
  savedAt: now.toISOString(),
291
292
  status: 'saved',
@@ -297,7 +298,7 @@ export const saveSession = async (options = {}) => {
297
298
  ...latest,
298
299
  sessionId,
299
300
  cwd,
300
- client: 'claude',
301
+ client,
301
302
  transcriptPath,
302
303
  lastEvent: event,
303
304
  savedAt: now.toISOString(),
@@ -1 +1 @@
1
- {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../../src/hooks/transcript.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GACtB;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,GAClD;IAAC,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,GACrD;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,CAAA;AAE3F,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,mEAAmE;IACnE,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;CACpC,CAAA;AAID,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,KAAG,MAAM,EAGtD,CAAA;AAgBD,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,KAAG,gBAiD7C,CAAA"}
1
+ {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../../src/hooks/transcript.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GACtB;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,GAClD;IAAC,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,GACrD;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,CAAA;AAE3F,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,mEAAmE;IACnE,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;CACpC,CAAA;AAQD,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,KAAG,MAAM,EAGtD,CAAA;AAgBD,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,KAAG,gBAiD7C,CAAA"}
@@ -1,6 +1,10 @@
1
1
  // Parse a Claude Code transcript (JSONL) into the flat sequence the session
2
2
  // graph is built from.
3
- const IMAGE_PATH_RE = /(?<![\w.~-])(?:\/[^\s"'\\<>|:*?`()[\]{}]+)+\.(?:png|jpe?g|webp)\b/gi;
3
+ // One run of path characters (they include `/`), capped at Linux's PATH_MAX
4
+ // (4096) so a long line with no spaces, such as base64, is scanned in linear
5
+ // time. A nested `(?:/…+)+` backtracks exponentially there and hung the save
6
+ // on a Codex rollout.
7
+ const IMAGE_PATH_RE = /(?<![\w.~-])\/[^\s"'\\<>|:*?`()[\]{}]{0,4096}?\.(?:png|jpe?g|webp)\b/gi;
4
8
  export const extractImagePaths = (text) => {
5
9
  const found = text.match(IMAGE_PATH_RE) ?? [];
6
10
  return [...new Set(found)];
@@ -7,8 +7,18 @@ type Json = Record<string, unknown>;
7
7
  /** One transcript entry reduced to what the conversation needs, or undefined to drop it. */
8
8
  export declare const trimTranscriptEntry: (entry: unknown) => Json | undefined;
9
9
  /**
10
- * Trim a JSONL transcript. A file that is not a Claude Code transcript (for
11
- * example a Codex rollout saved by hand) is returned unchanged.
10
+ * One Codex rollout record as a conversation entry in the same shape as a
11
+ * trimmed Claude Code transcript, or undefined to drop it. Kept: the artist's
12
+ * prompts, the agent's replies, its tool calls and their output, and compaction
13
+ * summaries. Dropped: session metadata (base instructions, git, cwd), developer
14
+ * and context messages, reasoning, turn settings and the event stream, which
15
+ * repeats the messages.
16
+ */
17
+ export declare const codexEntry: (entry: unknown) => Json | undefined;
18
+ /**
19
+ * Trim a JSONL transcript: a Claude Code transcript, or a Codex rollout turned
20
+ * into the same conversation shape. A file in any other format is returned
21
+ * unchanged.
12
22
  */
13
23
  export declare const trimTranscript: (raw: string) => string;
14
24
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"trim.d.ts","sourceRoot":"","sources":["../../src/hooks/trim.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,eAAO,MAAM,iBAAiB,oBAAoB,CAAA;AAElD,KAAK,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAuDnC,4FAA4F;AAC5F,eAAO,MAAM,mBAAmB,GAAI,OAAO,OAAO,KAAG,IAAI,GAAG,SAqB3D,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAc5C,CAAA"}
1
+ {"version":3,"file":"trim.d.ts","sourceRoot":"","sources":["../../src/hooks/trim.ts"],"names":[],"mappings":"AAUA;;;GAGG;AACH,eAAO,MAAM,iBAAiB,oBAAoB,CAAA;AAElD,KAAK,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAuDnC,4FAA4F;AAC5F,eAAO,MAAM,mBAAmB,GAAI,OAAO,OAAO,KAAG,IAAI,GAAG,SAqB3D,CAAA;AA8DD;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,GAAI,OAAO,OAAO,KAAG,IAAI,GAAG,SAuClD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAgB5C,CAAA"}