gipity 1.0.384 → 1.0.386

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/auth.js CHANGED
@@ -52,17 +52,18 @@ export function isExpired() {
52
52
  const buffer = 5 * 60 * 1000; // 5 minute buffer
53
53
  return Date.now() > expiresAt - buffer;
54
54
  }
55
- export function getTimeRemaining() {
55
+ /** True only when re-login is genuinely required: the refresh token itself
56
+ * has expired. Access-token expiry (`expiresAt` / isExpired) is invisible
57
+ * to users — every API call renews it via refreshTokenIfNeeded() — so it
58
+ * must never be surfaced as a session warning. */
59
+ export function sessionExpired() {
56
60
  const auth = getAuth();
57
61
  if (!auth)
58
- return 'not authenticated';
59
- const ms = new Date(auth.expiresAt).getTime() - Date.now();
60
- if (ms <= 0)
61
- return 'expired';
62
- const mins = Math.floor(ms / 60000);
63
- if (mins < 60)
64
- return `${mins}m remaining`;
65
- return `${Math.floor(mins / 60)}h ${mins % 60}m remaining`;
62
+ return true;
63
+ const exp = decodeJwtExp(auth.refreshToken);
64
+ if (!exp)
65
+ return false; // undecodable - let the refresh path decide
66
+ return Date.now() > exp * 1000;
66
67
  }
67
68
  export async function refreshTokenIfNeeded() {
68
69
  if (!isExpired())
@@ -91,7 +91,10 @@ dbCommand
91
91
  else {
92
92
  const config = requireConfig();
93
93
  const res = await get(`/projects/${config.projectGuid}/databases`);
94
- printList(res.data, opts, 'No databases. Create one: gipity db create <name>', db => db.friendlyName);
94
+ // This list is project-scoped; the account-wide database cap counts
95
+ // databases across ALL projects. An empty project can still be at the
96
+ // cap, so point at `--all` rather than implying nothing exists.
97
+ printList(res.data, opts, 'No databases in this project. Run `gipity db list --all` to see every database counting toward your account cap, or create one: gipity db create <name>', db => db.friendlyName);
95
98
  }
96
99
  }));
97
100
  dbCommand
@@ -73,8 +73,20 @@ export const deployCommand = new Command('deploy')
73
73
  }
74
74
  }
75
75
  console.log(muted('─'.repeat(40)));
76
- const hasFailed = d.phases?.some(p => p.status === 'failed');
77
- if (hasFailed) {
76
+ const failedPhases = d.phases?.filter(p => p.status === 'failed') ?? [];
77
+ if (failedPhases.length > 0) {
78
+ // The database phase can fail on the account-wide database cap, whose
79
+ // server message ("Maximum of N databases reached. Drop one first.")
80
+ // names no command. The droppable databases live in OTHER projects, so
81
+ // the default project-scoped `gipity db list` shows nothing — point the
82
+ // caller straight at the account-wide list + drop path so they don't
83
+ // dead-end (or reach for raw DB access) to free a slot.
84
+ if (failedPhases.some(p => /databases? reached|database (cap|limit)/i.test(p.summary))) {
85
+ console.log('');
86
+ console.log(muted('Free a slot under the account database cap:'));
87
+ console.log(` ${brand('gipity db list --all')} ${muted('# every database counting toward the cap, by project')}`);
88
+ console.log(` ${brand('gipity db drop <name> --project <slug>')} ${muted('# drop one from another project')}`);
89
+ }
78
90
  console.log(clrError(`Deploy failed`) + muted(` (${d.elapsedMs}ms)`));
79
91
  process.exit(1);
80
92
  }
@@ -2,36 +2,38 @@ import { Command } from 'commander';
2
2
  import { basename, resolve, dirname } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { getAccountSlug } from '../api.js';
5
- import { getConfig, getConfigPath } from '../config.js';
5
+ import { getConfig, getConfigPath, saveConfigAt } from '../config.js';
6
6
  import { getAuth } from '../auth.js';
7
- import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS } from '../setup.js';
7
+ import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from '../setup.js';
8
8
  import { success, error as clrError, info, muted, bold } from '../colors.js';
9
9
  import { confirm } from '../utils.js';
10
10
  import { scanForAdoption, adoptCurrentDir, canAdoptCwd, formatBytes, formatCwdLabel, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
11
11
  const TOOL_KEYS = SUPPORTED_TOOLS.map(t => t.key);
12
12
  function resolveTools(forFlag) {
13
- if (!forFlag || forFlag === 'all')
14
- return SUPPORTED_TOOLS;
13
+ if (!forFlag)
14
+ return DEFAULT_TOOLS;
15
15
  const requested = forFlag.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
16
16
  const unknown = requested.filter(k => !TOOL_KEYS.includes(k) && k !== 'all');
17
17
  if (unknown.length) {
18
18
  throw new Error(`Unknown --for value(s): ${unknown.join(', ')}. Valid: ${TOOL_KEYS.join(', ')}, all`);
19
19
  }
20
- if (requested.includes('all'))
21
- return SUPPORTED_TOOLS;
22
- return SUPPORTED_TOOLS.filter(t => requested.includes(t.key));
20
+ // `all` expands to the default set; an opt-in tool still joins when named
21
+ // alongside it (`--for all,aider`).
22
+ return SUPPORTED_TOOLS.filter(t => requested.includes(t.key) || (!t.optIn && requested.includes('all')));
23
23
  }
24
24
  export const initCommand = new Command('init')
25
25
  .description('Link this directory to a Gipity project (writes primer files so your AI coding tool understands Gipity)')
26
26
  .argument('[name]', 'Project name/slug (defaults to current directory name)')
27
27
  .option('--agent <guid>', 'Agent GUID to use')
28
- .option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all. Choices: ${TOOL_KEYS.join(', ')}, all`)
28
+ .option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all except aider (opt-in - it also writes .aider.conf.yml). Choices: ${TOOL_KEYS.join(', ')}, all`)
29
29
  .addHelpText('after', `
30
30
  Examples:
31
31
  $ gipity init Link cwd as a new project (slug = dir name).
32
32
  $ gipity init my-app Link cwd with an explicit slug.
33
33
  $ gipity init --for codex Write only AGENTS.md (skip Claude/Cursor/etc).
34
34
  $ gipity init --for cursor,gemini Write only the Cursor + Gemini primers.
35
+ $ gipity init --for aider AGENTS.md + a read: entry in .aider.conf.yml
36
+ (aider auto-reads nothing, so it's opt-in).
35
37
 
36
38
  Working with an existing Gipity project:
37
39
  - If cwd's name matches the remote project's slug, init auto-adopts it.
@@ -79,6 +81,17 @@ Working with an existing Gipity project:
79
81
  setupClaudeHooks();
80
82
  writeAllPrimers();
81
83
  setupGitignore();
84
+ // The config's ignore list was frozen at link time, so a workstation
85
+ // artifact introduced by a newer CLI (e.g. aider's .aider.conf.yml)
86
+ // would sync up as project content. Union in the current defaults.
87
+ if (existing) {
88
+ const cur = existing.ignore ?? (existing.ignore = []);
89
+ const missing = DEFAULT_SYNC_IGNORE.filter(e => !cur.includes(e));
90
+ if (missing.length) {
91
+ cur.push(...missing);
92
+ saveConfigAt(cwd, existing);
93
+ }
94
+ }
82
95
  console.log(success(`Refreshed primer files: ${primerSummary}.`));
83
96
  return;
84
97
  }
@@ -1,8 +1,64 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { Command } from 'commander';
2
3
  import { post, get, ApiError } from '../api.js';
3
4
  import { brand, bold, muted, warning } from '../colors.js';
4
5
  import { run } from '../helpers/index.js';
6
+ // Shown when an eval runs cleanly but returns nothing serializable. Turns a
7
+ // bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
8
+ // a returnable value instead of guessing and retrying.
9
+ export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A statement body with no `return`, an assignment, a void call, or a DOM node/function all serialize to null. ' +
10
+ 'End the script with an expression — or an explicit `return` — that yields plain data, e.g. `return { label: input.value, count: items.length }` or `return JSON.stringify(payload)`.';
11
+ /** Normalize a raw eval result for display. The eval can come back as a useful
12
+ * serialized value, the literal `null`/`undefined`/empty string, or — when the
13
+ * script returns undefined — agent-browser's raw envelope leaking through
14
+ * (`{"success":true,"data":{"origin":…,"result":null},"error":null}`). The last
15
+ * two mean the same thing to the agent: no value came back. Unwrap the leaked
16
+ * envelope so it never reaches the agent as an opaque blob, and flag the
17
+ * no-value cases so the caller can attach EVAL_NO_VALUE_HINT. */
18
+ export function normalizeEvalResult(raw) {
19
+ const trimmed = (raw ?? '').trim();
20
+ if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') {
21
+ return { result: trimmed, noValue: true };
22
+ }
23
+ // A leaked agent-browser eval envelope (only emitted when the eval returns
24
+ // undefined): unwrap to the inner value. Strict shape match — exact key set
25
+ // plus a string origin — so a genuine user object never trips this.
26
+ if (trimmed.startsWith('{') && trimmed.includes('"result"')) {
27
+ try {
28
+ const env = JSON.parse(trimmed);
29
+ const isEnvelope = env && typeof env === 'object'
30
+ && Object.keys(env).every((k) => k === 'success' || k === 'data' || k === 'error')
31
+ && env.data && typeof env.data === 'object'
32
+ && typeof env.data.origin === 'string' && 'result' in env.data;
33
+ if (isEnvelope) {
34
+ const inner = env.data.result;
35
+ if (inner == null)
36
+ return { result: 'null', noValue: true };
37
+ return { result: typeof inner === 'string' ? inner : JSON.stringify(inner), noValue: false };
38
+ }
39
+ }
40
+ catch { /* not the envelope — fall through and show the raw value */ }
41
+ }
42
+ return { result: raw, noValue: false };
43
+ }
5
44
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
45
+ // A single browser session is held open synchronously for the whole --wait, so
46
+ // the server caps it at the gateway idle timeout. Longer is impossible in one
47
+ // shot; watching an app past 30s means several windows, not one big wait.
48
+ export const MAX_WAIT_MS = 30_000;
49
+ /** Parse --wait (defaulting to 500ms), clamping to the per-call cap. When the
50
+ * caller asks for more than the cap, clamp and explain — to stderr, so --json
51
+ * stdout stays clean — and point at the windowed watch primitive instead of
52
+ * leaking the server's raw "Too big" validation error. */
53
+ export function capWaitMs(rawWait, url) {
54
+ const parsed = parseInt(rawWait, 10);
55
+ const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
56
+ if (wait <= MAX_WAIT_MS)
57
+ return wait;
58
+ console.error(warning(`--wait ${wait}ms exceeds the ${MAX_WAIT_MS}ms cap (one browser session is held open synchronously; longer trips the gateway timeout) — using ${MAX_WAIT_MS}ms. ` +
59
+ `To watch an app that keeps changing past 30s, cover the span with staggered windows in one command: gipity page test "${url}" --clients N --stagger S.`));
60
+ return MAX_WAIT_MS;
61
+ }
6
62
  /** Poll the async eval job until it finishes. Eval runs server-side as a
7
63
  * short-lived job (so a long --wait can't trip the gateway idle timeout);
8
64
  * we submit, then poll the result out of the job store. `expectedWorkMs` is
@@ -35,21 +91,74 @@ export async function pollEvalResult(evalJobId, expectedWorkMs) {
35
91
  }
36
92
  throw new ApiError(504, 'EVAL_TIMEOUT', 'Eval did not finish in time; narrow the expression or lower --wait');
37
93
  }
94
+ // The in-page execution budget for an eval body's OWN runtime (its `await`/
95
+ // `setTimeout` pauses), enforced by agent-browser's per-command CDP timeout
96
+ // (AGENT_BROWSER_DEFAULT_TIMEOUT) — distinct from --wait, which only sleeps
97
+ // BEFORE the eval. Used to translate the opaque timeout envelope into guidance.
98
+ const EVAL_EXEC_BUDGET_MS = 20_000;
99
+ /** When the eval body's own runtime overruns the in-page execution budget,
100
+ * agent-browser aborts the `Runtime.evaluate` CDP call and the failure comes
101
+ * back as a `{success:false, error:"CDP command timed out: Runtime.evaluate"}`
102
+ * envelope that the server surfaces verbatim as the eval `result` — opaque to
103
+ * the caller (no timeout named, no distinction from the page or --wait). Detect
104
+ * exactly that envelope and return an actionable message; null otherwise. */
105
+ export function evalExecTimeoutMessage(result) {
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(result);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ if (!parsed || parsed.success !== false || typeof parsed.error !== 'string')
114
+ return null;
115
+ if (!/CDP command timed out:\s*Runtime\.evaluate/i.test(parsed.error))
116
+ return null;
117
+ return (`the expression hit the ~${EVAL_EXEC_BUDGET_MS / 1000}s in-page execution budget — the eval body ` +
118
+ `(including its own await/setTimeout pauses) ran longer than that. This budget is the time the ` +
119
+ `expression itself is allowed to run; it is separate from --wait, which only sleeps BEFORE the eval ` +
120
+ `and cannot extend it. Split a long interactive check into several shorter 'page eval' calls (e.g. ` +
121
+ `one per state to verify), keeping each body's in-page waits well under ${EVAL_EXEC_BUDGET_MS / 1000}s.`);
122
+ }
38
123
  // The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
39
124
  // curated metrics don't cover what you need (computed styles, element rects,
40
125
  // visibility, z-index stacks), eval an expression in page context and get the
41
126
  // serialized result back. Runs in the same browser sandbox as inspect.
127
+ //
128
+ // The body runs as an async function, so it can be an inline expression OR a
129
+ // multi-statement script with `return`/`await`. Pass a saved script with
130
+ // --file to functionally exercise a page's own code paths headlessly (drive
131
+ // tools, undo/redo, transforms) and `return` a JSON-serializable result —
132
+ // no /tmp + shell command-substitution harness needed.
42
133
  export const pageEvalCommand = new Command('eval')
43
- .description('Evaluate a JS expression in a real browser on a page (DOM, computed styles, element rects)')
134
+ .description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script)')
44
135
  .argument('<url>', 'URL to load')
45
- .argument('<expr>', 'JavaScript expression to evaluate in page context (result is JSON-serialized)')
46
- .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle)', '500')
136
+ .argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file.')
137
+ .option('--file <path>', 'Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work.')
138
+ .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
47
139
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
48
140
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
49
141
  .option('--json', 'Output as JSON')
50
- .action((url, expr, opts) => run('Page eval', async () => {
51
- const parsedWait = parseInt(opts.wait, 10);
52
- const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
142
+ .action((url, exprArg, opts) => run('Page eval', async () => {
143
+ // Arg-shape errors go through commander's error() so the enableHelpAfterError
144
+ // hook renders this command's help inline with the one-line error LAST
145
+ // (survives `| tail`), same as commander-detected errors like a missing url.
146
+ if (exprArg !== undefined && opts.file) {
147
+ pageEvalCommand.error('error: Pass either an inline <expr> arg or --file <path>, not both');
148
+ }
149
+ if (exprArg === undefined && !opts.file) {
150
+ pageEvalCommand.error('error: Provide an inline <expr> arg or --file <path>');
151
+ }
152
+ let expr = exprArg;
153
+ if (opts.file) {
154
+ try {
155
+ expr = readFileSync(opts.file, 'utf8');
156
+ }
157
+ catch {
158
+ pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
159
+ }
160
+ }
161
+ const waitMs = capWaitMs(opts.wait, url);
53
162
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
54
163
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
55
164
  const kickoff = await post('/tools/browser/eval', {
@@ -58,16 +167,22 @@ export const pageEvalCommand = new Command('eval')
58
167
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
59
168
  });
60
169
  const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
170
+ const { result, noValue } = normalizeEvalResult(d.result);
171
+ const execTimeout = evalExecTimeoutMessage(d.result);
172
+ if (execTimeout)
173
+ throw new Error(execTimeout);
61
174
  if (opts.json) {
62
- console.log(JSON.stringify(d));
175
+ console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
63
176
  return;
64
177
  }
65
178
  console.log(`${brand('Eval')} ${bold(d.url || url)}`);
66
179
  if (d.navigationIncomplete) {
67
180
  console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
68
181
  }
69
- console.log(`${muted('Expression:')} ${expr}`);
70
- console.log(`\n${d.result || muted('(empty result)')}`);
182
+ console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
183
+ console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
184
+ if (noValue)
185
+ console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
71
186
  if (d.truncated)
72
187
  console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
73
188
  }));
@@ -77,6 +192,17 @@ export const pageEvalCommand = new Command('eval')
77
192
  // concurrent `page test --observe` instead, which overlaps N clients and reports
78
193
  // whether they actually ran together.
79
194
  pageEvalCommand.addHelpText('after', `
195
+ Examples:
196
+ gipity page eval "https://dev.gipity.ai/me/app/" "document.title"
197
+ # Functionally test a page's own code paths: save a script that drives the UI
198
+ # and returns a JSON-serializable result, then run it (no /tmp + shell quoting):
199
+ gipity page eval "https://dev.gipity.ai/me/app/" --file ./tests/draw-flow.js --json
200
+
201
+ The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
202
+ pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
203
+ interactive sequence, split it into several shorter evals (one per state to verify)
204
+ rather than one body with many long waits.
205
+
80
206
  Testing realtime/shared state across clients?
81
207
  Separate 'page eval' calls run sequentially (one finishes before the next
82
208
  starts), so they never overlap and will each see only themselves - a false
@@ -3,6 +3,9 @@ import { post } from '../api.js';
3
3
  import { formatSize } from '../utils.js';
4
4
  import { brand, bold, error as clrError, warning, muted, info } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
+ import { capWaitMs } from './page-eval.js';
7
+ /** A console line is an error-level entry (page error or console.error). */
8
+ const isErrorLine = (line) => /^error:/i.test(line);
6
9
  function shortUrl(url, truncate = true, maxLen = 100) {
7
10
  let result;
8
11
  try {
@@ -22,7 +25,7 @@ function shortUrl(url, truncate = true, maxLen = 100) {
22
25
  export const pageInspectCommand = new Command('inspect')
23
26
  .description('Inspect a web page (console, failed resources, timing, layout overflow)')
24
27
  .argument('<url>', 'URL to inspect')
25
- .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle)', '500')
28
+ .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)', '500')
26
29
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
27
30
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
28
31
  .option('--json', 'Output as JSON')
@@ -39,21 +42,41 @@ export const pageInspectCommand = new Command('inspect')
39
42
  process.exit(1);
40
43
  }
41
44
  return run('Page inspect', async () => {
42
- const parsedWait = parseInt(opts.wait, 10);
43
- const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
45
+ const waitMs = capWaitMs(opts.wait, url);
44
46
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
45
47
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
46
48
  const truncate = opts.truncate !== false;
47
49
  const showAll = opts.all === true;
48
- const res = await post(`/tools/browser/inspect`, {
50
+ const inspectBody = {
49
51
  url, waitMs,
50
52
  waitForSelector: opts.waitFor || undefined,
51
53
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
52
54
  fakeMedia: opts.fakeMedia || undefined,
53
- });
55
+ };
56
+ const res = await post(`/tools/browser/inspect`, inspectBody);
54
57
  const b = res.data;
58
+ // Self-verify console errors before flagging them. A freshly-deployed page's
59
+ // first hit can throw a one-time, non-reproducible error — typically a
60
+ // cross-origin "Script error." with no message/stack from a CDN asset still
61
+ // propagating — and reporting it as a real defect sends agents chasing a
62
+ // phantom. So when the first probe reports error-level console lines, re-probe
63
+ // once (the sticky session is now warm) and keep only the errors that recur;
64
+ // errors seen on a single probe are surfaced separately as transient noise.
65
+ let transientErrors = [];
66
+ if ((b.console || []).some(isErrorLine)) {
67
+ try {
68
+ const verify = await post(`/tools/browser/inspect`, inspectBody);
69
+ const recurring = new Set((verify.data.console || []).filter(isErrorLine));
70
+ transientErrors = (b.console || []).filter((l) => isErrorLine(l) && !recurring.has(l));
71
+ b.console = (b.console || []).filter((l) => !isErrorLine(l) || recurring.has(l));
72
+ }
73
+ catch {
74
+ // Re-probe failed (timeout / browser error) — report the first probe's
75
+ // console as-is rather than hiding anything.
76
+ }
77
+ }
55
78
  if (opts.json) {
56
- console.log(JSON.stringify(b));
79
+ console.log(JSON.stringify(transientErrors.length ? { ...b, transientConsole: transientErrors } : b));
57
80
  return;
58
81
  }
59
82
  const timing = b.timing || { ttfb: 0, domReady: 0, load: 0 };
@@ -83,6 +106,14 @@ export const pageInspectCommand = new Command('inspect')
83
106
  else {
84
107
  console.log(`\n${bold('Console:')} ${muted('(clean)')}`);
85
108
  }
109
+ // ── Transient console errors (seen on first probe, gone on re-probe) ──
110
+ if (transientErrors.length > 0) {
111
+ console.log(`\n${bold('Transient console errors')} ${muted(`(${transientErrors.length}, not reproduced on re-probe)`)}:`);
112
+ for (const line of transientErrors) {
113
+ console.log(muted(line));
114
+ }
115
+ console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets, or a cross-origin script) — not reproducible, not in your app code. Ignore unless it recurs.'));
116
+ }
86
117
  // ── Failed Resources ──
87
118
  // Browsers auto-request /favicon.ico at the site root for every page, so a
88
119
  // 404 there isn't a resource the page actually links — it's noise on any
@@ -1,6 +1,6 @@
1
1
  import { Command, Option } from 'commander';
2
2
  import { mkdirSync, writeFileSync } from 'fs';
3
- import { join, resolve as resolvePath } from 'path';
3
+ import { dirname, join, resolve as resolvePath } from 'path';
4
4
  import { postForTarEntries } from '../api.js';
5
5
  import { getProjectRoot } from '../config.js';
6
6
  import { brand, bold, muted, success } from '../colors.js';
@@ -96,7 +96,10 @@ function appendOption(value, previous = []) {
96
96
  export const pageScreenshotCommand = new Command('screenshot')
97
97
  .description('Screenshot a web page')
98
98
  .argument('<url>', 'URL to screenshot')
99
- .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms', '1000')
99
+ // No commander default: a default here makes opts.postLoadDelay always set,
100
+ // so the `?? opts.wait` merge below would never see the --wait alias. Default
101
+ // is applied in the merge instead.
102
+ .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
100
103
  .option('--full', 'Capture the full scrollable page (default: viewport only)')
101
104
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
102
105
  .option('--device <names>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
@@ -110,7 +113,10 @@ export const pageScreenshotCommand = new Command('screenshot')
110
113
  // rather than reject it as an unknown option and send them on a --help detour.
111
114
  .addOption(new Option('--full-page', 'Alias for --full').hideHelp())
112
115
  .action((url, opts) => run('Page screenshot', async () => {
113
- const delayRaw = opts.postLoadDelay ?? opts.wait;
116
+ // --wait is a hidden alias for --post-load-delay (agents reach for it because
117
+ // sibling `page inspect`/`eval` name the flag --wait). Canonical name wins if
118
+ // both given; fall back to the 1000ms default when neither is set.
119
+ const delayRaw = opts.postLoadDelay ?? opts.wait ?? '1000';
114
120
  const postLoadDelayMs = delayRaw !== undefined ? parseInt(String(delayRaw), 10) : undefined;
115
121
  if (postLoadDelayMs !== undefined && (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0)) {
116
122
  throw new Error('--post-load-delay must be a non-negative integer (ms)');
@@ -147,8 +153,6 @@ export const pageScreenshotCommand = new Command('screenshot')
147
153
  const slug = slugFromUrl(url);
148
154
  const ts = timestampSlug();
149
155
  const dir = defaultScreenshotDir();
150
- if (!opts.output)
151
- mkdirSync(dir, { recursive: true });
152
156
  const savedFiles = [];
153
157
  for (let i = 0; i < pngs.length; i++) {
154
158
  const shot = meta.screenshots[i];
@@ -156,6 +160,10 @@ export const pageScreenshotCommand = new Command('screenshot')
156
160
  const target = opts.output
157
161
  ? opts.output
158
162
  : join(dir, defaultFilename(slug, ts, suffix));
163
+ // Create the target's parent dir so a `-o` path under a not-yet-existing
164
+ // directory (e.g. .gipity/screenshots/home.png) writes cleanly instead of
165
+ // failing with a raw ENOENT and forcing a manual `mkdir -p`.
166
+ mkdirSync(dirname(target), { recursive: true });
159
167
  writeFileSync(target, pngs[i].buffer);
160
168
  // Absolute path so the agent knows exactly where the file landed.
161
169
  savedFiles.push(resolvePath(target));
@@ -18,12 +18,33 @@ async function inspectClient(url, waitMs, i) {
18
18
  }
19
19
  const MAX_HOLD_MS = 15_000; // keep each in-page await under the ~20s browser action timeout
20
20
  const MIN_HOLD_MS = 1_000;
21
- /** Splice per-client values into a user expression. `{{label}}` the client's
22
- * label, `{{i}}` → its 0-based index. Plain string replace (no regex) so the
23
- * expression's own characters are never treated as patterns. */
21
+ /** Splice per-client values into a user string (URL, --action, or --observe).
22
+ * `{{label}}` → the client's label, `{{i}}` → its 0-based index. Plain string
23
+ * replace (no regex) so the string's own characters are never treated as
24
+ * patterns. */
24
25
  function subst(expr, label, i) {
25
26
  return expr.split('{{label}}').join(label).split('{{i}}').join(String(i));
26
27
  }
28
+ /** Collect any `{{...}}` placeholders the runner does NOT recognize, so an
29
+ * invented token (e.g. `{{name}}`) is flagged instead of passing through
30
+ * verbatim into every client's URL/expression. */
31
+ function unknownTokens(...strings) {
32
+ const out = new Set();
33
+ for (const s of strings) {
34
+ for (const m of (s ?? '').match(/\{\{[^}]*\}\}/g) ?? []) {
35
+ if (m !== '{{i}}' && m !== '{{label}}')
36
+ out.add(m);
37
+ }
38
+ }
39
+ return [...out];
40
+ }
41
+ /** One-time warning (to stderr, so --json stdout stays clean) for
42
+ * unrecognized placeholders left as-is. */
43
+ function warnUnknownTokens(unknown) {
44
+ if (unknown.length === 0)
45
+ return;
46
+ console.error(warning(`⚠ Unrecognized placeholder ${unknown.join(', ')} left as-is — only {{i}} (0-based client index) and {{label}} are substituted per client. Set per-client values with --labels and reference them as {{label}}.`));
47
+ }
27
48
  /** Build the statement-body script one client runs: do the one-time action,
28
49
  * then sample `observe` `samples` times across `holdMs`, stamping in-page
29
50
  * start/end so the caller can confirm the clients overlapped. */
@@ -98,11 +119,22 @@ function fmtSamples(samples) {
98
119
  async function runInteractive(url, observe, opts) {
99
120
  const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
100
121
  const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 0;
101
- const hold = Math.min(MAX_HOLD_MS, Math.max(MIN_HOLD_MS, parseInt(opts.hold, 10) || 8000));
122
+ const rawHold = parseInt(opts.hold, 10) || 8000;
123
+ const hold = Math.min(MAX_HOLD_MS, Math.max(MIN_HOLD_MS, rawHold));
124
+ if (rawHold > MAX_HOLD_MS) {
125
+ // Surface the clamp (to stderr, so --json stdout stays clean) instead of
126
+ // leaving the agent to infer it from the printed "hold Nms" line.
127
+ console.error(warning(`--hold ${rawHold}ms exceeds the ${MAX_HOLD_MS}ms per-client cap (each client samples inside one browser eval, bounded by the server's eval budget) — using ${MAX_HOLD_MS}ms. ` +
128
+ `Co-launch every role in this one command (put {{label}}/{{i}} in the URL) so all clients overlap for the whole window; a separately-started background client overlaps only the sliver of its window that lines up.`));
129
+ }
102
130
  const samples = Math.min(30, Math.max(2, parseInt(opts.samples, 10) || 6));
103
131
  const settle = opts.waitFor ? 200 : 1000;
104
132
  const labels = (opts.labels ? String(opts.labels).split(',').map((s) => s.trim()) : []).filter(Boolean);
105
133
  const labelFor = (i) => labels[i] ?? `client-${i}`;
134
+ // Only {{label}} and {{i}} are substituted. Warn once on any other {{token}}
135
+ // (a natural guess like {{name}} or {{index}}) so it isn't sent literally to
136
+ // every client — the silent wrong-behavior trap of identical clients.
137
+ warnUnknownTokens(unknownTokens(url, opts.action, observe));
106
138
  if (!opts.json) {
107
139
  console.log(`${brand('Page test')} ${muted('(interactive)')} ${bold(url)}`);
108
140
  console.log(muted(`${clients} client(s), stagger ${stagger}s, hold ${hold}ms, ${samples} samples each`));
@@ -113,8 +145,12 @@ async function runInteractive(url, observe, opts) {
113
145
  await sleep(i * stagger * 1000);
114
146
  if (!opts.json)
115
147
  console.log(muted(`client ${i} (${labelFor(i)}) joining`));
148
+ // {{label}}/{{i}} substitute into the URL too, so one invocation can launch
149
+ // asymmetric roles concurrently (e.g. ?role={{label}} with --labels host,join)
150
+ // and the overlap check still confirms they coexisted.
151
+ const clientUrl = subst(url, labelFor(i), i);
116
152
  const expr = buildHarness(opts.action ? subst(opts.action, labelFor(i), i) : undefined, subst(observe, labelFor(i), i), labelFor(i), hold, samples);
117
- return observeClient(url, expr, i, labelFor(i), settle, hold, opts.waitFor);
153
+ return observeClient(clientUrl, expr, i, labelFor(i), settle, hold, opts.waitFor);
118
154
  })());
119
155
  }
120
156
  const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
@@ -159,6 +195,9 @@ async function runPassive(url, opts) {
159
195
  const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
160
196
  const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 12;
161
197
  const wait = Math.min(30000, Math.max(2000, parseInt(opts.wait, 10) || 24000));
198
+ const labels = (opts.labels ? String(opts.labels).split(',').map((s) => s.trim()) : []).filter(Boolean);
199
+ const labelFor = (i) => labels[i] ?? `client-${i}`;
200
+ warnUnknownTokens(unknownTokens(url));
162
201
  if (!opts.json) {
163
202
  console.log(`${brand('Page test')} ${bold(url)}`);
164
203
  console.log(`${muted(`${clients} client(s), stagger ${stagger}s, ${wait}ms open each`)}`);
@@ -169,7 +208,7 @@ async function runPassive(url, opts) {
169
208
  await sleep(i * stagger * 1000);
170
209
  if (!opts.json)
171
210
  console.log(`${muted(`client ${i}${i === 0 ? ' (first)' : ''} starting`)}`);
172
- return inspectClient(url, wait, i);
211
+ return inspectClient(subst(url, labelFor(i), i), wait, i);
173
212
  })());
174
213
  }
175
214
  const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
@@ -222,14 +261,14 @@ async function runPassive(url, opts) {
222
261
  // just because the clients never actually ran together.
223
262
  export const pageTestCommand = new Command('test')
224
263
  .description('Multi-client realtime check: load a URL in N concurrent headless clients; flag console errors, or drive an action and observe shared state (--observe)')
225
- .argument('<url>', 'Deployed URL to load in every client')
264
+ .argument('<url>', 'Deployed URL to load in every client. {{label}}/{{i}} substitute per client in both modes (e.g. ?name=Bot{{i}}, or ?role={{label}} with --labels host,join), so one invocation can give each client a distinct role.')
226
265
  .option('--clients <n>', 'Number of headless clients to launch', '2')
227
266
  .option('--stagger <s>', 'Seconds between client starts (passive default 12; interactive default 0)')
228
267
  .option('--wait <ms>', 'Passive mode: ms each client stays open after load (max 30000)', '24000')
229
268
  // Interactive mode (--observe drives it):
230
269
  .option('--observe <expr>', 'Interactive: JS expression sampled in each client to read shared state (e.g. presence count). Switches on interactive mode.')
231
270
  .option('--action <expr>', 'Interactive: one-time JS run in each client before observing (e.g. fill a name + submit). {{label}}/{{i}} are substituted per client.')
232
- .option('--labels <csv>', 'Interactive: per-client labels substituted for {{label}} (default client-0, client-1, …)')
271
+ .option('--labels <csv>', 'Per-client labels substituted for {{label}} in the URL/--action/--observe (default client-0, client-1, …)')
233
272
  .option('--hold <ms>', `Interactive: total observe window per client (${MIN_HOLD_MS}-${MAX_HOLD_MS}ms)`, '8000')
234
273
  .option('--samples <k>', 'Interactive: number of observations across the hold window (2-30)', '6')
235
274
  .option('--wait-for <selector>', 'Interactive: wait for this CSS selector before running --action (deterministic readiness gate)')
@@ -239,12 +278,22 @@ Examples:
239
278
  # Passive: load in 3 staggered clients, flag console errors
240
279
  gipity page test "https://dev.gipity.ai/me/app/" --clients 3 --stagger 8
241
280
 
281
+ # Per-client URL params: each client joins under a distinct name (Bot0, Bot1, …)
282
+ gipity page test "https://dev.gipity.ai/me/app/?name=Bot{{i}}" --clients 2
283
+
242
284
  # Interactive: two concurrent clients each join with a name, then watch the
243
285
  # live presence count. The command confirms the clients actually overlapped.
244
286
  gipity page test "https://dev.gipity.ai/me/app/" --clients 2 \\
245
287
  --action "document.querySelector('#name').value='{{label}}'; document.querySelector('form').requestSubmit();" \\
246
288
  --observe "document.querySelectorAll('.present').length" \\
247
- --labels Alice,Bob`)
289
+ --labels Alice,Bob
290
+
291
+ # Asymmetric roles in ONE invocation: {{label}} in the URL routes client 0 to
292
+ # host and client 1 to join. They overlap in time (verified), so the joiner
293
+ # observes the live state the host is driving — no background-process dance.
294
+ gipity page test "https://dev.gipity.ai/me/app/?test-action={{label}}" --clients 2 \\
295
+ --labels host,join \\
296
+ --observe "document.querySelector('[data-screen]')?.dataset.screen"`)
248
297
  .action((url, opts) => run('Page test', async () => {
249
298
  if (opts.observe) {
250
299
  await runInteractive(url, opts.observe, opts);