gipity 1.0.429 → 1.1.2

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 (45) hide show
  1. package/README.md +67 -29
  2. package/dist/agents/claude-code.js +48 -0
  3. package/dist/agents/codex.js +45 -0
  4. package/dist/agents/grok.js +50 -0
  5. package/dist/agents/index.js +23 -0
  6. package/dist/agents/types.js +18 -0
  7. package/dist/api.js +14 -4
  8. package/dist/capture/sources/codex.js +216 -0
  9. package/dist/capture/sources/grok.js +178 -0
  10. package/dist/catalog.js +46 -0
  11. package/dist/client-context.js +2 -0
  12. package/dist/commands/add.js +9 -22
  13. package/dist/commands/build.js +1330 -0
  14. package/dist/commands/chat.js +9 -3
  15. package/dist/commands/claude.js +4 -13
  16. package/dist/commands/db.js +22 -5
  17. package/dist/commands/deploy.js +7 -0
  18. package/dist/commands/doctor.js +8 -2
  19. package/dist/commands/fn.js +24 -1
  20. package/dist/commands/init.js +9 -15
  21. package/dist/commands/logs.js +21 -2
  22. package/dist/commands/page-eval.js +25 -10
  23. package/dist/commands/page-inspect.js +7 -3
  24. package/dist/commands/page-screenshot.js +62 -23
  25. package/dist/commands/project.js +1 -1
  26. package/dist/commands/relay-install.js +1 -1
  27. package/dist/commands/relay.js +3 -3
  28. package/dist/commands/sandbox.js +62 -16
  29. package/dist/commands/setup.js +16 -10
  30. package/dist/commands/status.js +35 -7
  31. package/dist/commands/test.js +7 -0
  32. package/dist/commands/uninstall.js +25 -3
  33. package/dist/flag-aliases.js +1 -0
  34. package/dist/hooks/capture-runner.js +127 -38
  35. package/dist/index.js +1118 -361
  36. package/dist/knowledge.js +3 -3
  37. package/dist/prefs.js +42 -0
  38. package/dist/project-setup.js +2 -10
  39. package/dist/relay/daemon.js +124 -16
  40. package/dist/relay/diagnostics.js +4 -2
  41. package/dist/relay/onboarding.js +9 -9
  42. package/dist/relay/setup.js +8 -8
  43. package/dist/relay/state.js +1 -1
  44. package/dist/setup.js +258 -17
  45. package/package.json +4 -3
@@ -50,9 +50,15 @@ export const chatCommand = new Command('chat')
50
50
  ...(a.remoteSize != null ? { size: a.remoteSize } : {}),
51
51
  }));
52
52
  }
53
+ // The server's cost-consent gate stops the agentic loop on big-build
54
+ // requests: content comes back empty with an explicit costWarning.
55
+ // Surface it - before this the CLI printed a blank line and the request
56
+ // silently did nothing. Re-running the command consents via chat reply.
57
+ const displayContent = res.data.content || res.data.costWarning?.message || '';
53
58
  if (opts.json) {
54
59
  console.log(JSON.stringify({
55
- content: res.data.content,
60
+ content: displayContent,
61
+ costWarning: res.data.costWarning ?? null,
56
62
  toolsUsed: res.data.toolsUsed?.map(t => ({
57
63
  tool: t.toolName,
58
64
  success: t.success,
@@ -67,8 +73,8 @@ export const chatCommand = new Command('chat')
67
73
  }));
68
74
  }
69
75
  else {
70
- // Show agent response
71
- console.log(res.data.content);
76
+ // Show agent response (or the cost warning when the gate stopped the loop)
77
+ console.log(displayContent);
72
78
  // Show tools used
73
79
  if (res.data.toolsUsed && res.data.toolsUsed.length > 0) {
74
80
  const toolNames = [...new Set(res.data.toolsUsed.map(t => t.toolName))];
@@ -9,7 +9,7 @@ import { get, post, ApiError, getAccountSlug } from '../api.js';
9
9
  import { interactiveLogin } from '../login-flow.js';
10
10
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
11
11
  import { sync } from '../sync.js';
12
- import { slugify, setupClaudeHooks, ensureGipityPluginInstalled, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
12
+ import { slugify, ensureGipityPluginInstalled, setupProjectTools, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
13
13
  import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
14
14
  import * as relayState from '../relay/state.js';
15
15
  import { maybeOfferRelayOn, ensureDaemonRunning } from '../relay/onboarding.js';
@@ -481,10 +481,7 @@ export const claudeCommand = new Command('claude')
481
481
  catch (err) {
482
482
  console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
483
483
  }
484
- setupClaudeHooks();
485
- setupClaudeMd();
486
- setupAgentsMd();
487
- setupGitignore();
484
+ setupProjectTools();
488
485
  if (nonInteractive) {
489
486
  // Headless: the -p message is wrapped later (Step 3). Just record
490
487
  // whether to use the new-project framing for that wrap.
@@ -503,10 +500,7 @@ export const claudeCommand = new Command('claude')
503
500
  // cwd already has (or inherits) a .gipity.json - run inside it.
504
501
  console.log(` Project: ${brand(existing.projectSlug)} ${muted(`(${existing.projectGuid})`)}`);
505
502
  console.log(` ${success('Already set up.')}\n`);
506
- setupClaudeHooks();
507
- setupClaudeMd();
508
- setupAgentsMd();
509
- setupGitignore();
503
+ setupProjectTools();
510
504
  // Warn before syncing a very large project tree. An oversized root
511
505
  // (or a config accidentally rooted high up, e.g. at $HOME) makes the
512
506
  // sync slow, and a silent multi-GB walk reads as a hang.
@@ -658,10 +652,7 @@ export const claudeCommand = new Command('claude')
658
652
  // of done, and a welcome banner prints before launch - the user tells
659
653
  // Claude directly what they want to build or do.
660
654
  initialPrompt = '';
661
- setupClaudeHooks();
662
- setupClaudeMd();
663
- setupAgentsMd();
664
- setupGitignore();
655
+ setupProjectTools();
665
656
  console.log(` ${success(`Project "${project.name}" ready.`)}\n`);
666
657
  }
667
658
  // ── Step 3: Launch Claude Code ────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { get, post, sendMessage } from '../api.js';
2
+ import { get, post } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, success } from '../colors.js';
5
5
  import { run, printList, emitField } from '../helpers/index.js';
@@ -44,6 +44,11 @@ dbCommand
44
44
  console.log(columns.map(c => String(row[c] ?? 'NULL')).join('\t'));
45
45
  }
46
46
  }
47
+ // DML with RETURNING carries both rows and a count - the rows are the
48
+ // table above; still name how many rows the statement touched.
49
+ if (res.data.affectedRows !== undefined) {
50
+ console.log(`Affected rows: ${res.data.affectedRows}`);
51
+ }
47
52
  }
48
53
  else if (res.data.affectedRows !== undefined) {
49
54
  console.log(`Affected rows: ${res.data.affectedRows}`);
@@ -51,6 +56,11 @@ dbCommand
51
56
  else if (res.data.results) {
52
57
  console.log(JSON.stringify(res.data.results, null, 2));
53
58
  }
59
+ else {
60
+ // Unknown payload shape: print it raw rather than nothing - silence
61
+ // here reads as "the query returned no data", which is a lie.
62
+ console.log(JSON.stringify(res.data, null, 2));
63
+ }
54
64
  }
55
65
  }));
56
66
  dbCommand
@@ -106,13 +116,20 @@ dbCommand
106
116
  .description('Create a database')
107
117
  .option('--json', 'Output as JSON')
108
118
  .action((name, opts) => run('Create', async () => {
109
- // DDL delegates to agent (needs confirmation flow)
110
- const response = await sendMessage(`Create a new database called "${name}" for this project. Confirm when done, no explanation.`);
119
+ // Direct REST create - same endpoint `db drop` uses. This used to delegate
120
+ // to agent chat, which burned LLM tokens and, after the cost-consent gate
121
+ // landed server-side, could return an empty response having created
122
+ // nothing. Creating a database isn't destructive, so no confirmation.
123
+ const config = requireConfig();
124
+ await post(`/projects/${config.projectGuid}/db/manage`, {
125
+ action: 'create',
126
+ name,
127
+ });
111
128
  if (opts.json) {
112
- console.log(JSON.stringify({ response }));
129
+ console.log(JSON.stringify({ success: true, name }));
113
130
  }
114
131
  else {
115
- console.log(response);
132
+ console.log(success(`Created database '${name}'.`));
116
133
  }
117
134
  }));
118
135
  dbCommand
@@ -146,6 +146,13 @@ export const deployCommand = new Command('deploy')
146
146
  // has to reconstruct the URL convention or guess a subdomain.
147
147
  if (d.url)
148
148
  console.log(`${muted('Live:')} ${brand(d.url)}`);
149
+ // Functions are served from the API host, NOT the static Live URL -
150
+ // for an API-only app the endpoint address IS the deliverable, so name
151
+ // it instead of leaving it to be inferred from a template comment.
152
+ if (d.phases?.some(p => p.name === 'functions')) {
153
+ console.log(`${muted('Functions:')} POST ${brand(`${config.apiBase}/api/${config.projectGuid}/fn/<name>`)}`);
154
+ console.log(muted(' (same address on dev and prod; names: gipity fn list; verify the public path: gipity fn call <name> --anon)'));
155
+ }
149
156
  await inspectAfter();
150
157
  }
151
158
  }));
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { existsSync, readFileSync, statSync } from 'fs';
3
- import { join } from 'path';
3
+ import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { LOCAL_PKG_DIR, LOCAL_ENTRY, STATE_FILE, SETTINGS_FILE, UPDATE_LOG, readState, readSettings, updatesDisabled } from '../updater/state.js';
6
6
  import { bold, dim, success, warning, error as clrError, muted } from '../colors.js';
@@ -162,7 +162,13 @@ export const doctorCommand = new Command('doctor')
162
162
  console.log(`${muted('claude code ')} installed ${yn(env.claude.installed)} · authenticated ${yn(env.claude.authenticated)}`);
163
163
  const autostartLabel = env.relay.autostart === null ? muted('n/a') : yn(env.relay.autostart);
164
164
  console.log(`${muted('relay ')} paired ${yn(env.relay.paired)} · running ${yn(env.relay.running)} · autostart ${autostartLabel}${env.relay.paused ? warning(' · paused') : ''}${env.relay.device ? muted(` (${env.relay.device.name})`) : ''}`);
165
- console.log(`${muted('ready ')} ${env.ready ? success('yes') : warning('no - run `gipity claude` (or the desktop app) to finish setup')}`);
165
+ // Codex approves project hooks interactively and stores the decision in
166
+ // its own opaque state - we can't verify it from here, so surface the
167
+ // one manual step whenever this project ships Codex hooks.
168
+ if (existsSync(resolve(process.cwd(), '.codex', 'hooks.json'))) {
169
+ console.log(`${muted('codex hooks ')} ${warning('written')} - session capture needs a one-time approval: run /hooks inside Codex in this project`);
170
+ }
171
+ console.log(`${muted('ready ')} ${env.ready ? success('yes') : warning('no - run `gipity build` (or the desktop app) to finish setup')}`);
166
172
  // ── CLI install / update health ────────────────────────────────────
167
173
  const state = readState();
168
174
  const settings = readSettings();
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post, del, publicPost } from '../api.js';
3
+ import { getAuth } from '../auth.js';
3
4
  import { requireConfig } from '../config.js';
4
5
  import { error as clrError, bold, muted, success, warning } from '../colors.js';
5
6
  import { run, printList, emitField } from '../helpers/index.js';
@@ -17,6 +18,11 @@ fnCommand
17
18
  const line = `${bold(f.name)} ${muted(`v${f.version}`)} ${muted(f.auth_level)} ${muted(`timeout=${f.timeout_ms}ms`)}`;
18
19
  return f.description ? `${line}\n${muted(f.description)}` : line;
19
20
  });
21
+ // The callable address is the deliverable for API-only apps - name it here
22
+ // rather than leaving it to be inferred from a template comment.
23
+ if (!opts.json && res.data.length > 0) {
24
+ console.log(muted(`Endpoint: POST ${config.apiBase}/api/${config.projectGuid}/fn/<name> (same on dev and prod; public path: gipity fn call <name> --anon)`));
25
+ }
20
26
  }));
21
27
  fnCommand
22
28
  .command('logs <name>')
@@ -29,8 +35,15 @@ fnCommand
29
35
  printList(res.data, opts, 'No execution logs.', log => {
30
36
  const dur = log.duration_ms != null ? `${log.duration_ms}ms` : '?';
31
37
  const ts = new Date(log.created_at).toLocaleString();
32
- const statusColor = log.status === 'success' ? success : log.status === 'error' ? clrError : muted;
38
+ // The runtime writes 'ok' | 'error' | 'limit_exceeded' - never 'success'
39
+ // (same dead value already fixed in `logs fn`).
40
+ const statusColor = log.status === 'ok' ? success : log.status === 'error' ? clrError : muted;
33
41
  let line = `${statusColor(log.status)} ${dur} ${muted(log.trigger_type || 'http')} ${muted(ts)}`;
42
+ // Platform service calls made inside the invocation (notify, email,
43
+ // LLM, ...) - per-call outcomes live in `gipity logs app --type services`.
44
+ const svcCalls = log.limits_consumed?.max_service_calls;
45
+ if (svcCalls)
46
+ line += ` ${muted(`svc:${svcCalls}`)}`;
34
47
  if (log.error_message)
35
48
  line += `\n${clrError(`error: ${log.error_message}`)}`;
36
49
  // Captured console.log/warn/error output for this invocation.
@@ -69,6 +82,16 @@ fnCommand
69
82
  const raw = bodyArg || opts.data || '{}';
70
83
  const body = JSON.parse(raw);
71
84
  const path = `/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`;
85
+ // Name the calling identity (stderr, so stdout stays parseable). Without
86
+ // this, a "test the anonymous path" call silently runs authenticated as
87
+ // the signed-in owner and the public path never gets exercised.
88
+ if (opts.anon) {
89
+ console.error(muted('Auth: anonymous visitor (the public path a signed-out user hits)'));
90
+ }
91
+ else {
92
+ const who = getAuth()?.email;
93
+ console.error(muted(`Auth: calling as ${who ?? 'your signed-in account'} (the owner persona; use --anon for the public visitor path)`));
94
+ }
72
95
  const res = opts.anon
73
96
  ? await callAnon(config.projectGuid, name, body)
74
97
  : await post(path, body);
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'fs';
4
4
  import { getAccountSlug } from '../api.js';
5
5
  import { getConfig, getConfigPath, saveConfigAt } from '../config.js';
6
6
  import { getAuth } from '../auth.js';
7
- import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from '../setup.js';
7
+ import { slugify, setupProjectTools, 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';
@@ -23,7 +23,7 @@ function resolveTools(forFlag) {
23
23
  }
24
24
  export const initCommand = new Command('init')
25
25
  .description('Link this directory to a project')
26
- .addHelpText('after', '\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity.')
26
+ .addHelpText('after', '\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity, and installs the Gipity skills + file-sync hooks into the agent CLIs found on this machine (Claude Code, Codex, Grok).')
27
27
  .argument('[name]', 'Project name/slug (defaults to current directory name)')
28
28
  .option('--agent <guid>', 'Agent GUID to use')
29
29
  .option('--no-capture', 'Don\'t record Claude Code sessions in this directory to your Gipity project (sets captureHooks: false in .gipity.json)')
@@ -32,7 +32,8 @@ export const initCommand = new Command('init')
32
32
  Examples:
33
33
  $ gipity init Link cwd as a new project (slug = dir name).
34
34
  $ gipity init my-app Link cwd with an explicit slug.
35
- $ gipity init --for codex Write only AGENTS.md (skip Claude/Cursor/etc).
35
+ $ gipity init --for codex AGENTS.md + Codex skills/sync hooks only.
36
+ $ gipity init --for grok AGENTS.md + the Gipity plugin in Grok only.
36
37
  $ gipity init --for cursor,gemini Write only the Cursor + Gemini primers.
37
38
  $ gipity init --for aider AGENTS.md + a read: entry in .aider.conf.yml
38
39
  (aider auto-reads nothing, so it's opt-in).
@@ -57,10 +58,6 @@ Working with an existing Gipity project:
57
58
  process.exit(1);
58
59
  }
59
60
  const wantsClaude = tools.some(t => t.key === 'claude');
60
- const writeAllPrimers = () => {
61
- for (const t of tools)
62
- t.setup();
63
- };
64
61
  const primerSummary = tools.map(t => t.label).join(', ');
65
62
  try {
66
63
  // Check auth
@@ -77,12 +74,9 @@ Working with an existing Gipity project:
77
74
  if (existsSync(resolve(cwd, '.gipity.json'))) {
78
75
  const existing = getConfig();
79
76
  console.log(`Already linked to ${info(`"${existing?.projectSlug ?? ''}"`)} ${muted(`(${existing?.projectGuid ?? ''})`)}`);
80
- // Re-run setup in case hooks/skills are missing. Claude Code hooks
81
- // only matter when the Claude primer is being written.
82
- if (wantsClaude)
83
- setupClaudeHooks();
84
- writeAllPrimers();
85
- setupGitignore();
77
+ // Re-run setup in case primers/hooks/skills are missing - each tool's
78
+ // integration self-gates on its binary and current-version state.
79
+ setupProjectTools(tools);
86
80
  // The config's ignore list was frozen at link time, so a workstation
87
81
  // artifact introduced by a newer CLI (e.g. aider's .aider.conf.yml)
88
82
  // would sync up as project content. Union in the current defaults.
@@ -143,7 +137,7 @@ Working with an existing Gipity project:
143
137
  const sizeStr = scan.truncated ? `>${formatBytes(ADOPT_THRESHOLDS.REFUSE_BYTES)}` : formatBytes(scan.bytes);
144
138
  const fileStr = scan.truncated ? `>${ADOPT_THRESHOLDS.REFUSE_FILES}` : `${scan.files}`;
145
139
  console.error(clrError(`Directory has ${fileStr} files (${sizeStr}) - too large to adopt as a Gipity project.`));
146
- console.error(muted('Move into a subdirectory, or use `gipity claude` and pick "Create new project".'));
140
+ console.error(muted('Move into a subdirectory, or use `gipity build` and pick "Create new project".'));
147
141
  process.exit(1);
148
142
  }
149
143
  if (scan.tier === 'moderate') {
@@ -183,7 +177,7 @@ Working with an existing Gipity project:
183
177
  }
184
178
  console.log(success(`Wrote primer files: ${primerSummary}.`));
185
179
  if (wantsClaude) {
186
- console.log(success('Ready! Run `gipity claude` for Claude Code, or open this directory in your other AI coding tool.'));
180
+ console.log(success('Ready! Run your coding agent here (claude, codex, grok), or `gipity build` to launch one with a picker.'));
187
181
  // Recording happens by default (however Claude Code is launched), so
188
182
  // say so up front - consent should be explicit, not discovered later.
189
183
  if (opts.capture === false) {
@@ -9,7 +9,20 @@ logsCommand
9
9
  .command('fn <name>')
10
10
  .description('Show function logs')
11
11
  .option('--limit <n>', 'Max entries', '20')
12
- .option('--json', 'Output as JSON')
12
+ .option('--json', 'Output as JSON (top-level ARRAY of invocations, newest first)')
13
+ .addHelpText('after', `
14
+ Each line: time, status (ok / error / limit_exceeded), duration, trigger, and
15
+ svc:N when the invocation made N platform service calls (notify, email, LLM,
16
+ image, ...). Captured console output is indented beneath its invocation.
17
+
18
+ --json emits a top-level ARRAY (not an object) of invocation rows:
19
+ [{ id, status, duration_ms, trigger_type, error_message,
20
+ limits_consumed: { max_queries, max_service_calls, ... }, logs: [...], created_at }]
21
+
22
+ To confirm an individual service call succeeded (and see provider, latency,
23
+ and its own error message), use the per-call log instead:
24
+ $ gipity logs app --type services
25
+ `)
13
26
  .action((name, opts) => run('Logs', async () => {
14
27
  const config = requireConfig();
15
28
  const limit = parseInt(opts.limit, 10) || 20;
@@ -32,8 +45,14 @@ logsCommand
32
45
  const statusColor = log.status === 'ok' ? success : log.status === 'error' ? clrError : warning;
33
46
  const status = statusColor(log.status.padEnd(8));
34
47
  const trigger = muted(log.trigger_type.padEnd(8));
48
+ // Service calls made inside the invocation (notify, email, LLM, ...) are
49
+ // otherwise invisible here - surface the count so "did my send happen?"
50
+ // doesn't require spelunking --json limits counters. Per-call outcomes
51
+ // live in `gipity logs app --type services`.
52
+ const svcCalls = log.limits_consumed?.max_service_calls;
53
+ const svc = svcCalls ? ` ${muted(`svc:${svcCalls}`)}` : '';
35
54
  const err = log.error_message ? ` ${clrError(`"${log.error_message}"`)}` : '';
36
- console.log(`${muted(time)} ${status} ${dur} ${trigger}${err}`);
55
+ console.log(`${muted(time)} ${status} ${dur} ${trigger}${svc}${err}`);
37
56
  // Captured console.log/warn/error output for this invocation, indented
38
57
  // under its summary line. Empty for calls that printed nothing.
39
58
  for (const line of log.logs ?? []) {
@@ -8,8 +8,10 @@ import { resolveProjectContext } from '../config.js';
8
8
  import { uploadPublicFixture, uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
9
9
  // Shown when an eval runs cleanly but returns nothing serializable. Turns a
10
10
  // bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
11
- // a returnable value instead of guessing and retrying.
12
- 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. ' +
11
+ // a returnable value instead of guessing and retrying. (A body ending in a bare
12
+ // expression is auto-returned SERVER-side before this can fire so this is the
13
+ // residue: block/loop endings, explicit undefined returns, DOM nodes, functions.)
14
+ export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A body ending in a loop/if-block, a `return` of undefined, or a DOM node/function all serialize to null. ' +
13
15
  '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)`.';
14
16
  /** Normalize a raw eval result for display. The eval can come back as a useful
15
17
  * serialized value, the literal `null`/`undefined`/empty string, or — when the
@@ -272,7 +274,8 @@ export function slowRenderMessage(fps, o) {
272
274
  return `${warning('⚠ Slow render:')} page painted at ${fps} fps. `
273
275
  + `Waiting on real time (setTimeout) advances animation/physics time far slower than it looks — `
274
276
  + `assertions after a wall-clock wait can report a false negative. `
275
- + `Step the app's own loop deterministically instead (3D templates: ${bold('core.advance(seconds)')}).`;
277
+ + `Step the app's own loop deterministically instead (3D templates: ${bold('core.advance(seconds)')}; `
278
+ + `2D/Phaser template: ${bold("(await import('./js/config.js')).advance(seconds)")}).`;
276
279
  }
277
280
  /** Poll the async eval job until it finishes. Eval runs server-side as a
278
281
  * short-lived job (so a long --wait can't trip the gateway idle timeout);
@@ -346,7 +349,9 @@ export function evalExecTimeoutMessage(result, budgetMs) {
346
349
  // a trap, since --json is a real flag that changes output but still leaves the
347
350
  // script unset, sending the agent in a loop. Capture the common guesses as
348
351
  // hidden decoy options so the action can redirect to the positional arg exactly.
349
- const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr', '--eval', '--exec'];
352
+ // `--action` rides along: it's the sibling `page screenshot`'s spelling of
353
+ // "run JS on the page", so an agent that learned it there guesses it here.
354
+ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr', '--eval', '--exec', '--action'];
350
355
  // The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
351
356
  // curated metrics don't cover what you need (computed styles, element rects,
352
357
  // visibility, z-index stacks), eval an expression in page context and get the
@@ -360,7 +365,7 @@ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr',
360
365
  export const pageEvalCommand = new Command('eval')
361
366
  .description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script). ONE client per call - to verify realtime/presence across concurrent clients use `page test --observe` instead')
362
367
  .argument('<url>', 'URL to load')
363
- .argument('[expr]', `JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file. Time budget: the body has ${EVAL_SCRIPT_BUDGET_MS / 1000}s to finish after page load (${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s with --camera) - raise it with --timeout, max ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s.`)
368
+ .argument('[expr]', `JavaScript to evaluate in page context (inline expression or statement body; await works, and the trailing expression is returned automatically — REPL-style — so no explicit return is needed; result is JSON-serialized). Omit when using --file. Time budget: the body has ${EVAL_SCRIPT_BUDGET_MS / 1000}s to finish after page load (${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s with --camera) - raise it with --timeout, max ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s.`)
364
369
  .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. Same post-load budget as <expr> (--timeout).`)
365
370
  .option('--step <expr>', `Run another expression against the SAME loaded page, after <expr> (repeat, max ${MAX_EVAL_STEPS}). Whatever that page load paid for — a vision model coming up, a game booting, a socket connecting — stays up for every step, so an N-part check costs ONE page load instead of N. Each step gets its own in-page budget and its own reported result.`, (val, prev) => [...prev, val], [])
366
371
  .option('--fixture <path>', 'Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
@@ -372,7 +377,7 @@ export const pageEvalCommand = new Command('eval')
372
377
  .option('--wait-for <selector>', `Wait until this CSS selector appears before evaluating, then evaluate (deterministic - beats guessing a --wait). Gate on the state you are ASSERTING, not just a ready flag: '#verdict:not(:empty)' returns the instant the round lands, where a fixed wait snapshots mid-sequence. Same flag on page inspect/screenshot. Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
373
378
  .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, '5000')
374
379
  .option('--timeout <ms>', `How long the script itself may run IN the page - its own await/setTimeout pauses count (default ${EVAL_SCRIPT_BUDGET_MS}, ${EVAL_SCRIPT_BUDGET_CAMERA_MS} with --camera/--fake-media; max ${EVAL_SCRIPT_BUDGET_MAX_MS}). Raise it to trace a sequence that unfolds over time (a game round, an animation). Distinct from --wait, which only sleeps BEFORE the script.`)
375
- .option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
380
+ .option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is 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.')
376
381
  .option('--json', 'Output as JSON')
377
382
  .action((url, exprArg, opts) => run('Page eval', async () => {
378
383
  // A JS-intent flag guess (captured as a hidden decoy below): redirect to the
@@ -470,6 +475,9 @@ export const pageEvalCommand = new Command('eval')
470
475
  // cleaned up, without shifting the fixture map the caller indexes into.
471
476
  let camera;
472
477
  let projectGuid;
478
+ // Completion-value semantics (a body ending in a bare expression returns it,
479
+ // REPL-style) are applied SERVER-side to every leg — one source of truth for
480
+ // every caller; the CLI sends the script as written.
473
481
  let sentExpr = expr;
474
482
  let sentSteps = steps;
475
483
  // Validate the camera file locally first: a wrong file type should cost one
@@ -563,7 +571,10 @@ export const pageEvalCommand = new Command('eval')
563
571
  }));
564
572
  return;
565
573
  }
566
- console.log(`${brand('Eval')} ${bold(d.url || url)}`);
574
+ // Context echo (which URL, which script) goes to stderr: the caller
575
+ // already knows what it sent, and keeping stdout = qualifiers + result
576
+ // means `2>/dev/null` (or any stdout capture) yields just the answer.
577
+ console.error(`${brand('Eval')} ${bold(d.url || url)}`);
567
578
  if (d.navigationIncomplete) {
568
579
  console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
569
580
  }
@@ -574,19 +585,23 @@ export const pageEvalCommand = new Command('eval')
574
585
  if (d.slowRender) {
575
586
  console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
576
587
  }
577
- // Auth state: without this line an agent can't distinguish "signed-in
578
- // eval" from "--auth silently no-op'd against the anonymous page".
588
+ // Identity line on EVERY run: without it an agent can't distinguish
589
+ // "signed-in eval" from "--auth silently no-op'd against the anonymous
590
+ // page" — or, worse, assume an unflagged run carried a signed-in session.
579
591
  if (d.auth?.requested) {
580
592
  const who = getAuth()?.email;
581
593
  console.log(d.auth.established
582
594
  ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
583
595
  : `${warning('Auth: session NOT established')}${d.auth.detail ? ` — ${d.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
584
596
  }
597
+ else {
598
+ console.log(muted('Auth: anonymous visitor (signed out; pass --auth to run as your Gipity account)'));
599
+ }
585
600
  if (camera)
586
601
  console.log(`${muted('Camera:')} ${camera.name} ${muted('(played as the webcam feed; getUserMedia resolves)')}`);
587
602
  if (hosted.length)
588
603
  console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
589
- console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
604
+ console.error(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${summarizeExpr(expr)}`);
590
605
  console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
591
606
  if (noValue)
592
607
  console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
@@ -43,7 +43,7 @@ export const pageInspectCommand = new Command('inspect')
43
43
  .option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
44
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())
@@ -164,14 +164,18 @@ export async function inspectPage(url, opts = {}) {
164
164
  if (b.navigationIncomplete) {
165
165
  console.log(`${warning('⚠ Navigation incomplete:')} ${b.note || 'page did not reach full load'}`);
166
166
  }
167
- // Auth state: without this line an agent can't distinguish "signed-in render"
168
- // 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.
169
170
  if (b.auth?.requested) {
170
171
  const who = getAuth()?.email;
171
172
  console.log(b.auth.established
172
173
  ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
173
174
  : `${warning('Auth: session NOT established')}${b.auth.detail ? ` — ${b.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
174
175
  }
176
+ else {
177
+ console.log(muted('Auth: anonymous visitor (signed out; pass --auth to load as your Gipity account)'));
178
+ }
175
179
  console.log(`${muted('Title:')} ${b.title || '(none)'}`);
176
180
  console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
177
181
  console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);