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.
- package/dist/agents/claude-code.js +53 -0
- package/dist/agents/codex.js +49 -0
- package/dist/agents/grok.js +54 -0
- package/dist/agents/index.js +23 -0
- package/dist/agents/types.js +18 -0
- package/dist/api.js +7 -0
- package/dist/capture/sources/codex.js +216 -0
- package/dist/capture/sources/grok.js +178 -0
- package/dist/client-context.js +2 -0
- package/dist/commands/build.js +1352 -0
- package/dist/commands/chat.js +9 -3
- package/dist/commands/claude.js +4 -13
- package/dist/commands/db.js +12 -5
- package/dist/commands/deploy.js +19 -1
- package/dist/commands/doctor.js +8 -2
- package/dist/commands/generate.js +61 -4
- package/dist/commands/init.js +9 -15
- package/dist/commands/page-eval.js +404 -56
- package/dist/commands/page-inspect.js +15 -5
- package/dist/commands/page-screenshot.js +269 -64
- package/dist/commands/project.js +1 -1
- package/dist/commands/relay-install.js +1 -1
- package/dist/commands/relay.js +2 -2
- package/dist/commands/sandbox.js +62 -16
- package/dist/commands/setup.js +16 -10
- package/dist/commands/uninstall.js +25 -3
- package/dist/hooks/capture-runner.js +136 -39
- package/dist/index.js +1962 -668
- package/dist/knowledge.js +3 -3
- package/dist/page-fixtures.js +92 -3
- package/dist/prefs.js +50 -0
- package/dist/project-setup.js +2 -10
- package/dist/provider-docs.js +10 -10
- package/dist/relay/daemon.js +140 -18
- package/dist/relay/device-http.js +22 -0
- package/dist/relay/diagnostics.js +4 -2
- package/dist/relay/media-upload.js +184 -0
- package/dist/relay/onboarding.js +2 -2
- package/dist/setup.js +262 -18
- package/package.json +4 -3
package/dist/commands/sandbox.js
CHANGED
|
@@ -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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// the flag is repeatable.
|
|
176
|
-
.option('--
|
|
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 --
|
|
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
|
|
274
|
-
if (
|
|
275
|
-
console.error(clrError('--
|
|
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
|
-
|
|
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
|
-
|
|
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('\
|
|
417
|
+
console.log(dim('\nDiscarded (--discard-output):'));
|
|
372
418
|
for (const f of res.data.skippedOutputFiles)
|
|
373
419
|
console.log(dim(`${f}`));
|
|
374
420
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `gipity
|
|
2
|
+
* `gipity connect` — connect this computer to gipity.ai, then stop.
|
|
3
3
|
*
|
|
4
|
-
* Runs the SAME first steps as `gipity
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
|
20
|
-
.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
6
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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'
|
|
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
|
|
21
|
-
* created/reused the conversation before spawning
|
|
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
|
|
25
|
-
* `claude` in a linked project dir). Resolve the
|
|
26
|
-
* .gipity.json and ask the server to bind this
|
|
27
|
-
* conversation (POST /remote-sessions/resolve), then
|
|
28
|
-
* mapping. This is what makes bare
|
|
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 =
|
|
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 =
|
|
395
|
+
result = src.parse(content, null, hook);
|
|
362
396
|
}
|
|
363
|
-
|
|
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,
|
|
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: `${
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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,
|
|
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',
|