gipity 1.0.429 → 1.1.1

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.
@@ -1,6 +1,6 @@
1
- import { Command } from 'commander';
2
- import { readFileSync, existsSync, statSync } from 'fs';
3
- import { dirname, extname, relative, resolve } from 'path';
1
+ import { Command, Option } from 'commander';
2
+ import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs';
3
+ import { dirname, extname, relative, resolve, sep } from 'path';
4
4
  import { post } from '../api.js';
5
5
  import { resolveProjectContext, getConfigPath, getProjectRoot, shouldIgnore } from '../config.js';
6
6
  import { SCRATCH_IGNORE } from '../setup.js';
@@ -167,19 +167,28 @@ sandboxCommand
167
167
  // an interpreter token, or a --file extension - see resolveLanguage().
168
168
  .option('--language <language>', 'Language: js, py, or bash (required unless pinned by an interpreter token or --file extension)')
169
169
  .option('--file <path>', 'Read the code body from a file instead of the inline <code> arg; --language is inferred from the extension when not given')
170
+ // `--code "<code>"` is the natural first guess for passing inline code (the
171
+ // positional arg is the canonical spelling). Accept it as a working alias
172
+ // rather than bouncing the guess into an unknown-option --help detour.
173
+ .addOption(new Option('--code <code>', 'Alias for the positional inline <code> arg').hideHelp())
170
174
  .option('--timeout <seconds>', 'Execution timeout in seconds', '30')
171
175
  .option('--input <path>', 'Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.', (v, prev) => [...(prev ?? []), v])
172
- // Commander maps a `--no-` prefixed flag to the un-prefixed camelCase name
173
- // (`opts.syncOutput`); the explicit `[]` default stops commander's negated-
174
- // boolean convention from defaulting it to `true`. Collected as an array so
175
- // the flag is repeatable.
176
- .option('--no-sync-output <glob>', 'Do not persist run outputs matching this glob back to the project (repeatable). For byproducts you want to inspect once but never keep, e.g. --no-sync-output "docs/preview*". Supports *, **, and dir/ prefixes.', (v, prev) => [...prev, v], [])
176
+ // Named "discard", not "no-sync": in Gipity vocabulary "sync" means the
177
+ // local<->cloud file sync, so a no-sync spelling reads as "give me the file
178
+ // locally, just don't sync it" - which is the tmp/ scratch path below, not
179
+ // this flag. Collected as an array so the flag is repeatable.
180
+ .option('--discard-output <glob>', 'Discard run outputs matching this glob entirely - not saved, not returned (repeatable). To LOOK at an output without keeping it, write it under tmp/ instead: scratch outputs land on local disk only and never enter the project. Supports *, **, and dir/ prefixes.', (v, prev) => [...prev, v], [])
177
181
  .option('--json', 'Output as JSON')
178
182
  .addHelpText('after', `
179
183
  By default the whole project is auto-mirrored into /work/ (up to 1 GB) -
180
184
  so your code can reference project files by their relative path, and any
181
185
  file you write lands back in the project. No manual copy needed.
182
186
 
187
+ Exception: tmp/ is scratch. An output written under tmp/ comes back to
188
+ your local tmp/ for inspection but is never saved to the project - use it
189
+ for previews and other look-once artifacts (no cleanup needed). To drop
190
+ an output entirely, --discard-output <glob>.
191
+
183
192
  Use --input only for projects over the auto-mirror cap, or when you want
184
193
  to restrict what the sandbox sees.
185
194
 
@@ -196,6 +205,11 @@ Examples:
196
205
  $ gipity sandbox run --language python \\
197
206
  "import pandas as pd; print(pd.read_csv('data/sales.csv').describe())"
198
207
 
208
+ # Preview a generated PDF without saving the preview: write it to tmp/
209
+ $ gipity sandbox run bash \\
210
+ "pdftoppm -png -r 90 docs/report.pdf tmp/preview"
211
+ # -> tmp/preview-1.png lands on local disk only; Read it, done.
212
+
199
213
  # Run a script file directly (language inferred from .py)
200
214
  $ gipity sandbox run --file build_report.py
201
215
  $ gipity sandbox run python build_report.py # same thing, interpreter shorthand
@@ -228,6 +242,14 @@ GCC/Rust).
228
242
  let inlineCode;
229
243
  let filePath = opts.file;
230
244
  let langFromInterp;
245
+ // --code alias: fold it into the positional slot before the shape checks.
246
+ if (opts.code !== undefined) {
247
+ if (args.length) {
248
+ console.error(clrError('Pass the code once: either positionally or via --code, not both'));
249
+ process.exit(1);
250
+ }
251
+ args = [opts.code];
252
+ }
231
253
  if (args.length >= 2 && INTERPRETERS[args[0].toLowerCase()] !== undefined) {
232
254
  langFromInterp = INTERPRETERS[args[0].toLowerCase()];
233
255
  const rest = args.slice(1).join(' ');
@@ -268,11 +290,11 @@ GCC/Rust).
268
290
  // This runs BEFORE the project sync and the server round trip below, so a
269
291
  // missing language costs nothing but the message.
270
292
  const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath, inlineCode });
271
- // Validate --no-sync-output globs while we're still pre-network: an empty
293
+ // Validate --discard-output globs while we're still pre-network: an empty
272
294
  // pattern (e.g. a quoting mishap) would silently match nothing server-side.
273
- const noSyncOutput = opts.syncOutput ?? [];
274
- if (noSyncOutput.some((g) => !g.trim())) {
275
- console.error(clrError('--no-sync-output requires a non-empty glob (e.g. --no-sync-output "docs/preview*")'));
295
+ const discardOutput = opts.discardOutput ?? [];
296
+ if (discardOutput.some((g) => !g.trim())) {
297
+ console.error(clrError('--discard-output requires a non-empty glob (e.g. --discard-output "*.ppm")'));
276
298
  process.exit(1);
277
299
  }
278
300
  // Args are good - now it's worth resolving (and announcing) the project.
@@ -311,8 +333,9 @@ GCC/Rust).
311
333
  cwd,
312
334
  // The filter must run SERVER-side (in the output extractor): skipping
313
335
  // only in the CLI would still write the files into project storage and
314
- // make every later sync propose deleting them.
315
- noSyncOutput: noSyncOutput.length ? noSyncOutput : undefined,
336
+ // make every later sync propose deleting them. (`noSyncOutput` is the
337
+ // wire name for --discard-output.)
338
+ noSyncOutput: discardOutput.length ? discardOutput : undefined,
316
339
  });
317
340
  const res = opts.json
318
341
  ? await doRun()
@@ -326,8 +349,26 @@ GCC/Rust).
326
349
  if (pulledLocal) {
327
350
  await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
328
351
  }
352
+ // Scratch outputs (tmp/ and friends) never enter the project: the server
353
+ // returns their bytes inline instead of persisting them, and we land them
354
+ // here. The scratch namespace is ignored by sync in both directions, so
355
+ // the file exists on local disk only - inspect it, no cleanup required.
356
+ const scratchWritten = [];
357
+ if (res.data.scratchFiles?.length) {
358
+ const base = resolve(getProjectRoot() ?? process.cwd());
359
+ for (const f of res.data.scratchFiles) {
360
+ const rel = f.path.replace(/\\/g, '/');
361
+ const abs = resolve(base, rel);
362
+ if (abs !== base && !abs.startsWith(base + sep))
363
+ continue; // traversal guard
364
+ mkdirSync(dirname(abs), { recursive: true });
365
+ writeFileSync(abs, Buffer.from(f.contentBase64, 'base64'));
366
+ scratchWritten.push(rel);
367
+ }
368
+ }
329
369
  if (opts.json) {
330
- console.log(JSON.stringify({ ...res.data, filesSynced: pulledLocal }));
370
+ const { scratchFiles: _omit, ...rest } = res.data;
371
+ console.log(JSON.stringify({ ...rest, scratchFiles: scratchWritten, filesSynced: pulledLocal }));
331
372
  }
332
373
  else {
333
374
  if (res.data.autoMirrorSkipped) {
@@ -367,8 +408,13 @@ GCC/Rust).
367
408
  console.log(dim("Not pulled locally - run 'gipity sync' to fetch them."));
368
409
  }
369
410
  }
411
+ if (scratchWritten.length > 0) {
412
+ console.log('\nScratch outputs (local only - never synced or deployed):');
413
+ for (const f of scratchWritten)
414
+ console.log(`${f}`);
415
+ }
370
416
  if (res.data.skippedOutputFiles && res.data.skippedOutputFiles.length > 0) {
371
- console.log(dim('\nNot persisted (--no-sync-output):'));
417
+ console.log(dim('\nDiscarded (--discard-output):'));
372
418
  for (const f of res.data.skippedOutputFiles)
373
419
  console.log(dim(`${f}`));
374
420
  }
@@ -1,12 +1,17 @@
1
1
  /**
2
- * `gipity setup` — get this computer ready as a relay, then stop.
2
+ * `gipity connect` — connect this computer to gipity.ai, then stop.
3
3
  *
4
- * Runs the SAME first steps as `gipity claude` (log in, then pair + start the
4
+ * Runs the SAME first steps as `gipity build` (log in, then pair + start the
5
5
  * relay + install the OS login service) but does NOT pick a project or launch
6
- * Claude Code. For the user who wants their machine available as a relay in the
7
- * web CLI without being dropped into a coding session.
6
+ * an agent. For the user who wants their machine drivable from the web CLI
7
+ * without being dropped into a coding session.
8
8
  *
9
- * Shares one implementation with `gipity claude`: auth via `login-flow.ts`,
9
+ * Named `connect` because "setup" and "init" are synonyms in plain English -
10
+ * the machine-scoped verb needed its own word (and it matches `gipity
11
+ * payments connect`). `setup` stays as a hidden alias for muscle memory and
12
+ * old docs.
13
+ *
14
+ * Shares one implementation with `gipity build`: auth via `login-flow.ts`,
10
15
  * relay setup via `relay/onboarding.ts` (`runRelaySetup`). Nothing to maintain
11
16
  * twice.
12
17
  */
@@ -16,12 +21,13 @@ import { interactiveLogin } from '../login-flow.js';
16
21
  import { runRelaySetup } from '../relay/onboarding.js';
17
22
  import * as relayState from '../relay/state.js';
18
23
  import { bold, brand, success, muted, error as clrError } from '../colors.js';
19
- export const setupCommand = new Command('setup')
20
- .description('Set up this computer as a relay (no project, no launch)')
24
+ export const connectCommand = new Command('connect')
25
+ .alias('setup') // legacy name, kept working but not advertised
26
+ .description('Connect this computer to gipity.ai so the web CLI can drive it (no project, no launch)')
21
27
  .action(async () => {
22
28
  try {
23
29
  // Leading blank comes from the central output frame (installOutputFrame).
24
- console.log(` ${bold('Gipity setup')} ${muted('- get this computer ready as a relay')}`);
30
+ console.log(` ${bold('Gipity connect')} ${muted('- let gipity.ai drive this computer')}`);
25
31
  console.log('');
26
32
  // ── Step 1: Auth ──────────────────────────────────────────────────
27
33
  let auth = getAuth();
@@ -44,10 +50,10 @@ export const setupCommand = new Command('setup')
44
50
  if (enabled) {
45
51
  const running = relayState.isRelayEnabled() && !relayState.isPaused();
46
52
  console.log(` ${success('Done')} — your relay ${running ? 'is running in the background' : 'is set up'} and will start with your computer.`);
47
- console.log(` ${muted('Open')} ${brand('gipity.ai')} ${muted('and start a chat to drive Claude Code here. Manage it with `gipity relay status`.')}`);
53
+ console.log(` ${muted('Open')} ${brand('gipity.ai')} ${muted('and start a chat to drive your coding agent here. Manage it with `gipity relay status`.')}`);
48
54
  }
49
55
  else {
50
- console.log(` ${muted('No relay set up. Run `gipity setup` again anytime, or `gipity claude` to build with Gipity.')}`);
56
+ console.log(` ${muted('No relay set up. Run `gipity connect` again anytime, or `gipity build` to start building.')}`);
51
57
  }
52
58
  console.log('');
53
59
  }
@@ -12,14 +12,14 @@ import { Command } from 'commander';
12
12
  import { existsSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'fs';
13
13
  import { homedir, platform as osPlatform } from 'os';
14
14
  import { join, resolve } from 'path';
15
- import { spawnSyncCommand } from '../platform.js';
15
+ import { spawnSyncCommand, resolveCommand } from '../platform.js';
16
16
  import { post } from '../api.js';
17
17
  import { getAuth } from '../auth.js';
18
18
  import { confirm, getAutoConfirm } from '../utils.js';
19
19
  import { bold, brand, dim, success, error as clrError, muted } from '../colors.js';
20
20
  import * as relayState from '../relay/state.js';
21
21
  import { planFor, UnsupportedPlatformError } from '../relay/installers.js';
22
- import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, stripGipityHooks } from '../setup.js';
22
+ import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, stripGipityHooks, grokInstallState, agentSkillsState, AGENTS_SKILLS_DIR } from '../setup.js';
23
23
  /** Remove Gipity's entries from the user-scope Claude Code settings: the
24
24
  * plugin enablement, the marketplace registration, and any legacy hook
25
25
  * blocks older CLI versions wrote there. Surgical - everything else in the
@@ -219,7 +219,29 @@ export const uninstallCommand = new Command('uninstall')
219
219
  else {
220
220
  console.log(`${muted('No Gipity entries in Claude Code settings.')}`);
221
221
  }
222
- // 5. Wipe ~/.gipity/.
222
+ // 4b. Uninstall the Gipity plugin from Grok Build and remove the skills
223
+ // the CLI copied into the cross-agent ~/.agents/skills dir for Codex.
224
+ // Both best-effort: neither existing is the common case.
225
+ if (grokInstallState().exists) {
226
+ spawnSyncCommand(resolveCommand('grok'), ['plugin', 'uninstall', 'gipity', '--confirm'], {
227
+ stdio: 'ignore',
228
+ timeout: 60_000,
229
+ });
230
+ console.log(`${success('Gipity plugin removed from Grok.')}`);
231
+ }
232
+ const agentSkills = agentSkillsState();
233
+ if (agentSkills.skills.length) {
234
+ for (const name of agentSkills.skills) {
235
+ // Only names our manifest recorded - never someone else's skills.
236
+ try {
237
+ rmSync(join(AGENTS_SKILLS_DIR, name), { recursive: true, force: true });
238
+ }
239
+ catch { /* best-effort */ }
240
+ }
241
+ console.log(`${success(`Removed ${agentSkills.skills.length} Gipity skills from ~/.agents/skills.`)}`);
242
+ }
243
+ // 5. Wipe ~/.gipity/ (this also removes the agent-hooks scripts and the
244
+ // agent-skills manifest, which live under it).
223
245
  if (existsSync(gipityDir)) {
224
246
  try {
225
247
  rmSync(gipityDir, { recursive: true, force: true });
@@ -1,31 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Internal hook runner - invoked by Claude Code's lifecycle hooks
3
+ * Internal hook runner - invoked by the coding agent's lifecycle hooks
4
4
  * (SessionStart, Stop, SubagentStop, SessionEnd, and a throttled
5
- * PostToolUse for mid-run flushing) to mirror a terminal Claude Code
6
- * session into the Gipity server so the web CLI can display it read-only.
5
+ * PostToolUse for mid-run flushing) to mirror a terminal agent session
6
+ * into the Gipity server so the web CLI can display it read-only.
7
+ *
8
+ * Works for every supported agent: Claude Code and Grok Build fire it
9
+ * through the Gipity plugin's hooks (Grok runs Claude-format plugin hooks
10
+ * natively; capture.cjs rewrites the source to 'grok' when it sees
11
+ * GROK_HOOK_EVENT), Codex through the project's .codex/hooks.json. Each
12
+ * source has its own transcript parser under cli/src/capture/sources/.
7
13
  *
8
14
  * Not a user-facing `gipity` subcommand by design: users never invoke
9
- * this directly. The Gipity Claude Code plugin's capture hook script
10
- * (skills repo hooks/scripts/capture.cjs) resolves this file inside the
11
- * installed CLI at fire time and runs it - so the capture logic versions
12
- * with the CLI, not the plugin.
15
+ * this directly. The hook scripts (skills repo hooks/scripts/capture.cjs)
16
+ * resolve this file inside the installed CLI at fire time and run it - so
17
+ * the capture logic versions with the CLI, not the plugin.
13
18
  *
14
19
  * Usage:
15
20
  * node capture-runner.js <source> <event>
16
- * source: 'claude-code' (today) | future: 'codex',
21
+ * source: 'claude-code' | 'codex' | 'grok'
17
22
  * event: 'session-start' | 'stop' | 'subagent-stop' | 'session-end' | 'post-tool-use' | 'pre-compact'
18
23
  *
19
24
  * Conversation binding, in order:
20
- * 1. GIPITY_CONVERSATION_GUID env var - set by `gipity claude`, which
21
- * created/reused the conversation before spawning Claude Code.
25
+ * 1. GIPITY_CONVERSATION_GUID env var - set by `gipity build`, which
26
+ * created/reused the conversation before spawning the agent.
22
27
  * 2. A session_id → conv mapping persisted in the capture-state dir by
23
28
  * an earlier event of this session.
24
- * 3. Self-arm: the session was launched WITHOUT `gipity claude` (bare
25
- * `claude` in a linked project dir). Resolve the project from
26
- * .gipity.json and ask the server to bind this session_id to a
27
- * conversation (POST /remote-sessions/resolve), then persist the
28
- * mapping. This is what makes bare `claude` sessions record.
29
+ * 3. Self-arm: the session was launched WITHOUT `gipity build` (bare
30
+ * `claude` / `codex` / `grok` in a linked project dir). Resolve the
31
+ * project from .gipity.json and ask the server to bind this
32
+ * session_id to a conversation (POST /remote-sessions/resolve), then
33
+ * persist the mapping. This is what makes bare agent sessions record.
29
34
  *
30
35
  * Graceful no-ops (exit 0 silently):
31
36
  * - GIPITY_CAPTURE=off - the relay daemon owns capture for this run
@@ -36,16 +41,43 @@
36
41
  * - Anything unexpected (parse error, network error, etc.). We must
37
42
  * not break the user's interactive session.
38
43
  */
39
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, utimesSync, createReadStream, } from 'fs';
44
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, utimesSync, createReadStream, } from 'fs';
40
45
  import { homedir } from 'os';
41
46
  import { join } from 'path';
42
47
  import { getDevice } from '../relay/state.js';
43
48
  import { deviceFetch } from '../relay/device-http.js';
44
49
  import { ImageBlockRewriter } from '../relay/media-upload.js';
45
50
  import { getConfig } from '../config.js';
46
- import { parseTranscript, } from '../capture/sources/claude-code.js';
51
+ import { parseTranscript as parseClaudeTranscript, } from '../capture/sources/claude-code.js';
52
+ import { parseTranscript as parseCodexTranscript } from '../capture/sources/codex.js';
53
+ import { parseTranscript as parseGrokTranscript } from '../capture/sources/grok.js';
47
54
  const CAPTURE_DIR = join(homedir(), '.gipity', 'capture-state');
48
55
  const INGEST_BATCH_MAX = 100; // server caps at 200; stay comfortably under
56
+ export const CAPTURE_SOURCES = {
57
+ 'claude-code': {
58
+ serverSource: 'claude_code',
59
+ displayName: 'Claude Code',
60
+ parse: (content, afterUuid) => parseClaudeTranscript(content, afterUuid),
61
+ },
62
+ codex: {
63
+ serverSource: 'codex',
64
+ displayName: 'Codex',
65
+ // Codex hook payloads always carry transcript_path (the rollout file).
66
+ parse: (content, afterUuid) => parseCodexTranscript(content, afterUuid),
67
+ },
68
+ grok: {
69
+ serverSource: 'grok',
70
+ displayName: 'Grok',
71
+ parse: (content, afterUuid, hook) => parseGrokTranscript(content, afterUuid, { sessionId: hook.session_id }),
72
+ // Grok's session dir is derivable: ~/.grok/sessions/<urlencoded-cwd>/<sid>/
73
+ resolveTranscriptPath: (hook) => {
74
+ if (!hook.session_id)
75
+ return null;
76
+ const cwd = hook.cwd ?? process.cwd();
77
+ return join(homedir(), '.grok', 'sessions', encodeURIComponent(cwd), hook.session_id, 'chat_history.jsonl');
78
+ },
79
+ },
80
+ };
49
81
  // PostToolUse fires after every tool call. We flush on it so a session that is
50
82
  // killed/crashes mid-run (e.g. a long headless `gipity claude -p` build that
51
83
  // hits a timeout) still has its transcript in the DB - Stop/SessionEnd only
@@ -212,13 +244,14 @@ function deleteSessionMap(sessionId) {
212
244
  * already bound to this session_id (resume), the project's still-empty
213
245
  * placeholder, or a fresh one. Returns null on any failure - capture is
214
246
  * best-effort and must never break the session. */
215
- async function resolveFromServer(projectGuid, hook) {
247
+ async function resolveFromServer(projectGuid, hook, serverSource) {
216
248
  let res;
217
249
  try {
218
250
  res = await deviceFetch('POST', '/remote-sessions/resolve', {
219
251
  project_guid: projectGuid,
220
252
  session_id: hook.session_id,
221
253
  cwd: hook.cwd,
254
+ source: serverSource,
222
255
  }, 15_000);
223
256
  }
224
257
  catch {
@@ -240,7 +273,7 @@ async function resolveFromServer(projectGuid, hook) {
240
273
  * the same crash-safe lock the flushers use, so concurrent hooks (Stop +
241
274
  * SubagentStop) can't each mint a conversation: the loser polls for the
242
275
  * winner's mapping instead of racing the server. */
243
- export async function resolveConvGuid(hook, sleep = (ms) => new Promise(r => setTimeout(r, ms))) {
276
+ export async function resolveConvGuid(hook, serverSource = 'claude_code', sleep = (ms) => new Promise(r => setTimeout(r, ms))) {
244
277
  const fromEnv = process.env.GIPITY_CONVERSATION_GUID;
245
278
  if (fromEnv)
246
279
  return fromEnv;
@@ -276,7 +309,7 @@ export async function resolveConvGuid(hook, sleep = (ms) => new Promise(r => set
276
309
  const raced = readSessionMap(sessionId);
277
310
  if (raced)
278
311
  return raced;
279
- const conv = await resolveFromServer(config.projectGuid, hook);
312
+ const conv = await resolveFromServer(config.projectGuid, hook, serverSource);
280
313
  if (conv)
281
314
  writeSessionMap(sessionId, conv);
282
315
  return conv;
@@ -338,7 +371,7 @@ async function handleSessionStart(convGuid, hook) {
338
371
  }];
339
372
  await postEntries(convGuid, entries);
340
373
  }
341
- async function handleStopFamily(convGuid, hook, isSubagent, minIntervalMs = 0) {
374
+ async function handleStopFamily(convGuid, src, hook, isSubagent, minIntervalMs = 0) {
342
375
  void isSubagent;
343
376
  if (!hook.transcript_path || !existsSync(hook.transcript_path))
344
377
  return;
@@ -355,11 +388,11 @@ async function handleStopFamily(convGuid, hook, isSubagent, minIntervalMs = 0) {
355
388
  try {
356
389
  const state = readState(convGuid) ?? { last_uuid: null };
357
390
  const content = await readWholeFile(hook.transcript_path);
358
- let result = parseTranscript(content, state.last_uuid);
391
+ let result = src.parse(content, state.last_uuid, hook);
359
392
  if (!result.foundWatermark && state.last_uuid !== null) {
360
393
  // Transcript rotated (/clear or compact) - watermark isn't present.
361
394
  // Replay from top; server dedupes via the source_uuid unique index.
362
- result = parseTranscript(content, null);
395
+ result = src.parse(content, null, hook);
363
396
  }
364
397
  // Base64 image blocks (Read-of-image tool results) upload to VFS and
365
398
  // become image_ref blocks — same no-inline-base64 chokepoint as the
@@ -379,18 +412,17 @@ async function handleStopFamily(convGuid, hook, isSubagent, minIntervalMs = 0) {
379
412
  release();
380
413
  }
381
414
  }
382
- async function handleSessionEnd(convGuid, hook, source) {
415
+ async function handleSessionEnd(convGuid, src, hook) {
383
416
  // Flush any tail lines one last time - SessionEnd fires after the final
384
417
  // Stop, so there's usually nothing new, but a race between Stop and
385
418
  // SessionEnd could leave lines behind.
386
419
  if (hook.transcript_path && existsSync(hook.transcript_path)) {
387
- await handleStopFamily(convGuid, hook, false);
420
+ await handleStopFamily(convGuid, src, hook, false);
388
421
  }
389
422
  const sessionId = hook.session_id ?? 'unknown';
390
- const finishedLabel = displayName(source);
391
423
  const entries = [{
392
424
  kind: 'system',
393
- content: `${finishedLabel} finished`,
425
+ content: `${src.displayName} finished`,
394
426
  source_uuid: `${sessionId}-end`,
395
427
  }];
396
428
  await postEntries(convGuid, entries);
@@ -398,10 +430,54 @@ async function handleSessionEnd(convGuid, hook, source) {
398
430
  if (hook.session_id)
399
431
  deleteSessionMap(hook.session_id);
400
432
  }
401
- function displayName(source) {
402
- if (source === 'claude-code')
403
- return 'Claude Code';
404
- return source;
433
+ // Codex has no SessionEnd hook, so its capture-state/session-map files are
434
+ // never cleaned by handleSessionEnd. Sweep anything stale on the way through
435
+ // - the files are tiny, so a generous TTL is fine; a live session's state is
436
+ // rewritten on every flush and never gets this old.
437
+ const STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
438
+ function sweepStaleState() {
439
+ let names;
440
+ try {
441
+ names = readdirSync(CAPTURE_DIR);
442
+ }
443
+ catch {
444
+ return;
445
+ }
446
+ const cutoff = Date.now() - STATE_TTL_MS;
447
+ for (const name of names) {
448
+ const p = join(CAPTURE_DIR, name);
449
+ try {
450
+ if (statSync(p).mtimeMs < cutoff)
451
+ unlinkSync(p);
452
+ }
453
+ catch { /* raced or unreadable - leave it */ }
454
+ }
455
+ }
456
+ /** Normalize a raw hook payload to snake_case HookInput. Claude Code and
457
+ * Codex deliver snake_case (`session_id`, `transcript_path`, `cwd`); Grok
458
+ * Build delivers camelCase (`sessionId`, `hookEventName`, …). Accept both
459
+ * so one runner serves every harness. */
460
+ export function normalizeHookInput(raw) {
461
+ if (!raw || typeof raw !== 'object')
462
+ return {};
463
+ const pick = (...keys) => {
464
+ for (const k of keys) {
465
+ const v = raw[k];
466
+ if (typeof v === 'string' && v)
467
+ return v;
468
+ }
469
+ return undefined;
470
+ };
471
+ const hook = {
472
+ session_id: pick('session_id', 'sessionId'),
473
+ transcript_path: pick('transcript_path', 'transcriptPath'),
474
+ cwd: pick('cwd', 'workingDirectory'),
475
+ hook_event_name: pick('hook_event_name', 'hookEventName'),
476
+ };
477
+ // Preserve extras the handlers peek at (e.g. pre-compact's `trigger`).
478
+ if (typeof raw.trigger === 'string')
479
+ hook.trigger = raw.trigger;
480
+ return hook;
405
481
  }
406
482
  async function main() {
407
483
  // Explicit capture opt-out. Set by the relay daemon's dispatch spawns
@@ -415,15 +491,28 @@ async function main() {
415
491
  const [source, event] = process.argv.slice(2);
416
492
  if (!source || !event)
417
493
  return;
494
+ const src = CAPTURE_SOURCES[source];
495
+ if (!src)
496
+ return; // unknown agent - silent no-op
418
497
  const stdin = await readStdin();
419
498
  let hook = {};
420
499
  if (stdin.trim()) {
421
500
  try {
422
- hook = JSON.parse(stdin);
501
+ hook = normalizeHookInput(JSON.parse(stdin));
423
502
  }
424
503
  catch { /* ignore - event may not require transcript */ }
425
504
  }
426
- const convGuid = await resolveConvGuid(hook);
505
+ // Agents whose hook payloads omit the transcript path (Grok) get it
506
+ // derived from the session id + cwd.
507
+ if (!hook.transcript_path && src.resolveTranscriptPath) {
508
+ const derived = src.resolveTranscriptPath(hook);
509
+ if (derived)
510
+ hook.transcript_path = derived;
511
+ }
512
+ // Opportunistic hygiene: Codex never fires session-end, so its state
513
+ // files are TTL-swept instead of deleted at end-of-session.
514
+ sweepStaleState();
515
+ const convGuid = await resolveConvGuid(hook, src.serverSource);
427
516
  if (!convGuid)
428
517
  return; // not a Gipity-bound session - nothing to capture
429
518
  try {
@@ -432,24 +521,24 @@ async function main() {
432
521
  await handleSessionStart(convGuid, hook);
433
522
  break;
434
523
  case 'stop':
435
- await handleStopFamily(convGuid, hook, false);
524
+ await handleStopFamily(convGuid, src, hook, false);
436
525
  break;
437
526
  case 'subagent-stop':
438
- await handleStopFamily(convGuid, hook, true);
527
+ await handleStopFamily(convGuid, src, hook, true);
439
528
  break;
440
529
  case 'post-tool-use':
441
530
  // Incremental mid-run flush so an interrupted session keeps its
442
531
  // transcript (Stop/SessionEnd only fire on clean exit). Throttled.
443
- await handleStopFamily(convGuid, hook, false, POST_TOOL_FLUSH_MS);
532
+ await handleStopFamily(convGuid, src, hook, false, POST_TOOL_FLUSH_MS);
444
533
  break;
445
534
  case 'session-end':
446
- await handleSessionEnd(convGuid, hook, source);
535
+ await handleSessionEnd(convGuid, src, hook);
447
536
  break;
448
537
  case 'pre-compact': {
449
538
  // Flush the transcript tail BEFORE compaction rewrites it (the
450
539
  // watermark replay after a rewrite relies on server dedup, but
451
540
  // flushing first keeps ordering clean), then record the boundary.
452
- await handleStopFamily(convGuid, hook, false);
541
+ await handleStopFamily(convGuid, src, hook, false);
453
542
  const trigger = typeof hook.trigger === 'string' ? hook.trigger : 'auto';
454
543
  await postEntries(convGuid, [{
455
544
  kind: 'compact',