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
@@ -41,9 +41,9 @@ export const pageInspectCommand = new Command('inspect')
41
41
  .option('--no-reprobe', 'Skip the automatic second probe that filters one-time transient console errors - roughly halves inspect time on a page with any console error, at the cost of possibly reporting a cold-load transient as real')
42
42
  .option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
43
43
  .option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
44
- .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
44
+ .option('--no-fake-media', "Inspect with NO camera or microphone present, so a getUserMedia app takes its no-device error path (use this only when that fallback IS what you're inspecting)")
45
45
  .option('--device <name>', `Inspect as a real touch device: ${INSPECT_DEVICES.join(', ')}. Emulates touch events, mobile user-agent and DPR, so a touch-gated mobile layout is what gets inspected.`)
46
- .option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
46
+ .option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor — nothing carries over from earlier --auth runs.')
47
47
  // Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
48
48
  // an image here (`page screenshot` is the single path for that) — just point there.
49
49
  .addOption(new Option('--screenshot [path]', 'Capture a screenshot').hideHelp())
@@ -65,11 +65,17 @@ export async function inspectPage(url, opts = {}) {
65
65
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
66
66
  const truncate = opts.truncate !== false;
67
67
  const showAll = opts.all === true;
68
+ // The headless browser has a synthetic camera + mic BY DEFAULT. A real user
69
+ // inspecting a camera app has a camera; a browser without one makes every
70
+ // getUserMedia app report a NotFoundError that is an artifact of the probe,
71
+ // not a defect in the page — and worse, the app stops at its error path, so
72
+ // the pipeline the agent actually wanted inspected never runs. A synthetic
73
+ // device costs a page that never asks for media exactly nothing.
68
74
  const inspectBody = {
69
75
  url, waitMs,
70
76
  waitForSelector: opts.waitFor || undefined,
71
77
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
72
- fakeMedia: opts.fakeMedia || undefined,
78
+ fakeMedia: opts.fakeMedia !== false ? true : undefined,
73
79
  device: opts.device ? resolveTouchDevice(opts.device) : undefined,
74
80
  auth: opts.auth || undefined,
75
81
  };
@@ -158,14 +164,18 @@ export async function inspectPage(url, opts = {}) {
158
164
  if (b.navigationIncomplete) {
159
165
  console.log(`${warning('⚠ Navigation incomplete:')} ${b.note || 'page did not reach full load'}`);
160
166
  }
161
- // Auth state: without this line an agent can't distinguish "signed-in render"
162
- // from "--auth silently no-op'd and I'm looking at the anonymous page".
167
+ // Identity line on EVERY run: without it an agent can't distinguish
168
+ // "signed-in render" from "--auth silently no-op'd and I'm looking at the
169
+ // anonymous page" — or assume an unflagged run carried a signed-in session.
163
170
  if (b.auth?.requested) {
164
171
  const who = getAuth()?.email;
165
172
  console.log(b.auth.established
166
173
  ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
167
174
  : `${warning('Auth: session NOT established')}${b.auth.detail ? ` — ${b.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
168
175
  }
176
+ else {
177
+ console.log(muted('Auth: anonymous visitor (signed out; pass --auth to load as your Gipity account)'));
178
+ }
169
179
  console.log(`${muted('Title:')} ${b.title || '(none)'}`);
170
180
  console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
171
181
  console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
@@ -2,12 +2,13 @@ import { Command, Option } from 'commander';
2
2
  import { mkdirSync, writeFileSync } from 'fs';
3
3
  import { dirname, join, resolve as resolvePath } from 'path';
4
4
  import { postForTarEntries } from '../api.js';
5
- import { getProjectRoot } from '../config.js';
5
+ import { getConfig, getProjectRoot } from '../config.js';
6
6
  import { getAuth } from '../auth.js';
7
7
  import { brand, bold, muted, success, warning } from '../colors.js';
8
8
  import { formatSize } from '../utils.js';
9
9
  import { run } from '../helpers/index.js';
10
10
  import { withSpinner } from '../progress.js';
11
+ import { uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
11
12
  /** `mobile` and `tablet` name a real handset/tablet rather than just a window size.
12
13
  * The server emulates it end to end - mobile user-agent, DPR, and crucially TOUCH,
13
14
  * so a page that gates its mobile UI on `'ontouchstart' in window` or
@@ -29,25 +30,50 @@ function label(text) {
29
30
  function fmtMs(ms) {
30
31
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
31
32
  }
32
- /** Auth state line (same format as page inspect/eval): without it an agent
33
- * can't tell a signed-in capture from "--auth silently no-op'd and this is
34
- * the anonymous page". */
33
+ /** Identity line on EVERY capture (same format as page inspect/eval): without
34
+ * it an agent can't tell a signed-in capture from "--auth silently no-op'd and
35
+ * this is the anonymous page" — or assumes an unflagged capture carried a
36
+ * signed-in session. */
35
37
  function printAuthLine(auth) {
36
- if (!auth?.requested)
38
+ if (!auth?.requested) {
39
+ console.log(`${label('Auth')} ${muted('anonymous visitor (signed out; pass --auth to capture as your Gipity account)')}`);
37
40
  return;
41
+ }
38
42
  const who = getAuth()?.email;
39
43
  console.log(auth.established
40
44
  ? `${label('Auth')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
41
45
  : `${warning('Auth: session NOT established')}${auth.detail ? ` — ${auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
42
46
  }
43
- /** A failed --action still yields a screenshot - of the page the action never
47
+ /** A failed pre-capture script still yields a screenshot - of the page it never
44
48
  * touched. Silence there is the trap: the image looks plausible and the command
45
- * reports success, so the caller trusts a capture of the wrong state. Say so. */
46
- function printActionErrorLine(actionError) {
49
+ * reports success, so the caller trusts a capture of the wrong state. Say so
50
+ * and say WHICH half failed, since --wait-for and --action ride the same script:
51
+ * a gate that never matched is a different problem (and a different fix) from a
52
+ * click that threw. */
53
+ export function printActionErrorLine(actionError) {
47
54
  if (!actionError)
48
55
  return;
56
+ if (/wait-for:/.test(actionError)) {
57
+ console.log(`${warning('⚠ --wait-for never matched:')} ${actionError.replace(/^EvalError:\s*/, '')} ${muted('(the image below is the state the page WAS in when the gate gave up)')}`);
58
+ return;
59
+ }
49
60
  console.log(`${warning('⚠ --action failed:')} ${actionError} ${muted('(this image shows the page BEFORE the action ran)')}`);
50
61
  }
62
+ /** The browser sandbox has one wall-clock budget for the entire capture, and
63
+ * --action's own runtime spends it. When the sandbox times out on a run that
64
+ * passed --action, the server's message ("platform-side failure, the page was
65
+ * never reached") is the whole story only if the action was cheap — so append
66
+ * the lever the caller actually has. Exported for tests. */
67
+ export function augmentSandboxTimeout(message) {
68
+ if (!/did not respond within/i.test(message))
69
+ return message;
70
+ return (`${message}\n` +
71
+ `A pre-capture script was set (--action and/or the --wait-for gate), and its runtime is spent INSIDE ` +
72
+ `that same sandbox budget — a script that waits on slow work (a WASM/model download, a network fetch, ` +
73
+ `a long animation) can exhaust the budget on its own. Keep --action to the interaction itself (a click, ` +
74
+ `a keypress), let the page do the slow work on load, and absorb that with --wait <ms> or a tighter ` +
75
+ `--wait-for '<selector>' gate instead.`);
76
+ }
51
77
  function fmtPerformance(p) {
52
78
  const parts = [
53
79
  `TTFB ${fmtMs(p.ttfb)}`,
@@ -74,13 +100,15 @@ function dimSuffix(vp) {
74
100
  const dpr = vp.deviceScaleFactor ?? 1;
75
101
  return dpr === 1 ? `${vp.width}x${vp.height}` : `${vp.width}x${vp.height}@${dpr}`;
76
102
  }
77
- /** Default screenshot directory: `<project-root>/.gipity/screenshots`, falling
78
- * back to `./.gipity/screenshots` in one-off mode (no linked project). `.gipity/`
79
- * is sync-ignored, so these verification artifacts never sync to Gipity or
80
- * deploy to the CDN - and they stay out of the project root. */
103
+ /** Default screenshot directory: `<project-root>/screenshots`, falling back
104
+ * to `./screenshots` in one-off mode (no linked project). Screenshots are
105
+ * part of the project's build history: the dir syncs to Gipity (the server
106
+ * also persists captures to VFS `screenshots/` directly same names, so
107
+ * sync reconciles by content hash) but is excluded from every deploy
108
+ * server-side. Timestamped filenames make the history browsable. */
81
109
  function defaultScreenshotDir() {
82
110
  const root = getProjectRoot();
83
- return join(root ?? '.', '.gipity', 'screenshots');
111
+ return join(root ?? '.', 'screenshots');
84
112
  }
85
113
  /** `yyyy-mm-dd_hh-mm-ss` per the repo timestamp convention - sorts chronologically,
86
114
  * filesystem-safe. One stamp per invocation; viewport suffixes keep multi-shot
@@ -151,49 +179,149 @@ function appendOption(value, previous = []) {
151
179
  return [...previous, value];
152
180
  }
153
181
  // Pre-capture scripting lives on --action, but agents reach for the JS-intent
154
- // names first and an "unknown option" alone doesn't name the right flag. Capture
155
- // the common guesses as hidden decoys (taking a value, so they swallow the
156
- // script) and redirect precisely — same pattern as `page eval`'s JS_DECOY_FLAGS.
157
- const ACTION_DECOY_FLAGS = ['--eval', '--js', '--javascript', '--script', '--code', '--exec'];
182
+ // names first --eval above all, the name they already know from `page eval`.
183
+ // The intent is unambiguous (run this JS in the page before the shot), so these
184
+ // are working hidden aliases, not errors — same accept-the-reflex-guess pattern
185
+ // as --full-page and --width/--height. (Supersedes the earlier decoy approach,
186
+ // which swallowed the script and errored with a redirect: when the guess is
187
+ // unambiguous, making it WORK beats making it a better error.)
188
+ const ACTION_ALIAS_FLAGS = ['--eval', '--js', '--javascript', '--script', '--code', '--exec'];
189
+ /** A capture is worthless if it fires before the app reaches the state you meant
190
+ * to photograph, and the only lever used to be a blind millisecond delay — so a
191
+ * camera/vision app got screenshotted with a guessed duration (`--wait 22000`).
192
+ * `--wait-for '<selector>'` is the same deterministic gate `page eval` and
193
+ * `page inspect` take, and it runs here as the head of the pre-capture script:
194
+ * poll for the element, then let any --action run. Timing out THROWS, so the
195
+ * server reports it (the shot still happens) instead of silently handing back a
196
+ * picture of the wrong moment. Exported for tests. */
197
+ export function buildWaitForGate(selector, timeoutMs) {
198
+ const sel = JSON.stringify(selector);
199
+ return (`await (async () => { const __t0 = Date.now(); ` +
200
+ `while (Date.now() - __t0 < ${timeoutMs}) { ` +
201
+ `try { if (document.querySelector(${sel})) return; } catch (e) { throw new Error('wait-for: invalid selector ' + ${sel}); } ` +
202
+ `await new Promise((r) => setTimeout(r, 100)); } ` +
203
+ `throw new Error('wait-for: nothing matched ' + ${sel} + ' within ${timeoutMs}ms — the page never reached that state (raise --wait-timeout, fix the selector, or check the app actually gets there headlessly)'); })();`);
204
+ }
205
+ /** Max ms the selector gate may wait. The gate runs inside the capture's sandbox
206
+ * exec budget (page load + delay + script + settle + render), and 15s is what
207
+ * that budget actually covers — asking for more would blow the whole capture and
208
+ * come back as "the browser sandbox did not respond", blaming the platform for a
209
+ * wait the CLI accepted. A state that takes longer than this to appear is not a
210
+ * screenshot problem: gate it on `page eval --wait-for` (30s) and read it there. */
211
+ export const WAIT_FOR_MAX_MS = 15_000;
212
+ export const WAIT_FOR_DEFAULT_MS = 15_000;
213
+ /** The server's cap on the post-load delay. Over it, the request is rejected by
214
+ * schema validation with no mention of the flag — clamp and explain instead. */
215
+ export const MAX_POST_LOAD_DELAY_MS = 30_000;
216
+ /** A camera app has nothing to photograph 1s after load: getUserMedia has to come
217
+ * up and the vision model (WASM + weights) has to download before the app can
218
+ * draw a single box or label. Same window `page eval --camera` takes — without it
219
+ * every camera screenshot is a picture of a loading screen, and the caller is
220
+ * left guessing a duration. */
221
+ export const CAMERA_DEFAULT_DELAY_MS = 15_000;
158
222
  export const pageScreenshotCommand = new Command('screenshot')
159
223
  .description('Screenshot a web page')
160
224
  .argument('<url>', 'URL to screenshot')
161
- // No commander default: a default here makes opts.postLoadDelay always set,
162
- // so the `?? opts.wait` merge below would never see the --wait alias. Default
163
- // is applied in the merge instead.
164
- .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
225
+ // --device / --viewport lead the options: "how does this look on a phone?" is
226
+ // the single most common reason to screenshot, and agents routinely read the
227
+ // help through `--help | head -N` — buried below the timing flags, these two
228
+ // fell off the bottom and the run proceeded at desktop width.
229
+ .option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
230
+ .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag), e.g. --viewport 390x844 for a phone-sized window', appendOption, [])
231
+ // No commander default: a default here would make the value always set, so the
232
+ // merge below could not tell "caller chose a delay" from "nobody did" — which
233
+ // is what --camera's model-load default and the --wait-for gate both hinge on.
234
+ .option('--wait <ms>', `Sleep this many ms after DOMContentLoaded before capturing (default 1000, max ${MAX_POST_LOAD_DELAY_MS}). With --camera it defaults to ${CAMERA_DEFAULT_DELAY_MS} so the vision model is up. Same flag as page eval/inspect.`)
235
+ .option('--wait-for <selector>', `Wait until this CSS selector appears before capturing, then capture (deterministic - beats guessing a --wait). Same flag as page eval/inspect. Gate on what you are photographing ('[data-vision="ready"]', '#verdict:not(:empty)'). Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
236
+ .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (default ${WAIT_FOR_DEFAULT_MS}, max ${WAIT_FOR_MAX_MS}). Timing out is reported - the capture still happens, so you see the state it got stuck in.`)
165
237
  .option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs as an async function body, so const/await and app-relative import(\'./…\') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.')
166
238
  .option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
167
239
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
168
- .option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
169
- .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
170
240
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
171
- .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
172
- .option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
241
+ .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly. The video feed is a built-in test pattern — to capture what the app does with a REAL frame (a hand, a face, an object), use --camera <path> instead.')
242
+ .option('--camera <path>', 'Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser\'s WEBCAM feed, then capture so the shot shows the app reacting to a frame you chose (detected gesture, boxes, labels). Implies --fake-media.')
243
+ .option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor — nothing carries over from earlier --auth runs.')
244
+ .option('--ephemeral', 'Skip the project screenshot history: do not persist this capture to Gipity (screenshots/ in the project). Local file is still written.')
173
245
  .option('--json', 'Output JSON metadata instead of a friendly summary')
174
- .addOption(new Option('--wait <ms>', 'Alias for --post-load-delay').hideHelp())
246
+ .addOption(new Option('--post-load-delay <ms>', 'Alias for --wait').hideHelp())
247
+ // (--eval and the other JS-intent guesses are registered in one place from
248
+ // ACTION_ALIAS_FLAGS below — a second standalone registration here makes
249
+ // commander throw "conflicting flag" at startup.)
175
250
  // `--full-page` is the Puppeteer/Playwright name for this (their `fullPage`),
176
251
  // so agents reach for it by reflex. Accept it as a hidden alias for `--full`
177
252
  // rather than reject it as an unknown option and send them on a --help detour.
178
253
  .addOption(new Option('--full-page', 'Alias for --full').hideHelp())
254
+ // `--width 390 --height 844` is the setViewport vocabulary agents guess first
255
+ // when asked for a phone-sized shot. Accept the pair as a working alias for
256
+ // --viewport WxH instead of bouncing the guess into a --help detour.
257
+ .addOption(new Option('--width <px>', 'Alias: --viewport WxH').hideHelp())
258
+ .addOption(new Option('--height <px>', 'Alias: --viewport WxH').hideHelp())
179
259
  .action((url, opts) => run('Page screenshot', async () => {
180
- // A JS-intent flag guess (captured as a hidden decoy below): name the real
181
- // flag before any other arg-shape check fires.
182
- const decoy = ACTION_DECOY_FLAGS.find((f) => opts[f.slice(2)] !== undefined);
183
- if (decoy) {
184
- pageScreenshotCommand.error(`error: ${decoy} is not a flag on screenshot — use --action "<js>" to run JavaScript in the page ` +
185
- `before the capture, e.g. gipity page screenshot "<url>" --action "document.getElementById('play').click()"`);
260
+ // A JS-intent alias guess (--eval et al., hidden): fold it into the action
261
+ // script so the reflex guess just works, and name the canonical flag once
262
+ // so the next call uses it.
263
+ const aliasFlag = ACTION_ALIAS_FLAGS.find((f) => opts[f.slice(2)] !== undefined);
264
+ if (aliasFlag) {
265
+ console.error(muted(`Note: treating ${aliasFlag} as --action — it runs your JS in the page before the capture. --action is the canonical flag.`));
266
+ }
267
+ const actionScript = [opts.action, ...ACTION_ALIAS_FLAGS.map((f) => opts[f.slice(2)])]
268
+ .filter(Boolean).join('\n');
269
+ // --wait is the canonical name (it is what `page inspect`/`page eval` call it);
270
+ // --post-load-delay stays as a hidden alias. Whether the caller named EITHER is
271
+ // what decides the defaults below, so keep the raw "was it set" signal.
272
+ const delayRaw = opts.wait ?? opts.postLoadDelay;
273
+ const chosenDelay = delayRaw !== undefined;
274
+ let postLoadDelayMs = chosenDelay ? parseInt(String(delayRaw), 10) : 1000;
275
+ if (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0) {
276
+ throw new Error('--wait must be a non-negative integer (ms)');
277
+ }
278
+ if (postLoadDelayMs > MAX_POST_LOAD_DELAY_MS) {
279
+ console.error(warning(`--wait ${postLoadDelayMs}ms exceeds the ${MAX_POST_LOAD_DELAY_MS}ms cap — using ${MAX_POST_LOAD_DELAY_MS}ms. ` +
280
+ `Waiting longer is rarely the fix: gate on the state you want with --wait-for '<selector>' instead of guessing a duration.`));
281
+ postLoadDelayMs = MAX_POST_LOAD_DELAY_MS;
282
+ }
283
+ const waitForTimeoutRaw = opts.waitTimeout !== undefined ? parseInt(String(opts.waitTimeout), 10) : WAIT_FOR_DEFAULT_MS;
284
+ if (!Number.isFinite(waitForTimeoutRaw) || waitForTimeoutRaw < 0) {
285
+ throw new Error('--wait-timeout must be a non-negative integer (ms)');
286
+ }
287
+ const waitForTimeoutMs = Math.min(waitForTimeoutRaw, WAIT_FOR_MAX_MS);
288
+ if (waitForTimeoutRaw > WAIT_FOR_MAX_MS) {
289
+ console.error(warning(`--wait-timeout ${waitForTimeoutRaw}ms exceeds the ${WAIT_FOR_MAX_MS}ms the capture's browser budget covers — using ${WAIT_FOR_MAX_MS}ms. ` +
290
+ `A state that takes longer than that to appear is not a screenshot problem: watch for it with ` +
291
+ `\`gipity page eval <url> --wait-for '<selector>' --wait-timeout 30000\` (a wider budget), then capture it.`));
292
+ }
293
+ if (opts.waitTimeout !== undefined && !opts.waitFor) {
294
+ throw new Error("--wait-timeout only means something with --wait-for '<selector>' (it bounds that gate)");
186
295
  }
187
- // --wait is a hidden alias for --post-load-delay (agents reach for it because
188
- // sibling `page inspect`/`eval` name the flag --wait). Canonical name wins if
189
- // both given; fall back to the 1000ms default when neither is set.
190
- const delayRaw = opts.postLoadDelay ?? opts.wait ?? '1000';
191
- const postLoadDelayMs = delayRaw !== undefined ? parseInt(String(delayRaw), 10) : undefined;
192
- if (postLoadDelayMs !== undefined && (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0)) {
193
- throw new Error('--post-load-delay must be a non-negative integer (ms)');
296
+ // A camera app is a loading screen at the 1s default: the model has to come up
297
+ // first. Give it the same window `page eval --camera` takes, unless the caller
298
+ // said otherwise either with their own --wait, or by gating on --wait-for,
299
+ // which ends the moment the app is actually ready.
300
+ if (opts.camera && !chosenDelay && !opts.waitFor) {
301
+ postLoadDelayMs = CAMERA_DEFAULT_DELAY_MS;
302
+ console.error(muted(`--camera: waiting ${CAMERA_DEFAULT_DELAY_MS / 1000}s before the capture so the camera and the app's vision ` +
303
+ `model finish loading. Override with --wait <ms>, or use --wait-for '<ready-selector>' to shoot the moment it's ready.`));
194
304
  }
195
305
  const deviceNames = splitCsv(opts.device);
196
306
  const viewportStrs = splitCsv(opts.viewport);
307
+ // --width/--height: fold the alias pair into a --viewport entry. Half a
308
+ // pair is a mis-invocation worth one precise line, not a range error.
309
+ if (opts.width !== undefined || opts.height !== undefined) {
310
+ if (opts.width === undefined || opts.height === undefined) {
311
+ pageScreenshotCommand.error('error: --width and --height go together (both in px, equivalent to --viewport WxH) — ' +
312
+ 'or use a preset like --device mobile for a real handset (touch events, mobile user-agent, DPR)');
313
+ }
314
+ if (!/^\d+$/.test(String(opts.width)) || !/^\d+$/.test(String(opts.height))) {
315
+ pageScreenshotCommand.error('error: --width and --height must be integers (px)');
316
+ }
317
+ viewportStrs.push(`${opts.width}x${opts.height}`);
318
+ // A raw phone-sized window is NOT a phone: touch-gated mobile UI still
319
+ // renders its desktop variant. Say so once, on the path where the caller
320
+ // was clearly after a phone.
321
+ if (parseInt(String(opts.width), 10) <= 500) {
322
+ console.error(muted('Tip: --device mobile emulates a real handset (touch events, mobile user-agent, DPR) — a raw viewport only resizes the window.'));
323
+ }
324
+ }
197
325
  const customViewports = [
198
326
  ...deviceNames.map(resolveDevice),
199
327
  ...viewportStrs.map(parseViewportString),
@@ -204,23 +332,80 @@ export const pageScreenshotCommand = new Command('screenshot')
204
332
  // Server defaults to 1280×720 when viewports is omitted - don't send it in
205
333
  // the no-flag case so the filename stays unsuffixed (no viewport segment).
206
334
  const userSpecifiedViewports = customViewports.length > 0;
335
+ // Filenames are decided before the request so the server can persist the
336
+ // captures to the project VFS under the SAME names the local files get —
337
+ // the local screenshots/ dir and the VFS screenshot history stay 1:1 and
338
+ // sync reconciles them by content hash instead of duplicating.
339
+ const slug = slugFromUrl(url);
340
+ const ts = timestampSlug();
341
+ const shotName = (vp) => defaultFilename(slug, ts, vp && userSpecifiedViewports ? dimSuffix(vp) : undefined);
342
+ const names = userSpecifiedViewports ? customViewports.map(shotName) : [shotName()];
343
+ const projectGuid = getConfig()?.projectGuid;
344
+ const save = !opts.ephemeral && projectGuid ? { project_guid: projectGuid, names } : undefined;
345
+ // The webcam frame is hosted in the project's public file store for the
346
+ // browser container to fetch, so it needs a linked project. Validate the
347
+ // file locally first — a bad file type costs one instant error, not an
348
+ // upload plus an opaque browser-side failure.
349
+ let camera;
350
+ if (opts.camera) {
351
+ assertCameraFile(opts.camera);
352
+ if (!projectGuid)
353
+ throw new Error('--camera needs a linked project (the frame is hosted for the browser to fetch) — run `gipity link` first.');
354
+ console.log(muted(`Hosting camera feed ${opts.camera}…`));
355
+ camera = await uploadCameraFeed(projectGuid, opts.camera);
356
+ }
357
+ // The pre-capture script the server runs after the post-load delay: the
358
+ // --wait-for gate first (so the shot waits for the state, deterministically),
359
+ // then the caller's --action (with any alias-flag scripts folded in by
360
+ // actionScript above). All the same in-page primitive, so they
361
+ // compose into one script rather than needing a second server round-trip.
362
+ const preCapture = [
363
+ opts.waitFor ? buildWaitForGate(opts.waitFor, waitForTimeoutMs) : '',
364
+ actionScript,
365
+ ].filter(Boolean).join('\n');
207
366
  const body = {
208
367
  url,
209
368
  postLoadDelayMs,
210
369
  full: !!(opts.full || opts.fullPage),
211
370
  reloadBetween: opts.reloadBetween !== false,
212
371
  ...(userSpecifiedViewports ? { viewports: customViewports } : {}),
213
- ...(opts.fakeMedia ? { fakeMedia: true } : {}),
372
+ // A camera feed is played into the synthetic devices, so --camera implies
373
+ // --fake-media rather than failing on the pairing.
374
+ ...(opts.fakeMedia || camera ? { fakeMedia: true } : {}),
375
+ ...(camera ? { cameraUrl: camera.url } : {}),
214
376
  ...(opts.auth ? { auth: true } : {}),
215
- ...(opts.action ? { action: opts.action } : {}),
377
+ ...(preCapture ? { action: preCapture } : {}),
378
+ ...(save ? { save } : {}),
216
379
  };
217
380
  // Load + render across viewports runs server-side and can take many
218
381
  // seconds; animate the wait, then clear so the saved-files summary is the
219
382
  // result. JSON mode skips the spinner (shares stdout).
220
383
  const doShoot = () => postForTarEntries('/tools/browser/screenshot', body);
221
- const entries = opts.json
222
- ? await doShoot()
223
- : await withSpinner('Capturing…', doShoot, { done: null });
384
+ let entries;
385
+ try {
386
+ try {
387
+ entries = opts.json
388
+ ? await doShoot()
389
+ : await withSpinner('Capturing…', doShoot, { done: null });
390
+ }
391
+ catch (err) {
392
+ // The sandbox budget covers the WHOLE capture — load, --action, settle,
393
+ // render. The server's timeout text (rightly) says the page was never
394
+ // reached, which reads as "nothing you can do" and leaves a long-running
395
+ // --action looking innocent. Name it, so the retry is an informed one.
396
+ throw preCapture ? new Error(augmentSandboxTimeout(err.message)) : err;
397
+ }
398
+ }
399
+ finally {
400
+ if (camera) {
401
+ try {
402
+ await deleteFixture(projectGuid, camera.guid);
403
+ }
404
+ catch (err) {
405
+ console.error(warning(`⚠ Could not auto-delete camera feed "${camera.name}" (${camera.guid}) — still hosted at ${camera.url}: ${err.message}`));
406
+ }
407
+ }
408
+ }
224
409
  const metaEntry = entries.find((e) => e.name === 'meta.json');
225
410
  if (!metaEntry)
226
411
  throw new Error('Server response missing meta.json');
@@ -229,16 +414,12 @@ export const pageScreenshotCommand = new Command('screenshot')
229
414
  if (pngs.length !== meta.screenshots.length) {
230
415
  throw new Error(`Server returned ${pngs.length} PNGs but ${meta.screenshots.length} metadata entries`);
231
416
  }
232
- const slug = slugFromUrl(url);
233
- const ts = timestampSlug();
234
417
  const dir = defaultScreenshotDir();
235
418
  const savedFiles = [];
236
419
  for (let i = 0; i < pngs.length; i++) {
237
- const shot = meta.screenshots[i];
238
- const suffix = userSpecifiedViewports ? dimSuffix(shot.viewport) : undefined;
239
420
  const target = opts.output
240
421
  ? opts.output
241
- : join(dir, defaultFilename(slug, ts, suffix));
422
+ : join(dir, names[i] ?? shotName(meta.screenshots[i].viewport));
242
423
  // Create the target's parent dir so a `-o` path under a not-yet-existing
243
424
  // directory (e.g. .gipity/screenshots/home.png) writes cleanly instead of
244
425
  // failing with a raw ENOENT and forcing a manual `mkdir -p`.
@@ -271,10 +452,22 @@ export const pageScreenshotCommand = new Command('screenshot')
271
452
  size_bytes: s.screenshotSizeBytes,
272
453
  full_page: meta.full,
273
454
  phase: s.phase,
455
+ ...(s.vfs ? { gipity: s.vfs } : {}),
274
456
  })),
275
457
  }));
276
458
  return;
277
459
  }
460
+ /** One-line VFS-history status for a screenshot ("saved to Gipity" or why not). */
461
+ const printVfsLine = (s) => {
462
+ if (!s.vfs)
463
+ return;
464
+ if ('error' in s.vfs) {
465
+ console.log(`${warning('⚠ Gipity save failed:')} ${s.vfs.error} ${muted('(local file is fine)')}`);
466
+ }
467
+ else {
468
+ console.log(`${label('Gipity history')} ${s.vfs.path} ${muted('(synced, not deployed)')}`);
469
+ }
470
+ };
278
471
  if (meta.screenshots.length === 1) {
279
472
  const s = meta.screenshots[0];
280
473
  console.log(`${brand('Screenshot')} ${bold(url)}`);
@@ -295,6 +488,7 @@ export const pageScreenshotCommand = new Command('screenshot')
295
488
  if (s.width && s.height)
296
489
  console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
297
490
  console.log(`${label('Screenshot file')} ${success(savedFiles[0])}`);
491
+ printVfsLine(s);
298
492
  return;
299
493
  }
300
494
  console.log(`${brand('Loading')} ${bold(url)} ${muted(`once → ${meta.screenshots.length} viewports`)}`);
@@ -318,20 +512,21 @@ export const pageScreenshotCommand = new Command('screenshot')
318
512
  if (s.width && s.height)
319
513
  console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
320
514
  console.log(`${label('Screenshot file')} ${success(savedFiles[i])}`);
515
+ printVfsLine(s);
321
516
  }
322
517
  }));
323
- // Register the JS-intent guesses as hidden decoys (value-taking, so they swallow
324
- // the script) — the action turns any of them into the "--action" redirect above.
325
- for (const f of ACTION_DECOY_FLAGS)
326
- pageScreenshotCommand.addOption(new Option(`${f} <value>`).hideHelp());
327
- // `screenshot` captures the page AFTER load + settle (+ optional --action). There
328
- // is no scroll-to-a-position or wait-for-a-selector lever (agents reach for
329
- // --scroll/--selector and get an unknown-option detour) — but `--full` does walk
330
- // the page top→bottom→top before the shot so scroll-reveal content paints in.
331
- // State the supported levers right here, so the help (rendered on any bad flag,
332
- // and this 'after' block survives `| tail`/`| grep`) ends the hunt in one shot.
333
- // --action covers "click, then shoot"; --full + crop covers off-screen regions
334
- // (and triggers reveals); `page eval` reads data without a picture.
518
+ // Register the JS-intent guesses as hidden aliases for --action (value-taking,
519
+ // so they capture the script) — the action folds them into the pre-capture body.
520
+ for (const f of ACTION_ALIAS_FLAGS)
521
+ pageScreenshotCommand.addOption(new Option(`${f} <js>`, 'Alias for --action').hideHelp());
522
+ // `screenshot` captures the page AFTER load + settle (+ optional --wait-for gate
523
+ // and --action). There is no scroll-to-a-position lever (agents reach for
524
+ // --scroll and get an unknown-option detour) — but `--full` does walk the page
525
+ // top→bottom→top before the shot so scroll-reveal content paints in. State the
526
+ // supported levers right here, so the help (rendered on any bad flag, and this
527
+ // 'after' block survives `| tail`/`| grep`) ends the hunt in one shot.
528
+ // --wait-for covers "shoot once it's ready"; --action covers "click, then shoot";
529
+ // --full + crop covers off-screen regions; `page eval` reads data, no picture.
335
530
  pageScreenshotCommand.addHelpText('after', `
336
531
  Examples:
337
532
  gipity page screenshot "https://dev.gipity.ai/me/app/"
@@ -339,11 +534,21 @@ Examples:
339
534
  gipity page screenshot "https://dev.gipity.ai/me/app/" --device mobile,desktop
340
535
  gipity page screenshot "https://dev.gipity.ai/me/app/" \\
341
536
  --action "document.getElementById('play').click()" # capture an in-game frame
537
+ # Camera / vision app: play a real frame in as the webcam and shoot once the app
538
+ # says it's ready — same --camera / --wait-for flags as 'page eval', no guessed delay:
539
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --camera fist.png \\
540
+ --wait-for '[data-vision="ready"]'
541
+
542
+ Waiting for the page to reach a state before the shot?
543
+ --wait-for '<selector>' gates the capture on the app's own signal (deterministic).
544
+ Reach for --wait <ms> only when there is nothing to gate on — a guessed duration
545
+ either shoots too early or wastes the difference.
342
546
 
343
547
  Capturing a state that needs an interaction (start a game, open a menu, dismiss a modal)?
344
- Use --action to run JS in the page before the shot — it fires after the post-load
345
- delay, then settles again so the result has painted. Do NOT hand-roll a 'page eval'
346
- that returns a base64 image: the eval result is capped (~16KB) and truncates the PNG.
548
+ Use --action to run JS in the page before the shot — it fires after the wait
549
+ (and after any --wait-for gate), then settles again so the result has painted. Do
550
+ NOT hand-roll a 'page eval' that returns a base64 image: the eval result is capped
551
+ (~16KB) and truncates the PNG.
347
552
 
348
553
  Capturing an off-screen region or reading element data?
349
554
  • --full captures the ENTIRE scrollable page (then crop to the region).
@@ -134,7 +134,7 @@ projectCommand
134
134
  console.log(`${muted('Next:')} switch to "${project.name}" in the sidebar.`);
135
135
  }
136
136
  else {
137
- console.log(`${muted('Next:')} exit Claude (Ctrl+D), then run: ${brand('gipity claude')}`);
137
+ console.log(`${muted('Next:')} exit Claude (Ctrl+D), then run: ${brand('gipity build')}`);
138
138
  console.log(`${muted('Pick')} "${project.name}" ${muted(`- it'll be at the top of the list.`)}`);
139
139
  }
140
140
  }));
@@ -6,7 +6,7 @@ import { installAutostart, removeAutostart, resolveCliPath } from '../relay/setu
6
6
  function requirePaired() {
7
7
  const device = state.getDevice();
8
8
  if (!device) {
9
- console.error(`${clrError('No paired device.')} Run ${bold('gipity claude')} to pair this machine.`);
9
+ console.error(`${clrError('No paired device.')} Run ${bold('gipity connect')} to pair this machine.`);
10
10
  process.exit(1);
11
11
  }
12
12
  return device;
@@ -121,7 +121,7 @@ relayCommand
121
121
  return;
122
122
  }
123
123
  if (!s.device) {
124
- console.log(`${muted('No paired device.')} Run ${brand('gipity claude')} to pair this machine.`);
124
+ console.log(`${muted('No paired device.')} Run ${brand('gipity connect')} to pair this machine.`);
125
125
  return;
126
126
  }
127
127
  console.log(`${bold('Device:')} ${brand(s.device.name)} ${muted(`(${s.device.guid})`)}`);
@@ -380,7 +380,7 @@ registerInstallCommands(relayCommand);
380
380
  function requirePaired() {
381
381
  const device = state.getDevice();
382
382
  if (!device) {
383
- console.error(`${clrError('No paired device.')} Run ${brand('gipity claude')} to pair this machine.`);
383
+ console.error(`${clrError('No paired device.')} Run ${brand('gipity connect')} to pair this machine.`);
384
384
  process.exit(1);
385
385
  }
386
386
  return device;