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
package/dist/knowledge.js CHANGED
@@ -54,7 +54,7 @@ Prefer the cheapest option that works - CLI and sandbox are instant and free, ap
54
54
  3. App services - runtime HTTP endpoints your deployed app calls directly at \`https://a.gipity.ai/api/<PROJECT_GUID>/services/*\`. Available: LLM, TTS, image, sound, music, transcribe, video, file upload, realtime, location, push notifications (Gipity Notify - \`gipity add notify\`, send from a function with the injected \`notify()\`; see \`app-notify\`). Load the matching skill (\`app-llm\`, \`app-tts\`, etc.) before writing service code - they have the schemas, auth pattern, and common-mistake guards. For one-off generation during development, prefer \`gipity generate <image|video|speech|sound|music>\` or \`gipity chat\` - direct generation always bills you (the owner), regardless of any service \`billing_mode\` (that setting only governs the deployed app's runtime calls; never flip it just to create assets). \`gipity generate\` saves to a generic file in the current directory by default (e.g. \`./generated.png\`) - pass \`-o <path>\` to write it straight into your source tree so it deploys (e.g. \`gipity generate image "hero banner" -o src/assets/images/hero.png\`) instead of generating at cwd and moving it.
55
55
  4. Delegate to Gip (\`gipity chat "<task>"\`) - only when the work genuinely needs agent reasoning or a tool not in the CLI, sandbox, or app services. Required for: Twitter/X search, Gmail, calendar, push notifications, video understanding, audio source isolation, cross-model second opinions, multi-step orchestration. Don't use \`gipity chat\` for anything the sandbox can do - it's slower and burns tokens.
56
56
 
57
- You are the developer. Write files in this directory - the Gipity Claude Code plugin's hooks auto-sync them to Gipity. Don't run \`npm install\`, \`npm start\`, \`node\`, or \`python\` locally; there is no local runtime. Code runs in the Gipity sandbox.
57
+ You are the developer. Write files in this directory - Gipity's editor hooks (installed by \`gipity init\` into Claude Code, Grok, and Codex) auto-sync them to Gipity. Don't run \`npm install\`, \`npm start\`, \`node\`, or \`python\` locally; there is no local runtime. Code runs in the Gipity sandbox.
58
58
 
59
59
  ## Use first-party services before reaching outside
60
60
 
@@ -90,7 +90,7 @@ The full "when to add a template" rule and the definition of done are spelled ou
90
90
 
91
91
  Build loop: \`gipity add\` → edit files → \`gipity deploy dev --inspect\` → fix any errors → repeat until the definition of done is met. \`deploy --inspect\` deploys and then runs the page-inspect report on the live URL in one command; use a standalone \`gipity page inspect <url>\` only when re-checking without deploying.
92
92
 
93
- \`add\` writes real files to disk - Read a scaffolded file before your first Write/Edit to it, or the call fails \`"File has not been read yet"\`. Don't rewrite from memory of the template.
93
+ \`add\` writes real files to disk - Read a scaffolded file with the Read tool before your first Write/Edit to it, or the call fails \`"File has not been read yet"\` (viewing it with \`cat\`/\`head\` in Bash does not count - the harness only tracks the Read tool). Don't rewrite from memory of the template.
94
94
 
95
95
  Make your file changes and verify they landed, then run \`gipity deploy dev\` once. \`0 uploaded, N unchanged\` means nothing changed on disk - fix the files, don't re-run deploy or probe the environment.
96
96
 
@@ -146,7 +146,7 @@ Every tool call returns its full output with that call. There is no output buffe
146
146
 
147
147
  This directory is the app root - it holds \`.gipity.json\` (and \`gipity.yaml\` for backends) and is already your cwd, so run app commands here. Don't \`cd\` to a git root (\`git rev-parse --show-toplevel\`): when the app is nested in a larger repo that resolves outside the app.
148
148
 
149
- Write files locally - the Gipity Claude Code plugin's hooks auto-push every save to Gipity and auto-pull remote changes (images, audio from \`gipity chat\`) before each turn. Use \`gipity sync\` if things get out of sync (or if the plugin isn't installed - \`gipity status --repair-hooks\` re-enables it). Deletes are safe - use \`rollback\` with a datetime to undo, or \`file_version_restore\` for individual files.
149
+ Write files locally - Gipity's editor hooks (installed into Claude Code, Grok, and Codex by \`gipity init\`) auto-push every save to Gipity and auto-pull remote changes (images, audio from \`gipity chat\`) before each turn. Use \`gipity sync\` if things get out of sync (or if the hooks aren't installed - \`gipity status --repair-hooks\` re-enables them). Deletes are safe - use \`rollback\` with a datetime to undo, or \`file_version_restore\` for individual files.
150
150
 
151
151
  To keep local-only material (research clones, scratch data, vendored references) in the project directory without syncing or deploying it, list it in a \`.gipityignore\` at the project root - gitignore-style, one pattern per line, \`#\` comments. Ignored paths are invisible to sync in both directions; anything that already synced before being ignored stays on the server until you delete it.
152
152
 
@@ -8,12 +8,63 @@
8
8
  // Uses the same presigned init -> PUT -> complete flow the app file-upload
9
9
  // service exposes, with `public: true`. Cleanup goes through the matching
10
10
  // DELETE /api/:appGuid/uploads/:guid, which removes the public object too.
11
- import { readFileSync, statSync } from 'node:fs';
12
- import { basename } from 'node:path';
11
+ import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
12
+ import { basename, resolve, relative, join } from 'node:path';
13
13
  import { post, del } from './api.js';
14
+ import { getProjectRoot } from './config.js';
14
15
  import { guessMime } from './upload.js';
16
+ /** Dirs a hand-made asset is never in, and that are expensive to walk. */
17
+ const FIND_SKIP = new Set(['node_modules', '.git', '.gipity', 'dist', 'build', '.next', 'coverage']);
18
+ /** Find files named `name` anywhere under `root` (breadth-first, bounded). */
19
+ function findByBasename(root, name, limit = 3) {
20
+ const hits = [];
21
+ const queue = [root];
22
+ let visited = 0;
23
+ while (queue.length && hits.length < limit && visited < 500) {
24
+ const dir = queue.shift();
25
+ visited++;
26
+ let entries;
27
+ try {
28
+ entries = readdirSync(dir, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ for (const e of entries) {
34
+ if (e.isDirectory()) {
35
+ if (!FIND_SKIP.has(e.name))
36
+ queue.push(join(dir, e.name));
37
+ }
38
+ else if (e.name === name && hits.length < limit) {
39
+ hits.push(join(dir, e.name));
40
+ }
41
+ }
42
+ }
43
+ return hits;
44
+ }
45
+ /** Every `page` flag that takes a local file resolves it against the CURRENT
46
+ * DIRECTORY, and a bare ENOENT from deep inside the upload path ("stat
47
+ * './tmp/fist.jpg'") tells the caller nothing about where we looked or where
48
+ * the file actually is. That costs an ls/find round-trip every time a file was
49
+ * written somewhere other than where the caller assumed - which is exactly what
50
+ * happens after a `cd` that failed, or a `gipity generate` run from a different
51
+ * directory. So: name the absolute path we tried, the directory it was resolved
52
+ * against, and - when a file with that basename exists elsewhere in the project
53
+ * - the path that would have worked. Recovery becomes copy-paste, not a search. */
54
+ export function assertLocalAsset(flag, localPath) {
55
+ const abs = resolve(localPath);
56
+ if (existsSync(abs))
57
+ return;
58
+ const root = getProjectRoot() ?? process.cwd();
59
+ const elsewhere = findByBasename(root, basename(localPath)).filter(p => p !== abs);
60
+ const found = elsewhere.length
61
+ ? `\nThat file DOES exist here — pass this path instead:\n${elsewhere.map(p => ` ${flag} ${relative(process.cwd(), p) || p}`).join('\n')}`
62
+ : `\nNothing named "${basename(localPath)}" under ${root} either. Generate a frame with \`gipity generate image "<description>" -o ${localPath}\`, or check the path.`;
63
+ throw new Error(`${flag} ${localPath}: no such file — looked for ${abs} (relative paths resolve against the current directory, ${process.cwd()}).${found}`);
64
+ }
15
65
  /** Upload a local file to the app's public file store and return its URL. */
16
- export async function uploadPublicFixture(projectGuid, localPath) {
66
+ export async function uploadPublicFixture(projectGuid, localPath, flag = '--fixture') {
67
+ assertLocalAsset(flag, localPath);
17
68
  const name = basename(localPath);
18
69
  const size = statSync(localPath).size;
19
70
  const contentType = guessMime(localPath);
@@ -38,4 +89,42 @@ export async function uploadPublicFixture(projectGuid, localPath) {
38
89
  export async function deleteFixture(projectGuid, guid) {
39
90
  await del(`/api/${projectGuid}/uploads/${guid}`);
40
91
  }
92
+ // ── camera feed ─────────────────────────────────────────────────────────────
93
+ // A headless browser has no webcam, so a vision app (MediaPipe, YOLOX, any
94
+ // getUserMedia consumer) can't be exercised at all: getUserMedia rejects, the
95
+ // app shows its no-camera state, and the code path the user actually cares
96
+ // about never runs. `--fake-media` alone only fixes the rejection — Chrome's
97
+ // built-in synthetic device is a rolling test pattern, so a gesture/pose/object
98
+ // model still sees nothing recognizable and every agent ends up stubbing the
99
+ // model's own output to test around it.
100
+ //
101
+ // --camera closes that: host a real image/video and let the browser play it as
102
+ // the webcam's frames, so the app's REAL pipeline (frames → model → app logic)
103
+ // runs headlessly on input you control. Hosting reuses the fixture path above;
104
+ // the browser side (fetch the file, feed it to Chrome's fake capture device)
105
+ // is the server's job — the CLI just names the file and hands over its URL.
106
+ /** Container formats Chrome's fake video-capture device can be fed from, plus
107
+ * the still-image types the server transcodes into a looping single-frame
108
+ * feed. Anything else is rejected here (fast, local) rather than after an
109
+ * upload + a browser launch. */
110
+ const CAMERA_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.mp4', '.webm', '.y4m', '.mjpeg'];
111
+ /** Validate a --camera path before uploading it. Throws with the accepted list
112
+ * and the in-platform way to produce a frame, so a wrong file type costs one
113
+ * local error instead of an upload plus an opaque browser failure. */
114
+ export function assertCameraFile(localPath) {
115
+ assertLocalAsset('--camera', localPath);
116
+ const ext = localPath.slice(localPath.lastIndexOf('.')).toLowerCase();
117
+ if (CAMERA_EXTS.includes(ext))
118
+ return;
119
+ throw new Error(`--camera ${basename(localPath)}: unsupported file type "${ext || '(none)'}" — the camera feed must be an image or video (${CAMERA_EXTS.join(', ')}).\n` +
120
+ `A still image is played as a looping single-frame feed, which is what a gesture/pose/object model needs.\n` +
121
+ `No frame to hand? Generate one: gipity generate image "a hand making a closed fist, palm to camera, plain background"`);
122
+ }
123
+ /** Host a local image/video as the browser's webcam feed. Same public-upload
124
+ * path as a fixture (so it is fetchable from the browser container and is
125
+ * deleted the same way), validated first. */
126
+ export async function uploadCameraFeed(projectGuid, localPath) {
127
+ assertCameraFile(localPath);
128
+ return uploadPublicFixture(projectGuid, localPath, '--camera');
129
+ }
41
130
  //# sourceMappingURL=page-fixtures.js.map
package/dist/prefs.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Machine-level user preferences (~/.gipity/prefs.json) - the `gipity build`
3
+ * picker's memory. Deliberately NOT in a project's .gipity.json: per-project
4
+ * storage would re-ask the agent/model questions on every new project, which
5
+ * defeats "hit enter twice".
6
+ *
7
+ * Shape:
8
+ * {
9
+ * "lastAgent": "claude",
10
+ * "lastModel": { "claude": "opus", "codex": null } // per-agent so a
11
+ * } // Codex session never
12
+ * // clobbers the Claude pick
13
+ *
14
+ * A `lastModel` of null/absent means "Agent default" - launch passes no
15
+ * model flag and the agent's own model memory stays authoritative.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
18
+ import { homedir } from 'os';
19
+ import { join } from 'path';
20
+ const PREFS_PATH = join(homedir(), '.gipity', 'prefs.json');
21
+ export function readPrefs() {
22
+ if (!existsSync(PREFS_PATH))
23
+ return {};
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(PREFS_PATH, 'utf-8'));
26
+ return {
27
+ lastAgent: typeof parsed.lastAgent === 'string' ? parsed.lastAgent : undefined,
28
+ lastModel: parsed.lastModel && typeof parsed.lastModel === 'object' ? parsed.lastModel : undefined,
29
+ };
30
+ }
31
+ catch {
32
+ return {}; // unreadable prefs must never block a launch
33
+ }
34
+ }
35
+ export function writePrefs(update) {
36
+ try {
37
+ const current = readPrefs();
38
+ const next = {
39
+ ...current,
40
+ ...update,
41
+ lastModel: { ...current.lastModel, ...update.lastModel },
42
+ };
43
+ mkdirSync(join(homedir(), '.gipity'), { recursive: true });
44
+ writeFileSync(PREFS_PATH, JSON.stringify(next, null, 2) + '\n');
45
+ }
46
+ catch {
47
+ /* best-effort - prefs are a convenience */
48
+ }
49
+ }
50
+ //# sourceMappingURL=prefs.js.map
@@ -7,7 +7,7 @@
7
7
  import { clearConfigCache, saveConfigAt, getApiBaseOverride, DEFAULT_API_BASE } from './config.js';
8
8
  import { sync } from './sync.js';
9
9
  import { createProgressReporter } from './progress.js';
10
- import { setupClaudeHooks, setupGitignore, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from './setup.js';
10
+ import { setupProjectTools, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from './setup.js';
11
11
  import { substituteDir } from './template-vars.js';
12
12
  import { muted } from './colors.js';
13
13
  /** Write `.gipity.json` in `dir`, chdir into it so the hook/skill writers
@@ -61,15 +61,7 @@ export async function finalizeLocalProject(opts) {
61
61
  throw err;
62
62
  // soft mode - swallow; caller can log
63
63
  }
64
- const tools = opts.tools ?? DEFAULT_TOOLS;
65
- // Claude hooks (file-sync + capture) only matter if the Claude primer is
66
- // being written. Skipping them for Codex/Gemini/Cursor-only inits avoids
67
- // littering `.claude/settings.json` into projects that don't use Claude.
68
- if (tools.some(t => t.key === 'claude'))
69
- setupClaudeHooks();
70
- for (const t of tools)
71
- t.setup();
72
- setupGitignore();
64
+ setupProjectTools(opts.tools ?? DEFAULT_TOOLS);
73
65
  return { applied };
74
66
  }
75
67
  //# sourceMappingURL=project-setup.js.map
@@ -42,17 +42,17 @@ export const GEMINI_TTS_VOICES_DOC = `Zephyr, Puck, Charon, Kore, Fenrir, Leda,
42
42
  export const GEMINI_TTS_VOICES_SHORT = `Kore, Puck, Zephyr, Charon, Fenrir, Leda, Orus, Aoede, and 22 more`;
43
43
  export const IMAGE_GEMINI_ASPECT_RATIOS = `1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 4:5, 5:4, 21:9`;
44
44
  export const IMAGE_GEMINI_SIZES = `512, 1K, 2K, 4K`;
45
- export const IMAGE_MODELS_DOC = `openai: gpt-image-2, gpt-image-1.5. bfl: flux-2-pro, flux-2-flex, flux-2-max, flux-dev. gemini: gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image`;
46
- export const IMAGE_PROVIDERS_BULLET = `- **OpenAI**: \`gpt-image-2, gpt-image-1.5\`
47
- - **BFL/Flux**: \`flux-2-pro, flux-2-flex, flux-2-max, flux-dev\`
48
- - **Gemini/Nano Banana**: \`gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image\``;
45
+ export const IMAGE_MODELS_DOC = `openai: gpt-image-2. bfl: flux-2-pro, flux-2-flex, flux-2-max, flux-2-klein-9b, flux-2-klein-4b. gemini: gemini-3.1-flash-lite-image, gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image`;
46
+ export const IMAGE_PROVIDERS_BULLET = `- **OpenAI**: \`gpt-image-2\`
47
+ - **BFL/Flux**: \`flux-2-pro, flux-2-flex, flux-2-max, flux-2-klein-9b, flux-2-klein-4b\`
48
+ - **Gemini/Nano Banana**: \`gemini-3.1-flash-lite-image, gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image\``;
49
49
  export const IMAGE_PROVIDERS_LIST = `openai, bfl, gemini`;
50
50
  export const IMAGE_PROVIDER_DESCRIPTIONS = {
51
- 'openai': `OpenAI (gpt-image-2, gpt-image-1.5)`,
52
- 'bfl': `BFL/Flux (flux-2-pro, flux-2-flex, flux-2-max, flux-dev)`,
53
- 'gemini': `Gemini/Nano Banana (gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image)`,
51
+ 'openai': `OpenAI (gpt-image-2)`,
52
+ 'bfl': `BFL/Flux (flux-2-pro, flux-2-flex, flux-2-max, flux-2-klein-9b, flux-2-klein-4b)`,
53
+ 'gemini': `Gemini/Nano Banana (gemini-3.1-flash-lite-image, gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image)`,
54
54
  };
55
- export const LLM_DEFAULT_MODELS_DOC = `OpenAI: gpt-4.1-nano (cheapest), gpt-5.4-mini (cheap reasoning). Anthropic: claude-haiku-4-5 (cheapest). Gemini: gemini-2.5-flash-lite (cheapest, 1M context)`;
55
+ export const LLM_DEFAULT_MODELS_DOC = `OpenAI: gpt-5.4-nano (cheapest), gpt-5.4-mini (cheap reasoning). Anthropic: claude-haiku-4-5 (cheapest). Gemini: gemini-2.5-flash-lite (cheapest, 1M context)`;
56
56
  export const LLM_PROVIDERS_LIST = `anthropic, openai, gemini`;
57
57
  export const OPENAI_TTS_VOICES_DOC = `alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse`;
58
58
  export const TRANSCRIBE_PROVIDERS_DOC = `elevenlabs (default, Scribe v2), openai (GPT-4o Transcribe), gemini (Gemini 2.5 Flash - cheapest, multilingual)`;
@@ -63,7 +63,7 @@ export const TTS_PROVIDER_DESCRIPTIONS = {
63
63
  'gemini': `Gemini (30 voices: Kore, Puck, Zephyr, Charon, Fenrir, Leda, Orus, Aoede, and 22 more). Multi-speaker (up to 2) and 60+ languages`,
64
64
  };
65
65
  export const VIDEO_ASPECT_RATIOS = `16:9 (landscape), 9:16 (portrait), 1:1 (square)`;
66
- export const VIDEO_MODELS_DOC = `veo-3.1-generate-preview (best quality, ~$0.40/sec), veo-3.1-fast-generate-preview (faster, ~$0.15/sec), veo-3.1-lite-generate-preview (budget, ~$0.07/sec)`;
67
- export const VIDEO_MODELS_LIST = `veo-3.1-generate-preview, veo-3.1-fast-generate-preview, veo-3.1-lite-generate-preview`;
66
+ export const VIDEO_MODELS_DOC = `veo-3.1-generate-preview (best quality, up to 4K, ~$0.40/sec), veo-3.1-fast-generate-preview (faster, ~$0.10/sec), veo-3.1-lite-generate-preview (budget, ~$0.05/sec), gemini-omni-flash-preview (3-10s @ 720p, conversational editing, ~$0.10/sec)`;
67
+ export const VIDEO_MODELS_LIST = `veo-3.1-generate-preview, veo-3.1-fast-generate-preview, veo-3.1-lite-generate-preview, gemini-omni-flash-preview`;
68
68
  export const VIDEO_RESOLUTIONS = `720p, 1080p, 4k`;
69
69
  //# sourceMappingURL=provider-docs.js.map
@@ -6,12 +6,13 @@ import { homedir, hostname, platform as osPlatform, loadavg, freemem, totalmem,
6
6
  import { join } from 'path';
7
7
  import { getApiBaseOverride, DEFAULT_API_BASE } from '../config.js';
8
8
  import { getProjectsRoot } from './paths.js';
9
- import { setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE } from '../setup.js';
9
+ import { setupProjectTools, DEFAULT_SYNC_IGNORE } from '../setup.js';
10
10
  import { getAuth, readAuthFresh, refreshTokenIfNeeded, accessTokenExpired } from '../auth.js';
11
11
  import { post } from '../api.js';
12
12
  import * as state from './state.js';
13
13
  import { createLineSplitter, parseEvent, mapEventToEntries, } from './stream-json.js';
14
14
  import { IngestQueue } from './ingest-queue.js';
15
+ import { ImageBlockRewriter } from './media-upload.js';
15
16
  import { DeltaAccumulator, DeltaBatcher } from './stream-delta.js';
16
17
  import { randomUUID } from 'crypto';
17
18
  import { deviceFetch, bridgeAbort as bridgeAbortImpl } from './device-http.js';
@@ -23,7 +24,8 @@ import { SessionPool, PoolFullError } from './session-pool.js';
23
24
  import { getConfig } from '../config.js';
24
25
  import { getAccountSlug } from '../api.js';
25
26
  import { buildFreshWrap, buildResumeWrap, buildProjectContextBlock } from '../prompts.js';
26
- import { fetchProjectStats } from '../commands/claude.js';
27
+ import { fetchProjectStats } from '../commands/build.js';
28
+ import { getAdapterBySource } from '../agents/index.js';
27
29
  // Re-exported so the existing `relay-bridge-abort.test.ts` keeps working.
28
30
  // New callers should import from device-http.js directly.
29
31
  export const bridgeAbort = bridgeAbortImpl;
@@ -875,7 +877,13 @@ async function handleDispatch(claimed) {
875
877
  // retry instead of the old fire-and-forget loss on network errors.
876
878
  // Daemon-authored entries get a random source_uuid so retried batches
877
879
  // dedup server-side.
878
- const queue = new IngestQueue((entries) => postIngest(d.conversation_guid, entries), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
880
+ // The rewriter uploads any base64 image blocks (Read-of-screenshot
881
+ // results) to VFS and swaps in image_ref blocks before the batch posts —
882
+ // transcripts carry URLs, never image bytes. Retries re-enter it, which
883
+ // is safe: rewritten entries have nothing left to upload, and the server
884
+ // stores by content hash so a replayed upload dedups.
885
+ const rewriter = new ImageBlockRewriter(d.conversation_guid, (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }));
886
+ const queue = new IngestQueue(async (entries) => postIngest(d.conversation_guid, await rewriter.rewrite(entries)), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
879
887
  const pushSystem = (content) => {
880
888
  queue.push({ kind: 'system', content, ts: new Date().toISOString(), source_uuid: randomUUID() });
881
889
  };
@@ -886,6 +894,9 @@ async function handleDispatch(claimed) {
886
894
  let bootstrapped;
887
895
  try {
888
896
  ({ cwd, bootstrapped } = await resolveCwdForProject(d));
897
+ // Lets the rewriter map an absolute Read path (…/screenshots/x.png)
898
+ // to its project-relative VFS path for content-hash dedup.
899
+ rewriter.setCwd(cwd);
889
900
  log('debug', 'resolved project cwd', { id: d.short_guid, project: d.project_slug, cwd, bootstrapped });
890
901
  }
891
902
  catch (err) {
@@ -939,6 +950,15 @@ async function handleDispatch(claimed) {
939
950
  if (!syncFailed)
940
951
  pushSystem(bootstrapped ? 'Project files synced.' : 'Attached files synced.');
941
952
  }
953
+ // Which agent runs this dispatch. Older servers omit remote_type - they
954
+ // only ever dispatched Claude Code.
955
+ const adapter = getAdapterBySource(d.remote_type ?? 'claude_code');
956
+ // Non-Claude agents take the simpler hook-capture dispatch path: no
957
+ // stream-json ingest, no session pool.
958
+ if (adapter.key !== 'claude') {
959
+ await handleNonClaudeDispatch(adapter, d, cwd, queue, pushSystem, flushQueue);
960
+ return;
961
+ }
942
962
  // Phase 2: if the session pool is enabled, try to run this turn in a
943
963
  // long-lived Claude Code session (fast follow-up + clean interrupt). Any
944
964
  // failure - pool saturated, SDK error, wrap failure - falls through to the
@@ -948,8 +968,11 @@ async function handleDispatch(claimed) {
948
968
  if (handled)
949
969
  return;
950
970
  }
951
- // Build argv for `gipity claude -p …` (or with --resume). No shell - argv
952
- // as array so the message string can't be interpreted as shell syntax.
971
+ // Build argv for `gipity build --agent claude -p …` (or with --resume).
972
+ // No shell - argv as array so the message string can't be interpreted as
973
+ // shell syntax. (`build` is the renamed launcher; the flags below pass
974
+ // through it to the `claude` binary unchanged, exactly as the legacy
975
+ // `gipity claude -p` spawn did.)
953
976
  //
954
977
  // `--permission-mode bypassPermissions`: a relay dispatch has no
955
978
  // human on the other end to click "Approve" - Claude prompting would
@@ -957,7 +980,7 @@ async function handleDispatch(claimed) {
957
980
  // the device and dispatching the message; skipping the interactive
958
981
  // prompt is correct (same authority as running `claude -p` in a local
959
982
  // terminal yourself).
960
- const args = ['claude', '-p', d.message, '--permission-mode', 'bypassPermissions'];
983
+ const args = ['build', '--agent', 'claude', '-p', d.message, '--permission-mode', 'bypassPermissions'];
961
984
  // Relay sessions run in -p mode, where Claude Code's AskUserQuestion tool
962
985
  // is unavailable - so without help the model would ask clarifying
963
986
  // questions as prose the user just types back. This system-prompt
@@ -974,7 +997,7 @@ async function handleDispatch(claimed) {
974
997
  if (d.kind === 'resume' && d.remote_session_id) {
975
998
  args.push('--resume', d.remote_session_id);
976
999
  }
977
- log('debug', 'spawning gipity claude', {
1000
+ log('debug', 'spawning gipity build --agent claude', {
978
1001
  id: d.short_guid,
979
1002
  cwd,
980
1003
  args,
@@ -1113,7 +1136,92 @@ async function handleDispatch(claimed) {
1113
1136
  }
1114
1137
  else {
1115
1138
  log('warn', 'dispatch child exited nonzero', { id: d.short_guid, exitCode, ms });
1116
- await ack(d.short_guid, 'error', `gipity claude exited with code ${exitCode}${stderrNote}`);
1139
+ await ack(d.short_guid, 'error', `Claude Code exited with code ${exitCode}${stderrNote}`);
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Dispatch for non-Claude agents (Codex, Grok). Deliberately simpler than
1144
+ * the Claude path: there is no stream-json ingest mapper for these agents -
1145
+ * hook-based session capture (Phase C) owns the transcript. The spawned
1146
+ * `gipity build --agent <key> -p …` child gets GIPITY_CONVERSATION_GUID (so
1147
+ * capture posts into the right conversation) and capture is NOT turned off.
1148
+ * The daemon contributes: the "Running <agent>" marker, byte-level progress
1149
+ * heartbeats (spawnGipityClaude's generic plumbing with streamJson:false),
1150
+ * post-run file sync, the tail marker, and the ack. The prompt entry itself
1151
+ * arrives via capture from the agent's own transcript - the daemon doesn't
1152
+ * push one, or it would render twice.
1153
+ */
1154
+ async function handleNonClaudeDispatch(adapter, d, cwd, queue, pushSystem, flushQueue) {
1155
+ // `gipity build` translates these into the agent's own argv via the same
1156
+ // adapter (Codex: `exec …`; Grok: `-p … --always-approve`). Raw message -
1157
+ // AGENTS.md carries the Gipity context for these agents; no wrapping.
1158
+ const args = ['build', '--agent', adapter.key, '-p', d.message, '--bypass-approvals'];
1159
+ if (d.model)
1160
+ args.push('--model', d.model);
1161
+ if (d.kind === 'resume' && d.remote_session_id)
1162
+ args.push('--resume', d.remote_session_id);
1163
+ log('debug', `spawning gipity build --agent ${adapter.key}`, {
1164
+ id: d.short_guid, cwd, args, conv: d.conversation_guid,
1165
+ chain: d.kind === 'resume' ? `resume ${d.remote_session_id}` : 'start (fresh session)',
1166
+ });
1167
+ const words = d.message.trim().split(/\s+/).filter(Boolean).length;
1168
+ pushSystem(`Running ${adapter.displayName} - ${words.toLocaleString('en-US')} words`);
1169
+ const t0 = Date.now();
1170
+ let exitCode = 1;
1171
+ let spawnErr = null;
1172
+ let killed = false;
1173
+ let runtimeLimit = false;
1174
+ let stderrTail = '';
1175
+ try {
1176
+ const result = await spawnGipityClaude(args, cwd, d, queue, { streamJson: false });
1177
+ exitCode = result.exitCode;
1178
+ killed = result.killed;
1179
+ runtimeLimit = result.runtimeLimit ?? false;
1180
+ stderrTail = result.stderrTail ?? '';
1181
+ }
1182
+ catch (err) {
1183
+ spawnErr = err?.message || String(err);
1184
+ log('error', 'dispatch spawn failed', { id: d.short_guid, err: spawnErr });
1185
+ }
1186
+ const dur = formatDuration(Date.now() - t0);
1187
+ // Same post-run reconcile as the Claude path: push files the agent wrote
1188
+ // via Bash/scripts back to VFS before the ack (hooks only cover its
1189
+ // native edit tools). Skipped on kill - the replacement dispatch owns the tree.
1190
+ if (!spawnErr && !killed) {
1191
+ try {
1192
+ await spawnSync(cwd, PROJECT_SYNC_TIMEOUT_MS);
1193
+ }
1194
+ catch (err) {
1195
+ log('warn', 'sync after dispatch failed', { id: d.short_guid, err: err?.message });
1196
+ }
1197
+ }
1198
+ const stderrNote = stderrTail ? `: ${stderrTail.slice(0, 300)}` : '';
1199
+ const tail = runtimeLimit
1200
+ ? `stopped after ${dur} (runtime limit)`
1201
+ : killed
1202
+ ? `cancelled (${dur})`
1203
+ : spawnErr
1204
+ ? `failed (${dur}: ${spawnErr})`
1205
+ : exitCode === 0
1206
+ ? `finished (${dur})`
1207
+ : `failed (${dur}, exit ${exitCode}${stderrNote})`;
1208
+ pushSystem(`${adapter.displayName} ${tail}`);
1209
+ await flushQueue();
1210
+ if (runtimeLimit) {
1211
+ await ack(d.short_guid, 'error', `${adapter.displayName} stopped after ${dur} (runtime limit)`);
1212
+ }
1213
+ else if (killed) {
1214
+ await ack(d.short_guid, 'cancelled');
1215
+ }
1216
+ else if (spawnErr) {
1217
+ await ack(d.short_guid, 'error', spawnErr);
1218
+ }
1219
+ else if (exitCode === 0) {
1220
+ log('info', 'dispatch done', { id: d.short_guid, agent: adapter.key });
1221
+ await ack(d.short_guid, 'done');
1222
+ }
1223
+ else {
1224
+ await ack(d.short_guid, 'error', `${adapter.displayName} exited with code ${exitCode}${stderrNote}`);
1117
1225
  }
1118
1226
  }
1119
1227
  /**
@@ -1362,10 +1470,7 @@ async function resolveCwdForProject(d) {
1362
1470
  const origCwd = process.cwd();
1363
1471
  try {
1364
1472
  process.chdir(path);
1365
- setupClaudeHooks();
1366
- setupClaudeMd();
1367
- setupAgentsMd();
1368
- setupGitignore();
1473
+ setupProjectTools();
1369
1474
  }
1370
1475
  finally {
1371
1476
  process.chdir(origCwd);
@@ -1552,6 +1657,11 @@ async function spawnSync(cwd, timeoutMs, onSpawn) {
1552
1657
  });
1553
1658
  }
1554
1659
  export async function spawnGipityClaude(args, cwd, d, queue, meta) {
1660
+ // streamJson (default true) = the Claude path: parse the child's
1661
+ // stream-json stdout into ingest entries and stand hook capture down.
1662
+ // false = non-Claude agents (Codex/Grok): hook capture owns the
1663
+ // transcript; stdout only feeds the byte-level progress heartbeat.
1664
+ const streamJson = meta?.streamJson !== false;
1555
1665
  // resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
1556
1666
  // can't launch without an explicit path. An explicit env override is used
1557
1667
  // verbatim (it may be a full path); only the default name is resolved.
@@ -1562,13 +1672,19 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
1562
1672
  // `--include-partial-messages` adds stream_event token deltas, which
1563
1673
  // feed the ephemeral live-typing channel (see stream-delta.ts); whole
1564
1674
  // assistant/tool events still arrive unchanged for the persistent path.
1565
- const fullArgs = [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
1675
+ const fullArgs = streamJson
1676
+ ? [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages']
1677
+ : [...args];
1566
1678
  // GIPITY_CAPTURE=off: the daemon owns capture for this dispatch (it
1567
1679
  // parses the stream-json on stdout), so the plugin's lifecycle-hook
1568
1680
  // capture must stand down. `gipity claude` sets the same sentinel on the
1569
1681
  // Claude child when it detects a daemon spawn; setting it here too keeps
1570
- // dispatches double-post-free even across CLI version skew.
1571
- const env = childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid, GIPITY_CAPTURE: 'off' });
1682
+ // dispatches double-post-free even across CLI version skew. Non-Claude
1683
+ // agents are the opposite: hook capture IS the transcript path, so the
1684
+ // sentinel must NOT be set.
1685
+ const env = streamJson
1686
+ ? childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid, GIPITY_CAPTURE: 'off' })
1687
+ : childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid });
1572
1688
  return new Promise((resolve, reject) => {
1573
1689
  const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
1574
1690
  // `exited` fires when the child fully unwinds (exit event). Callers
@@ -1583,7 +1699,11 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
1583
1699
  // no longer drops stream content permanently (source_uuid dedup makes
1584
1700
  // the retries safe). Falls back to a local queue when the caller
1585
1701
  // didn't pass one (tests, direct invocation).
1586
- const q = queue ?? new IngestQueue((entries) => postIngest(d.conversation_guid, entries), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
1702
+ const q = queue ?? (() => {
1703
+ const rw = new ImageBlockRewriter(d.conversation_guid, (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }));
1704
+ rw.setCwd(cwd);
1705
+ return new IngestQueue(async (entries) => postIngest(d.conversation_guid, await rw.rewrite(entries)), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
1706
+ })();
1587
1707
  const ownQueue = !queue;
1588
1708
  // Progress heartbeat state. Measured at the daemon boundary - this is
1589
1709
  // what we actually observe, not anything the child self-reports. These
@@ -1766,9 +1886,11 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
1766
1886
  child.stdout?.on('data', (chunk) => {
1767
1887
  stdoutBytesTotal += chunk.length;
1768
1888
  lastStdoutByteAt = Date.now();
1769
- splitter.push(chunk);
1889
+ if (streamJson)
1890
+ splitter.push(chunk);
1770
1891
  });
1771
- child.stdout?.on('end', () => splitter.flush());
1892
+ child.stdout?.on('end', () => { if (streamJson)
1893
+ splitter.flush(); });
1772
1894
  // Stderr: human-readable only (Claude's progress bars, errors).
1773
1895
  // Kept on the daemon's own stderr for `gipity relay log`, plus a
1774
1896
  // small tail ring so a crash with no stream output can include the
@@ -31,6 +31,28 @@ export function bridgeAbort(outer, inner) {
31
31
  outer.addEventListener('abort', onAbort, { once: true });
32
32
  return () => outer.removeEventListener('abort', onAbort);
33
33
  }
34
+ /** Device-auth binary POST — raw bytes body (no JSON/base64 wrapping).
35
+ * Used by the transcript-media upload, where the whole point is that the
36
+ * image travels as bytes instead of base64. */
37
+ export async function deviceFetchBinary(method, path, body, contentType, timeoutMs) {
38
+ const controller = timeoutMs ? new AbortController() : null;
39
+ const timer = controller ? setTimeout(() => controller.abort('timeout'), timeoutMs) : null;
40
+ try {
41
+ return await fetch(`${apiBase()}${path}`, {
42
+ method,
43
+ headers: {
44
+ 'Authorization': `Bearer ${deviceToken()}`,
45
+ 'Content-Type': contentType,
46
+ },
47
+ body: new Uint8Array(body),
48
+ signal: controller?.signal,
49
+ });
50
+ }
51
+ finally {
52
+ if (timer)
53
+ clearTimeout(timer);
54
+ }
55
+ }
34
56
  /** Device-auth HTTP helper. Sends `Authorization: Bearer <device-token>`
35
57
  * + `Content-Type: application/json` to the configured API base. */
36
58
  export async function deviceFetch(method, path, body, timeoutMs, signal) {
@@ -137,14 +137,16 @@ export async function collectDiagnostics() {
137
137
  } })();
138
138
  // Run the subprocess probes concurrently (each already bounded + best-effort)
139
139
  // so the whole snapshot costs one timeout, not the sum of four.
140
- const [claude, codex, cursor, gpu] = await Promise.all([
141
- probeVersion('claude'), probeVersion('codex'), probeVersion('cursor'), detectGpu(),
140
+ const [claude, codex, grok, cursor, gpu] = await Promise.all([
141
+ probeVersion('claude'), probeVersion('codex'), probeVersion('grok'), probeVersion('cursor'), detectGpu(),
142
142
  ]);
143
143
  const agents = {};
144
144
  if (claude)
145
145
  agents.claude_code = claude;
146
146
  if (codex)
147
147
  agents.codex = codex;
148
+ if (grok)
149
+ agents.grok = grok;
148
150
  if (cursor)
149
151
  agents.cursor = cursor;
150
152
  return {