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.
- 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/doctor.js +8 -2
- package/dist/commands/init.js +9 -15
- package/dist/commands/page-eval.js +18 -7
- package/dist/commands/page-inspect.js +7 -3
- package/dist/commands/page-screenshot.js +62 -23
- 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 +127 -38
- package/dist/index.js +1055 -330
- package/dist/knowledge.js +3 -3
- package/dist/prefs.js +50 -0
- package/dist/project-setup.js +2 -10
- package/dist/relay/daemon.js +124 -16
- package/dist/relay/diagnostics.js +4 -2
- package/dist/relay/onboarding.js +2 -2
- package/dist/setup.js +258 -17
- 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,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 =
|
|
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 =
|
|
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,
|
|
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: `${
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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,
|
|
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',
|