gipity 1.0.428 → 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.
Files changed (40) hide show
  1. package/dist/agents/claude-code.js +53 -0
  2. package/dist/agents/codex.js +49 -0
  3. package/dist/agents/grok.js +54 -0
  4. package/dist/agents/index.js +23 -0
  5. package/dist/agents/types.js +18 -0
  6. package/dist/api.js +7 -0
  7. package/dist/capture/sources/codex.js +216 -0
  8. package/dist/capture/sources/grok.js +178 -0
  9. package/dist/client-context.js +2 -0
  10. package/dist/commands/build.js +1352 -0
  11. package/dist/commands/chat.js +9 -3
  12. package/dist/commands/claude.js +4 -13
  13. package/dist/commands/db.js +12 -5
  14. package/dist/commands/deploy.js +19 -1
  15. package/dist/commands/doctor.js +8 -2
  16. package/dist/commands/generate.js +61 -4
  17. package/dist/commands/init.js +9 -15
  18. package/dist/commands/page-eval.js +404 -56
  19. package/dist/commands/page-inspect.js +15 -5
  20. package/dist/commands/page-screenshot.js +269 -64
  21. package/dist/commands/project.js +1 -1
  22. package/dist/commands/relay-install.js +1 -1
  23. package/dist/commands/relay.js +2 -2
  24. package/dist/commands/sandbox.js +62 -16
  25. package/dist/commands/setup.js +16 -10
  26. package/dist/commands/uninstall.js +25 -3
  27. package/dist/hooks/capture-runner.js +136 -39
  28. package/dist/index.js +1962 -668
  29. package/dist/knowledge.js +3 -3
  30. package/dist/page-fixtures.js +92 -3
  31. package/dist/prefs.js +50 -0
  32. package/dist/project-setup.js +2 -10
  33. package/dist/provider-docs.js +10 -10
  34. package/dist/relay/daemon.js +140 -18
  35. package/dist/relay/device-http.js +22 -0
  36. package/dist/relay/diagnostics.js +4 -2
  37. package/dist/relay/media-upload.js +184 -0
  38. package/dist/relay/onboarding.js +2 -2
  39. package/dist/setup.js +262 -18
  40. package/package.json +4 -3
@@ -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,15 +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';
49
+ import { ImageBlockRewriter } from '../relay/media-upload.js';
44
50
  import { getConfig } from '../config.js';
45
- 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';
46
54
  const CAPTURE_DIR = join(homedir(), '.gipity', 'capture-state');
47
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
+ };
48
81
  // PostToolUse fires after every tool call. We flush on it so a session that is
49
82
  // killed/crashes mid-run (e.g. a long headless `gipity claude -p` build that
50
83
  // hits a timeout) still has its transcript in the DB - Stop/SessionEnd only
@@ -211,13 +244,14 @@ function deleteSessionMap(sessionId) {
211
244
  * already bound to this session_id (resume), the project's still-empty
212
245
  * placeholder, or a fresh one. Returns null on any failure - capture is
213
246
  * best-effort and must never break the session. */
214
- async function resolveFromServer(projectGuid, hook) {
247
+ async function resolveFromServer(projectGuid, hook, serverSource) {
215
248
  let res;
216
249
  try {
217
250
  res = await deviceFetch('POST', '/remote-sessions/resolve', {
218
251
  project_guid: projectGuid,
219
252
  session_id: hook.session_id,
220
253
  cwd: hook.cwd,
254
+ source: serverSource,
221
255
  }, 15_000);
222
256
  }
223
257
  catch {
@@ -239,7 +273,7 @@ async function resolveFromServer(projectGuid, hook) {
239
273
  * the same crash-safe lock the flushers use, so concurrent hooks (Stop +
240
274
  * SubagentStop) can't each mint a conversation: the loser polls for the
241
275
  * winner's mapping instead of racing the server. */
242
- 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))) {
243
277
  const fromEnv = process.env.GIPITY_CONVERSATION_GUID;
244
278
  if (fromEnv)
245
279
  return fromEnv;
@@ -275,7 +309,7 @@ export async function resolveConvGuid(hook, sleep = (ms) => new Promise(r => set
275
309
  const raced = readSessionMap(sessionId);
276
310
  if (raced)
277
311
  return raced;
278
- const conv = await resolveFromServer(config.projectGuid, hook);
312
+ const conv = await resolveFromServer(config.projectGuid, hook, serverSource);
279
313
  if (conv)
280
314
  writeSessionMap(sessionId, conv);
281
315
  return conv;
@@ -337,7 +371,7 @@ async function handleSessionStart(convGuid, hook) {
337
371
  }];
338
372
  await postEntries(convGuid, entries);
339
373
  }
340
- async function handleStopFamily(convGuid, hook, isSubagent, minIntervalMs = 0) {
374
+ async function handleStopFamily(convGuid, src, hook, isSubagent, minIntervalMs = 0) {
341
375
  void isSubagent;
342
376
  if (!hook.transcript_path || !existsSync(hook.transcript_path))
343
377
  return;
@@ -354,13 +388,20 @@ async function handleStopFamily(convGuid, hook, isSubagent, minIntervalMs = 0) {
354
388
  try {
355
389
  const state = readState(convGuid) ?? { last_uuid: null };
356
390
  const content = await readWholeFile(hook.transcript_path);
357
- let result = parseTranscript(content, state.last_uuid);
391
+ let result = src.parse(content, state.last_uuid, hook);
358
392
  if (!result.foundWatermark && state.last_uuid !== null) {
359
393
  // Transcript rotated (/clear or compact) - watermark isn't present.
360
394
  // Replay from top; server dedupes via the source_uuid unique index.
361
- result = parseTranscript(content, null);
395
+ result = src.parse(content, null, hook);
362
396
  }
363
- const ok = await postEntries(convGuid, result.entries);
397
+ // Base64 image blocks (Read-of-image tool results) upload to VFS and
398
+ // become image_ref blocks — same no-inline-base64 chokepoint as the
399
+ // daemon's stream path. Content-hash storage keeps replays idempotent.
400
+ const rewriter = new ImageBlockRewriter(convGuid);
401
+ if (hook.cwd)
402
+ rewriter.setCwd(hook.cwd);
403
+ const entries = await rewriter.rewrite(result.entries);
404
+ const ok = await postEntries(convGuid, entries);
364
405
  if (ok) {
365
406
  // Stamp the flush time even when no new lines landed, so the throttle
366
407
  // above measures from the last attempt. Keep the watermark if unchanged.
@@ -371,18 +412,17 @@ async function handleStopFamily(convGuid, hook, isSubagent, minIntervalMs = 0) {
371
412
  release();
372
413
  }
373
414
  }
374
- async function handleSessionEnd(convGuid, hook, source) {
415
+ async function handleSessionEnd(convGuid, src, hook) {
375
416
  // Flush any tail lines one last time - SessionEnd fires after the final
376
417
  // Stop, so there's usually nothing new, but a race between Stop and
377
418
  // SessionEnd could leave lines behind.
378
419
  if (hook.transcript_path && existsSync(hook.transcript_path)) {
379
- await handleStopFamily(convGuid, hook, false);
420
+ await handleStopFamily(convGuid, src, hook, false);
380
421
  }
381
422
  const sessionId = hook.session_id ?? 'unknown';
382
- const finishedLabel = displayName(source);
383
423
  const entries = [{
384
424
  kind: 'system',
385
- content: `${finishedLabel} finished`,
425
+ content: `${src.displayName} finished`,
386
426
  source_uuid: `${sessionId}-end`,
387
427
  }];
388
428
  await postEntries(convGuid, entries);
@@ -390,10 +430,54 @@ async function handleSessionEnd(convGuid, hook, source) {
390
430
  if (hook.session_id)
391
431
  deleteSessionMap(hook.session_id);
392
432
  }
393
- function displayName(source) {
394
- if (source === 'claude-code')
395
- return 'Claude Code';
396
- 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;
397
481
  }
398
482
  async function main() {
399
483
  // Explicit capture opt-out. Set by the relay daemon's dispatch spawns
@@ -407,15 +491,28 @@ async function main() {
407
491
  const [source, event] = process.argv.slice(2);
408
492
  if (!source || !event)
409
493
  return;
494
+ const src = CAPTURE_SOURCES[source];
495
+ if (!src)
496
+ return; // unknown agent - silent no-op
410
497
  const stdin = await readStdin();
411
498
  let hook = {};
412
499
  if (stdin.trim()) {
413
500
  try {
414
- hook = JSON.parse(stdin);
501
+ hook = normalizeHookInput(JSON.parse(stdin));
415
502
  }
416
503
  catch { /* ignore - event may not require transcript */ }
417
504
  }
418
- 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);
419
516
  if (!convGuid)
420
517
  return; // not a Gipity-bound session - nothing to capture
421
518
  try {
@@ -424,24 +521,24 @@ async function main() {
424
521
  await handleSessionStart(convGuid, hook);
425
522
  break;
426
523
  case 'stop':
427
- await handleStopFamily(convGuid, hook, false);
524
+ await handleStopFamily(convGuid, src, hook, false);
428
525
  break;
429
526
  case 'subagent-stop':
430
- await handleStopFamily(convGuid, hook, true);
527
+ await handleStopFamily(convGuid, src, hook, true);
431
528
  break;
432
529
  case 'post-tool-use':
433
530
  // Incremental mid-run flush so an interrupted session keeps its
434
531
  // transcript (Stop/SessionEnd only fire on clean exit). Throttled.
435
- await handleStopFamily(convGuid, hook, false, POST_TOOL_FLUSH_MS);
532
+ await handleStopFamily(convGuid, src, hook, false, POST_TOOL_FLUSH_MS);
436
533
  break;
437
534
  case 'session-end':
438
- await handleSessionEnd(convGuid, hook, source);
535
+ await handleSessionEnd(convGuid, src, hook);
439
536
  break;
440
537
  case 'pre-compact': {
441
538
  // Flush the transcript tail BEFORE compaction rewrites it (the
442
539
  // watermark replay after a rewrite relies on server dedup, but
443
540
  // flushing first keeps ordering clean), then record the boundary.
444
- await handleStopFamily(convGuid, hook, false);
541
+ await handleStopFamily(convGuid, src, hook, false);
445
542
  const trigger = typeof hook.trigger === 'string' ? hook.trigger : 'auto';
446
543
  await postEntries(convGuid, [{
447
544
  kind: 'compact',