@homie-rocks/studio 0.51.6 → 0.51.8

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 (42) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/apps/APPS.md +26 -0
  3. package/bin/homie-studio.mjs +10 -5
  4. package/docs/toolkit-place-patterns.md +62 -0
  5. package/functions/FUNCTIONS.md +2 -2
  6. package/guides/app/SKILL.md +34 -0
  7. package/guides/app/examples/reserve.mjs +13 -0
  8. package/guides/functions/SKILL.md +2 -0
  9. package/guides/office/SKILL.md +27 -1
  10. package/guides/servers/SKILL.md +27 -1
  11. package/guides/shop/SKILL.md +38 -15
  12. package/guides/studio-setup/SKILL.md +4 -0
  13. package/lib/app-needs.mjs +8 -2
  14. package/lib/app-ready.mjs +31 -0
  15. package/lib/check.mjs +9 -6
  16. package/lib/cloudflare.mjs +4 -4
  17. package/lib/connector-token.mjs +30 -0
  18. package/lib/mcp-tools.mjs +1 -1
  19. package/lib/shop-links.mjs +70 -32
  20. package/lib/shop-mcp.mjs +37 -10
  21. package/lib/stripe-connect.mjs +4 -4
  22. package/lib/template-history.json +84 -0
  23. package/lib/worker-config.mjs +3 -0
  24. package/netplay/netplay.ts +3 -0
  25. package/package.json +1 -1
  26. package/rules/collision.ts +4 -2
  27. package/rules/terrain.ts +2 -1
  28. package/rules/tiles.ts +8 -4
  29. package/shop/SHOP.md +38 -15
  30. package/worker/app-graphics.mjs +8 -0
  31. package/worker/app-shell.mjs +36 -15
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/connector-token.mjs +11 -0
  34. package/worker/index.mjs +5 -2
  35. package/worker/join-area.mjs +35 -0
  36. package/worker/mcp-tools.mjs +12 -3
  37. package/worker/mcp.mjs +14 -6
  38. package/worker/pages.mjs +6 -3
  39. package/worker/records-page.mjs +3 -2
  40. package/worker/records-prefs.mjs +24 -0
  41. package/worker/shop.mjs +2 -2
  42. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,41 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.51.8] - 2026-10-15
21
+
22
+ **Plugin 0.51.8**
23
+
24
+ Run your studio across more screens with reliable shared records, clearer checks and a simpler owner connection.
25
+
26
+ ### Fixed
27
+
28
+ - **Reliable screens and saved settings:** App screens share simultaneous record reads across tabs, queue bursts and back off after connection failures. Writes stay separate and refresh cached reads; reconnect notices use plain language. Records-only apps remember device preferences and saved references after reload while keeping their iframe sandbox.
29
+ - **Room for your wall display:** TV join cards reserve a measured safe area for your app, stay within a bounded size and wrap long addresses. Hiding the card returns that space to your content.
30
+ - **Checks that match what people can use:** Covered controls fail checks immediately with the blocking element identified, including overlays outside the app frame. You can also check text navigation with graphics disabled using `check --webgl-failure`; fallback notices stay in the page flow so controls remain reachable.
31
+ - **Clean toolkit type checks:** Collision, terrain and tile-coordinate code now passes strict `build --types` checks when imported into your app or game.
32
+
33
+ ### Added
34
+
35
+ - **Connect your owner's AI with your existing login:** `connector` uses your Wrangler sign-in to save a private MCP configuration limited to the chosen tools, studio origin and expiry. You can revoke access; the server stores only token hashes and command output contains no bearer secret.
36
+
37
+ ### Changed
38
+
39
+ - **Safer bookings and usable text controls:** Authoring guides show server-owned capacity with atomic versioned writes, tested with six simultaneous requests and one successful reservation. They explain how to initialise text controls before graphics and keep the app usable when 3D is unavailable.
40
+ - **Clear deploy guidance:** Wrangler remains the path for uploading your site's static assets. The guide explains why Cloudflare's official MCP request interface cannot perform the required upload authorization.
41
+
42
+ ## [0.51.7] - 2026-10-14
43
+
44
+ **Plugin 0.51.7**
45
+
46
+ Finish setting up your shop through Stripe's official plugin, and see what your studio's scheduled work is doing.
47
+
48
+ ### Fixed
49
+
50
+ - **Finish shop setup with fewer calls:** Products, prices and payment links are set up together where possible, and setup remembers completed changes instead of repeatedly searching your Stripe account. Verified shop settings are saved and installed on deploy; checkout explains when private webhook setup is the remaining step.
51
+ - **Clear, private webhook setup:** The guided flow explains when you need one separate Stripe CLI browser approval. The installer passes the signing secret privately to Wrangler. You do not need to supply an API key, and payments stay with Stripe.
52
+ - **See your studio's work:** Owners and studio-wide staff can inspect function run history, errors and the next scheduled run through their authenticated studio connection. Record actions use your highest declared role by default and tell you which role was used.
53
+ - **Reliable handover:** Deploy distinguishes claiming your studio from listing it in the directory. Screenshots used as evidence that owner needs are met are copied into the studio so they remain available with its Codex handover.
54
+
20
55
  ## [0.51.6] - 2026-10-14
21
56
 
22
57
  **Plugin 0.51.6**
package/apps/APPS.md CHANGED
@@ -196,3 +196,29 @@ Keep buttons connected during delayed refreshes: create the button once and set
196
196
  `button.textContent = label` when data arrives. Never replace a held button with
197
197
  `innerHTML` or `replaceWith`; that can cancel navigation between pointer down and
198
198
  up. The needs guide has a short complete example and explains outcome assertions.
199
+
200
+
201
+ ## Selling and operational checks over MCP
202
+
203
+ Use Stripe's official plugin and the shop skill's batched `stripe_shop_plan` flow.
204
+ Retain write responses, verify, then deploy the compact public links. Until the
205
+ private `stripe_webhook` step completes, checkout names that one remaining step.
206
+ The AI starts the tool; the owner approves Stripe CLI once if needed. No secrets
207
+ or API keys pass through chat. Deploy distinguishes **claimed, not listed** from
208
+ an actual directory listing; publishing is a separate action.
209
+
210
+ On the existing signed-in studio connector (`<site>/mcp/auth`), call
211
+ `studio_function_runs` with optional `name`, `errors`, `before` for actual attempts,
212
+ redacted errors and next due times. Owners and studio-wide `staff` grants can read
213
+ this operational history; per-app staff grants do not expose other apps' runs.
214
+ Do not start `functions runs --url` in a headless shell with no CLI OAuth session:
215
+ that is a different client and would need its own initial browser approval.
216
+
217
+ `app_records` without `role` selects the highest available declared role: signed-in
218
+ roles first, then the largest permission set, with role name as a stable tie-break.
219
+ Each response reports `role`; an explicit role stays explicit. Inspect `app_schema`
220
+ and pass a role when deliberately testing the customer experience.
221
+
222
+ `check --shots` may capture outside the studio, but persistent needs evidence copies
223
+ those pictures into `.studio/checks` and stores studio-relative paths. Keep that
224
+ folder with the Codex evidence. Deploy can render it without outside-path warnings.
@@ -37,7 +37,9 @@
37
37
  * AI guides, chat review and game decisions think with Clef on this computer
38
38
  * when Ollama has clef-flash (free; dev never downloads it), else scripted;
39
39
  * --remote-ai: the real Workers AI, billed)
40
- * homie-studio check <id> [--url <site>] [--shots <dir>]
40
+ * homie-studio check <id> [--url <site>] [--shots <dir>] [--webgl-failure]
41
+ * homie-studio connector --tools <name,...> --out <private-config.json> [--days 30]
42
+ * homie-studio connector --revoke <id>
41
43
  * homie-studio shoot <id> [--url <site> | --preview] [--frames 60] [--fps 30] [--device computer|phone]
42
44
  * [--out <dir>] [--hold <key code>] [--no-smoke] [--timeout 180]
43
45
  * pictures of the game on a clock this command steps (one frame = 1/fps s
@@ -380,14 +382,14 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
380
382
  const argv = process.argv.slice(2);
381
383
  const flags = new Map();
382
384
  // build --quick keeps compilation and type checks, skips rules generated play/smoke checks.
383
- const BOOL_FLAGS = ['quick', 'public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
385
+ const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
384
386
  const positional = [];
385
387
  for (let i = 0; i < argv.length; i++) {
386
388
  const a = argv[i];
387
389
  if (a.startsWith('--')) {
388
390
  const [k, v] = a.slice(2).split(/=(.*)/s, 2);
389
391
  if (v !== undefined) flags.set(k, v);
390
- else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && (!BOOL_FLAGS.includes(k) || k === 'profile' && argv[0] === 'shop')) flags.set(k, argv[++i]);
392
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && (!BOOL_FLAGS.includes(k) || k === 'revoke' && argv[0] === 'connector' || k === 'profile' && argv[0] === 'shop')) flags.set(k, argv[++i]);
391
393
  else flags.set(k, true);
392
394
  } else positional.push(a);
393
395
  }
@@ -407,6 +409,7 @@ function print(result) {
407
409
  if (/^(style|assets|cast|anim)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
408
410
  if (/^collision /.test(String(result.command ?? '')) && result.lines) { process.stdout.write(`${result.lines.join('\n')}\n`); return; }
409
411
  switch (result.command) {
412
+ case 'connector': lines.push(JSON.stringify(result,null,2)); break;
410
413
  case 'new':
411
414
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
412
415
  break;
@@ -514,7 +517,7 @@ function print(result) {
514
517
  // (lib/deploy-state.mjs): the hash `build` printed and the one the live site's manifest answers with.
515
518
  ...result.games.map((g) => ` ${g.id}: ${g.open ?? g.play}${g.hash ? ` [${deployWords(g)}]` : ''}${g.wall ? ` wall: ${g.wall}` : ''}`), ...(result.removed?.length ? [` no longer on the site: ${result.removed.join(', ')}`] : []), ...(result.notes ?? []).map((n) => ` Note: ${n}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
516
519
  ...(result.media?.moved ?? []).map((m) => ` moved to R2 (checked by SHA-256): ${m.path}, still at ${m.url}`), ...(result.media?.failed ?? []).map((m) => ` not moved to R2: ${m.path}: ${m.why}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}.`,
517
- result.claim ? 'The site claimed itself in the directory: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim. Listing is the studio’s choice: set homie.directory to true or a directory URL before deploying to request one.',
520
+ result.claim ? `The site is ${result.listing??'claimed, not listed'}: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish` : 'No directory claim. Listing is the studio’s choice: set homie.directory to true or a directory URL before deploying to request one.',
518
521
  ...(result.standalone?.length ? ['', ...result.standalone] : []));
519
522
  break;
520
523
  case 'deploy plan':
@@ -831,6 +834,7 @@ const OUTWARD_FLAGS = {
831
834
  'setup attach': ['homie', 'client'],
832
835
  handoff: ['homie', 'client'],
833
836
  'players owner': ['url', 'revoke'],
837
+ 'connector': ['url','tools','out','days','revoke'],
834
838
  'stats key': ['url', 'hours'], 'stats link': ['url'], 'stats revoke': ['url'], 'stats share': [],
835
839
  'office link': ['url', 'to'], 'office key': ['url', 'hours'], 'office announce': ['url', 'game', 'room', 'seconds'],
836
840
  'office invite': ['url', 'label', 'uses', 'count', 'days', 'server'], 'office launch': ['url', 'max'],
@@ -914,6 +918,7 @@ async function main() {
914
918
  }
915
919
  if (cmd === 'events') return (await import('../lib/events-cli.mjs')).eventsCommand(root, sub, positional, flags, { log });
916
920
  if (cmd === 'function' || cmd === 'functions') return (await import('../lib/functions-cli.mjs')).functionCommand(root, sub, positional, flags, { log });
921
+ if (cmd === 'connector') return (await import('../lib/connector-token.mjs')).connectorToken(root,{url:flags.get('url'),tools:typeof flags.get('tools')==='string'?flags.get('tools').split(','):[],out:flags.get('out'),days:flags.has('days')?Number(flags.get('days')):30,revoke:flags.get('revoke')});
917
922
  if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
918
923
  // A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
919
924
  if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
@@ -995,7 +1000,7 @@ async function main() {
995
1000
  const results = [];
996
1001
  for (const game of games) {
997
1002
  const shots = flags.get('shots') ? resolve(flags.get('shots'), ...(games.length > 1 ? [game] : [])) : null;
998
- const r = await tracked(root, 'checks', (report) => check({ root, url: site.url, game, shots, log, report, isolate:!flags.has('no-isolate') }), 'check');
1003
+ const r = await tracked(root, 'checks', (report) => check({ webglFailure:flags.has('webgl-failure'), root, url: site.url, game, shots, log, report, isolate:!flags.has('no-isolate') }), 'check');
999
1004
  results.push({ ...r, game, site: site.url, local: site.local });
1000
1005
  const receiptDir = join(root, '.studio', 'checks');
1001
1006
  mkdirSync(receiptDir, { recursive: true });
@@ -0,0 +1,62 @@
1
+ # Building places: layout, authority and recovery
2
+
3
+ ## TV join safe area
4
+
5
+ On `/<id>/tv`, the shell sets `body.homie-join-visible` while the join card is present. It publishes CSS pixel values on the document root in both the shell and the sandboxed app: `--homie-join-x`, `--homie-join-y`, `--homie-join-width`, `--homie-join-height`, and `--homie-join-inset-top/right/bottom/left`. The insets include the card's distance from its configured screen edge. Inactive edges are zero; no card means all values are zero and the body class is absent. Measurements update on resize and iframe navigation. The card is at most 480 × 280 CSS pixels; its address wraps within that box.
6
+
7
+ Reserve an entire strip on either occupied axis, with a gap:
8
+
9
+ ```css
10
+ .status { position: fixed; right: 20px;
11
+ bottom: calc(var(--homie-join-inset-bottom, 0px) + 20px); }
12
+ .tee-sheet { position: fixed; left: 20px; bottom: 20px;
13
+ right: calc(var(--homie-join-inset-right, 0px) + 20px); }
14
+ ```
15
+
16
+ For top/left join corners, use the corresponding top/left insets. Never position critical controls underneath the card merely because it ignores pointer events. Regression coverage measures actual rectangles at 1920×1080, 1280×720 and 3840×2160.
17
+
18
+ ## Authoritative capacity
19
+
20
+ Bookings and inventory belong to server-side `defineTool` mutations, with strict schemas (`additionalProperties: false`) and atomic versioned writes. A client-writable generic ledger is not an inventory authority: six browsers can all read one remaining place and overwrite one another. Clients may display availability and request a reservation; they cannot supply remaining capacity, identity or a new inventory total.
21
+
22
+ The executable [reservation example](../guides/app/examples/reserve.mjs) accepts only a slot ID. An owner-only action seeds `ctx.database.put('tee-0900', {remaining: 1, bookings: []}, {version: 0})`. `reserve` reads that row, derives the booking identity from `ctx.caller`, and writes with `{version: row.version}`. One SQLite statement checks the version and updates the entire row; a conflict is a failed reservation, never a success. Six simultaneous requests produce one winner and five conflicts. Do not expose this namespace through a generic client record mutation. Read availability through a separate read-only action. For retryable purchases add a server-owned idempotency key and settlement handling before charging.
23
+
24
+ ## Text first, graphics optional
25
+
26
+ Bind navigation and text controls before creating a renderer. A failed WebGL context must not prevent those handlers from being installed:
27
+
28
+ ```ts
29
+ renderTextControls();
30
+ bindNavigation();
31
+ try {
32
+ const renderer = new THREE.WebGLRenderer({canvas});
33
+ startScene(renderer);
34
+ } catch {
35
+ canvas.hidden = true;
36
+ fallback.hidden = false; // a normal-flow status inside the scene section
37
+ }
38
+ ```
39
+
40
+ Keep the fallback inside the scene section, outside navigation; never use a fullscreen or fixed overlay. The shell's automatic fallback is a normal-flow status beside the canvas and does not intercept input. Author a Codex need that clicks navigation and asserts the resulting text; then run `homie-studio check <id> --webgl-failure --shots .checks/no-webgl`. This disables WebGL context creation before scripts run in every app frame and executes the same UI needs. A passing rendering check alone does not establish usable text controls.
41
+
42
+ ## Action diagnostics and types
43
+
44
+ `check` tests whether the action target is covered before dispatch. An obstruction reports the surface, target selector and covering element immediately; remove the overlay or repair its layout before retrying. Navigation/detachment handling does not replay an ambiguous dispatched action.
45
+
46
+ `build --types` checks imported toolkit sources too. Collision/terrain closure narrowing and tile-coordinate narrowing are fixed in the toolkit; fresh studios need no local suppressions or edits to `node_modules`.
47
+
48
+ ## Connect another studio without another approval page
49
+
50
+ Use the new studio's directory and the owner's existing Wrangler login (the same Cloudflare account must have D1 access). One command writes a connector config, never its bearer to stdout:
51
+
52
+ ```sh
53
+ homie-studio connector --tools studio_stats --out /private/path/studio-connector.json
54
+ ```
55
+
56
+ Load that file directly into the AI client's connector configuration using its local configuration/import mechanism. Do not paste, print or include the file in chat or commit it. The file is created exclusively with mode 0600; existing files are never overwritten. Only the file path, token ID, scopes and expiry are reported. The config targets this studio's `/mcp/auth` endpoint. No browser approval is needed when Wrangler is already authenticated; this command does not deploy anything.
57
+
58
+ Tokens are bound to this origin, limited to the exact named tools, expire after 30 days by default (`--days 1` through `--days 90`), and cannot read MCP resources or subscriptions. D1 stores only the token hash and its scope metadata. Revoke with `homie-studio connector --revoke <id>` from the same studio. Every dispatch rechecks expiry and revocation. A login to a different studio alone does not grant authority over this database.
59
+
60
+ ## Deployment
61
+
62
+ Cloudflare's official MCP request interface cannot authorize new static-asset uploads with an upload JWT; deploy with Wrangler, Cloudflare's own CLI using browser OAuth.
@@ -87,8 +87,8 @@ homie-studio functions runs --before 101
87
87
  The command uses the studio's deployed address unless `--url` overrides it. It
88
88
  uses the existing owner OAuth connection (one initial browser approval; no office
89
89
  key). After approval, checks need no browser. An AI connected to `<site>/mcp` calls
90
- the owner-only `studio_function_runs` tool with optional `name`, `errors` and
91
- `before`. Anonymous callers and staff cannot read it.
90
+ the owner/studio-staff `studio_function_runs` tool with optional `name`, `errors` and
91
+ `before`. Anonymous callers and per-app staff cannot read it. A studio-wide `staff` grant can read it; revocation takes effect on the next call. Reuse the authenticated connector at `/mcp/auth`; a headless CLI with no saved OAuth session requires a separate initial browser approval.
92
92
 
93
93
  Results include function, event ID, trigger, started/finished UTC times, outcome,
94
94
  error message and attempt. Failed attempts survive successful retries; history
@@ -328,3 +328,37 @@ text and attributes in place; never replace a button under a press. The short
328
328
  example in NEEDS.md preserves its click listener and focus. A stale-element retry
329
329
  helps a replaced handle before dispatch; it cannot recover a silently cancelled
330
330
  press. Assert the resulting navigation as well as the button's presence.
331
+
332
+
333
+ ## Selling and operational checks over MCP
334
+
335
+ Use Stripe's official plugin and the shop skill's batched `stripe_shop_plan` flow.
336
+ Retain write responses, verify, then deploy the compact public links. Until the
337
+ private `stripe_webhook` step completes, checkout names that one remaining step.
338
+ The AI starts the tool; the owner approves Stripe CLI once if needed. No secrets
339
+ or API keys pass through chat. Deploy distinguishes **claimed, not listed** from
340
+ an actual directory listing; publishing is a separate action.
341
+
342
+ On the existing signed-in studio connector (`<site>/mcp/auth`), call
343
+ `studio_function_runs` with optional `name`, `errors`, `before` for actual attempts,
344
+ redacted errors and next due times. Owners and studio-wide `staff` grants can read
345
+ this operational history; per-app staff grants do not expose other apps' runs.
346
+ Do not start `functions runs --url` in a headless shell with no CLI OAuth session:
347
+ that is a different client and would need its own initial browser approval.
348
+
349
+ `app_records` without `role` selects the highest available declared role: signed-in
350
+ roles first, then the largest permission set, with role name as a stable tie-break.
351
+ Each response reports `role`; an explicit role stays explicit. Inspect `app_schema`
352
+ and pass a role when deliberately testing the customer experience.
353
+
354
+ `check --shots` may capture outside the studio, but persistent needs evidence copies
355
+ those pictures into `.studio/checks` and stores studio-relative paths. Keep that
356
+ folder with the Codex evidence. Deploy can render it without outside-path warnings.
357
+
358
+ ### Places: capacity, TV space and failed graphics
359
+
360
+ Authoritative bookings/inventory are server-side `defineTool` actions with strict schemas and atomic versioned writes, never a client-writable generic ledger. Use [the capacity example](examples/reserve.mjs): seed one place through an owner action; six simultaneous reservation requests must yield exactly one winner. Derive identity from the caller; expose neither inventory totals nor the authoritative namespace as client mutations.
361
+
362
+ Initialise text controls and navigation before 3D rendering. Catch renderer creation failures inside the scene section; keep fallback text in normal flow, never over navigation. Add a navigation need and run `homie-studio check <id> --webgl-failure --shots .checks/no-webgl` to prove the controls work without WebGL.
363
+
364
+ On TV, reserve `--homie-join-inset-bottom/right` (or the configured corner's top/left) plus a gap; `body.homie-join-visible` and `--homie-join-x/y/width/height` describe the shell card in the app frame too. See `node_modules/@homie-rocks/studio/docs/toolkit-place-patterns.md` for layout and text-first examples, connector setup and diagnostics.
@@ -0,0 +1,13 @@
1
+ import {defineTool} from '@homie-rocks/studio/tools';
2
+ export default defineTool({
3
+ name:'reserve',description:'Reserve one place in a server-owned slot',audience:'signed-in',namespace:'capacity',
4
+ inputSchema:{type:'object',properties:{slot:{type:'string',pattern:'^tee-[0-9]{4}$'}},required:['slot'],additionalProperties:false},
5
+ async handler({slot},ctx){
6
+ const row=await ctx.database.getVersioned(slot);
7
+ if(!row)return {ok:false,reason:'Unknown slot'};
8
+ const value=row.value;
9
+ if(value.remaining<1)return {ok:false,reason:'Full'};
10
+ const result=await ctx.database.put(slot,{remaining:value.remaining-1,bookings:[...value.bookings,ctx.caller.id]},{version:row.version});
11
+ return result.ok?{ok:true}:{ok:false,reason:'Taken; refresh availability'};
12
+ }
13
+ });
@@ -28,3 +28,5 @@ isolated records. Assert actual sound/haptic/notification triggers where request
28
28
  Check waits through reload readiness and reports dialogs and named timeout conditions.
29
29
  Do not weaken a need to obtain a green result; inspect captures and complete the
30
30
  owner's workflow. Do not deploy merely to prove a local handler.
31
+
32
+ For authoritative bookings/inventory use strict `defineTool` actions and `database.put(key, next, {version: row.version})`; a conflict is a failed reservation. Never make the inventory a client-writable generic ledger. The [capacity example](../app/examples/reserve.mjs) proves one winner from six simultaneous requests for one place; initialise capacity through an owner-only action.
@@ -118,7 +118,7 @@ build/deploy; the Worker dispatches due functions without an office browser.
118
118
  Build and redeploy after upgrading; custom crons are kept. Check the deploy plan's
119
119
  cron line. Do not add a default cron to a studio with no declared need.
120
120
 
121
- To inspect runs from the owner's AI, call the owner-only studio MCP tool
121
+ To inspect runs from the owner's AI, call the owner or studio-staff MCP tool
122
122
  `studio_function_runs` (optional `name`, `errors`, `before`), or run
123
123
  `homie-studio functions runs [<name>] [--errors] [--url <studio-site>]`.
124
124
  Use the existing owner OAuth connection; never request an office key. Results
@@ -180,3 +180,29 @@ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
180
180
  posts signed events to the client's webhook so new or recurring reliability
181
181
  problems reach the AI without polling; Homie hosts nothing for this. See
182
182
  [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
183
+
184
+
185
+ ## Selling and operational checks over MCP
186
+
187
+ Use Stripe's official plugin and the shop skill's batched `stripe_shop_plan` flow.
188
+ Retain write responses, verify, then deploy the compact public links. Until the
189
+ private `stripe_webhook` step completes, checkout names that one remaining step.
190
+ The AI starts the tool; the owner approves Stripe CLI once if needed. No secrets
191
+ or API keys pass through chat. Deploy distinguishes **claimed, not listed** from
192
+ an actual directory listing; publishing is a separate action.
193
+
194
+ On the existing signed-in studio connector (`<site>/mcp/auth`), call
195
+ `studio_function_runs` with optional `name`, `errors`, `before` for actual attempts,
196
+ redacted errors and next due times. Owners and studio-wide `staff` grants can read
197
+ this operational history; per-app staff grants do not expose other apps' runs.
198
+ Do not start `functions runs --url` in a headless shell with no CLI OAuth session:
199
+ that is a different client and would need its own initial browser approval.
200
+
201
+ `app_records` without `role` selects the highest available declared role: signed-in
202
+ roles first, then the largest permission set, with role name as a stable tie-break.
203
+ Each response reports `role`; an explicit role stays explicit. Inspect `app_schema`
204
+ and pass a role when deliberately testing the customer experience.
205
+
206
+ `check --shots` may capture outside the studio, but persistent needs evidence copies
207
+ those pictures into `.studio/checks` and stores studio-relative paths. Keep that
208
+ folder with the Codex evidence. Deploy can render it without outside-path warnings.
@@ -130,7 +130,7 @@ build/deploy; the Worker dispatches due functions without an office browser.
130
130
  Build and redeploy after upgrading; custom crons are kept. Check the deploy plan's
131
131
  cron line. Do not add a default cron to a studio with no declared need.
132
132
 
133
- To inspect runs from the owner's AI, call the owner-only studio MCP tool
133
+ To inspect runs from the owner's AI, call the owner or studio-staff MCP tool
134
134
  `studio_function_runs` (optional `name`, `errors`, `before`), or run
135
135
  `homie-studio functions runs [<name>] [--errors] [--url <studio-site>]`.
136
136
  Use the existing owner OAuth connection; never request an office key. Results
@@ -181,3 +181,29 @@ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
181
181
  posts signed events to the client's webhook so new or recurring reliability
182
182
  problems reach the AI without polling; Homie hosts nothing for this. See
183
183
  [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
184
+
185
+
186
+ ## Selling and operational checks over MCP
187
+
188
+ Use Stripe's official plugin and the shop skill's batched `stripe_shop_plan` flow.
189
+ Retain write responses, verify, then deploy the compact public links. Until the
190
+ private `stripe_webhook` step completes, checkout names that one remaining step.
191
+ The AI starts the tool; the owner approves Stripe CLI once if needed. No secrets
192
+ or API keys pass through chat. Deploy distinguishes **claimed, not listed** from
193
+ an actual directory listing; publishing is a separate action.
194
+
195
+ On the existing signed-in studio connector (`<site>/mcp/auth`), call
196
+ `studio_function_runs` with optional `name`, `errors`, `before` for actual attempts,
197
+ redacted errors and next due times. Owners and studio-wide `staff` grants can read
198
+ this operational history; per-app staff grants do not expose other apps' runs.
199
+ Do not start `functions runs --url` in a headless shell with no CLI OAuth session:
200
+ that is a different client and would need its own initial browser approval.
201
+
202
+ `app_records` without `role` selects the highest available declared role: signed-in
203
+ roles first, then the largest permission set, with role name as a stable tie-break.
204
+ Each response reports `role`; an explicit role stays explicit. Inspect `app_schema`
205
+ and pass a role when deliberately testing the customer experience.
206
+
207
+ `check --shots` may capture outside the studio, but persistent needs evidence copies
208
+ those pictures into `.studio/checks` and stores studio-relative paths. Keep that
209
+ folder with the Codex evidence. Deploy can render it without outside-path warnings.
@@ -42,20 +42,30 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
42
42
  read `/v1/account` to record its account ID. Live mode only after the owner requests it.
43
43
  2. Call Homie's `stripe_shop_plan` with `account`, `mode: "test"`, `slots` and
44
44
  `observations: []`. CLI equivalent: `homie-studio shop plan --have plan-input.json`.
45
- That JSON file contains the same fields. A returned `next` is the exact operation:
46
- method, API path, parameters, API version and (for creates) idempotency key. Updates set desired fields; read before retrying. Metadata
47
- scopes objects to this studio and origin; prices and links have content revisions.
48
- 3. Perform each `next` through Stripe's official MCP. For GET, append
49
- `{ "path": "…", "params": {…}, "body": <Stripe response> }` to `observations`
50
- and plan again. Follow every pagination request. After a write, discard observations
51
- and read fresh. Replanning resolves real Stripe IDs; never invent IDs or substitute
52
- a different price. Reuse the supplied idempotency key where the tool supports it;
53
- otherwise read before retrying and match the stable product ID/revision metadata.
54
- 4. When no writes remain, use fresh reads with `stripe_shop_plan` `verify: true`
55
- (CLI `homie-studio shop verify --have plan-input.json`). It checks the catalog,
56
- actual link fields and line items, menus, slot limits and portal settings against
57
- shop.json and the slot manifest. A mismatch blocks verification. It saves only public
58
- configuration to `.studio/stripe-mcp.json`; local test configuration goes to `.dev.vars`.
45
+ That JSON file contains the same fields. `operations` contains every independent
46
+ operation currently ready, with method, path, parameters and create idempotency key.
47
+ Execute the batch through Stripe's official plugin, then replan for dependent work.
48
+ `next` remains the first operation for older clients.
49
+ 3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
50
+ for **writes as well as reads**. Keep this setup's observations across batches.
51
+ Product creation includes its default Price; link creation expands line items.
52
+ Product and Price Search use studio/origin metadata. Existing links are fetched by
53
+ IDs from the verified configuration; Stripe has no Payment Link metadata search.
54
+ Keep `.studio/stripe-mcp.json` when moving the studio to another computer. Without
55
+ it, repeat creates use their idempotency keys; outside Stripe's idempotency retention
56
+ window, recover the studio's existing link IDs before setup to avoid duplicates.
57
+ Follow search `next_page` cursors. Search is eventually consistent: write responses
58
+ are the immediate evidence. On an uncertain write, retry its exact idempotency key;
59
+ never discard successful responses or invent IDs. Start a new read set when checking
60
+ later external edits, changing accounts, or changing mode.
61
+ 4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
62
+ `homie-studio shop verify --have plan-input.json`). Verification checks real fields,
63
+ line items, menus, slot limits and portal settings. It saves compact public Worker
64
+ configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
65
+ Deploy installs those links. Checkout then reports only `webhook-secret` missing;
66
+ it cannot open until signed payment events can confirm orders. Changing shop.json,
67
+ the slot manifest or origin requires verification again. Local test configuration
68
+ goes to `.dev.vars`.
59
69
  5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
60
70
  Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
61
71
  checks the same account as the MCP, and installs the signing secret and verified
@@ -63,7 +73,7 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
63
73
  studio profile is not already connected. MCP approval cannot be reused or exported.
64
74
  Cloudflare authorization and an existing deployment are prerequisites.
65
75
  6. After installation, perform the returned `retire` operations through Stripe MCP to
66
- archive replaced catalog objects, then verify fresh reads again. Test a sandbox
76
+ archive replaced catalog objects. Use fresh scoped reads for later drift checks. Test a sandbox
67
77
  purchase and its signed event, order grant, booking capacity or recurring invoice;
68
78
  a browser return is never proof. Stripe CLI `trigger` can send a test event, and
69
79
  `events resend <event> --webhook-endpoint <endpoint>` retries a delivery. Check the
@@ -72,6 +82,19 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
72
82
 
73
83
  Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
74
84
 
85
+ **Why the separate approval (checked 2026-10-14).** Stripe's
86
+ [event destination API](https://docs.stripe.com/api/v2/core/event-destinations/create)
87
+ also returns its signing secret only in the private create response when explicitly
88
+ included. Thin events still require signature verification and authenticated retrieval
89
+ of event details; they do not supply a secret handoff to Wrangler. The
90
+ [official MCP](https://docs.stripe.com/mcp) documents no credential or secret transfer
91
+ to a studio Worker. Therefore the AI starts `stripe_webhook`, the owner clicks
92
+ Approve once in Stripe's CLI browser page if needed, and the tool completes endpoint
93
+ creation and Wrangler secret installation privately. MCP approval and CLI approval
94
+ are separate. No API key is requested, no secret enters chat, and Homie is never in
95
+ the money path. A connected studio CLI profile needs no further approval.
96
+
97
+
75
98
  The owner never handles an API key or signing secret. No Homie backend handles payments.
76
99
  The Worker needs only public link configuration and the signing secret; buyers pay
77
100
  Stripe directly even when the AI and the owner's computer are offline. The old
@@ -409,3 +409,7 @@ and slug. It fills missing generated files and retries installation, preserving
409
409
  already there. A different studio or unrelated nonempty folder is still refused.
410
410
  New studios do not request a directory listing by default; set `homie.directory: true`
411
411
  or a directory URL only when the person chooses listing.
412
+
413
+ Cloudflare's official MCP request interface cannot authorize new static-asset uploads with an upload JWT; deploy with Wrangler, Cloudflare's own CLI using browser OAuth.
414
+
415
+ An owner's existing Wrangler login can connect another studio without a new browser approval: in that studio run `homie-studio connector --tools <exact-tool-names> --out <private-config.json>`, then load the file directly into the AI client's connector configuration. Never print the bearer or paste the file in chat. Revoke with `homie-studio connector --revoke <id>`. See the installed `docs/toolkit-place-patterns.md` for scope and expiry.
package/lib/app-needs.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import {actInApp,waitInApp} from './app-ready.mjs';
2
2
  /** Opt-in acceptance evidence, sourced from the person's Codex, not starter labels. */
3
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { join, resolve, relative, isAbsolute } from 'node:path';
3
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, copyFileSync, realpathSync } from 'node:fs';
4
+ import { join, resolve, relative, isAbsolute, basename } from 'node:path';
5
5
  import { isDeepStrictEqual } from 'node:util';
6
6
  import { createHash } from 'node:crypto';
7
7
 
@@ -129,6 +129,12 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
129
129
  export function saveAppNeeds(root, id, plan, needs) {
130
130
  if (!root) return;
131
131
  const dir = join(root, '.studio', 'checks'); mkdirSync(dir, { recursive: true });
132
+ needs=needs.map(need=>({...need,captures:(need.captures??[]).map(file=>{
133
+ const source=realpathSync(resolve(root,file)),rel=relative(realpathSync(root),source);
134
+ if(!rel.startsWith('..')&&!isAbsolute(rel))return rel;
135
+ const target=join(dir,`${id}-${createHash('sha256').update(source).digest('hex').slice(0,12)}-${basename(source)}`);
136
+ copyFileSync(source,target);return relative(root,target);
137
+ })}));
132
138
  writeFileSync(join(dir, `${id}-needs.json`), JSON.stringify({ at: new Date().toISOString(), codexHash: plan.hash, needs }, null, 2));
133
139
  }
134
140
 
package/lib/app-ready.mjs CHANGED
@@ -56,6 +56,28 @@ export async function actInApp(surface, selector, action, {ready=appReady,timeou
56
56
  for (;;) {
57
57
  try {
58
58
  await waitInApp(surface,selector,frame=>frame.waitForSelector(selector,{visible:true,timeout:Math.max(1,end-Date.now())}),{ready,timeout:Math.max(1,end-Date.now())});
59
+ const handle=await surface.frame.$?.(selector);
60
+ if(handle)try {
61
+ await handle.scrollIntoView();
62
+ const point=await handle.clickablePoint();
63
+ const ancestors=[];
64
+ for(let frame=surface.frame;frame.parentFrame();frame=frame.parentFrame())ancestors.unshift(await frame.frameElement());
65
+ const describeHit=(el,p)=>{
66
+ const hit=document.elementFromPoint(p.x,p.y);
67
+ return hit&&!el.contains(hit)?hit.tagName.toLowerCase()+(hit.id?'#'+hit.id:'')+[...hit.classList].map(c=>'.'+c).join(''):null;
68
+ };
69
+ let local={...point};
70
+ try {
71
+ for(const ancestor of ancestors){
72
+ const covered=await ancestor.evaluate(describeHit,local);
73
+ if(covered)throw new Error(`${surface.name}: ${selector} is covered at the click point by ${covered}`);
74
+ const offset=await ancestor.evaluate(el=>{const r=el.getBoundingClientRect();return {x:r.x+el.clientLeft,y:r.y+el.clientTop};});
75
+ local={x:local.x-offset.x,y:local.y-offset.y};
76
+ }
77
+ const covered=await handle.evaluate(describeHit,local);
78
+ if(covered)throw new Error(`${surface.name}: ${selector} is covered at the click point by ${covered}`);
79
+ }finally{await Promise.all(ancestors.map(h=>h.dispose()));}
80
+ }finally{await handle.dispose();}
59
81
  return await action(surface.frame);
60
82
  } catch(error) {
61
83
  if(!/node is detached|element.*(?:detach|stale)|stale.*element/i.test(error.message)) {
@@ -69,3 +91,12 @@ export async function actInApp(surface, selector, action, {ready=appReady,timeou
69
91
  }
70
92
  }
71
93
  }
94
+
95
+ /** Install before navigation: every app iframe observes a real context creation failure. */
96
+ export function forceWebglFailure() {
97
+ for(const proto of [globalThis.HTMLCanvasElement?.prototype,globalThis.OffscreenCanvas?.prototype]) {
98
+ if(!proto)continue;
99
+ const original=proto.getContext;
100
+ proto.getContext=function(kind,...args){return /^(webgl2?|experimental-webgl)$/.test(kind)?null:Reflect.apply(original,this,[kind,...args]);};
101
+ }
102
+ }
package/lib/check.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {needsHeadless} from './needs-headless.mjs';
2
2
  import {randomBytes} from 'node:crypto';
3
- import {actInApp,appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
3
+ import {actInApp,appReady,observeEffects,forceWebglFailure,waitInApp,answerDialogs} from './app-ready.mjs';
4
4
  import {localCredential,localCookie} from './local-test.mjs';
5
5
  import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
6
6
  /**
@@ -132,7 +132,7 @@ export function judgeRounds(browsers, missed = new Map()) {
132
132
  return null;
133
133
  }
134
134
 
135
- export async function check({ isolate = true, root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
135
+ export async function check({ webglFailure = false, isolate = true, root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
136
136
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
137
137
  const base = String(url).replace(/\/+$/, '');
138
138
  const play = `${base}/${game}/play`;
@@ -152,14 +152,15 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
152
152
  if(appMeta && !appMeta.check?.action) {
153
153
  try {
154
154
  const plan=readAppNeeds(root,game);
155
- if(plan && !plan.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({isolate,root,url:base,game,meta:appMeta,shots,log,report});
155
+ if(plan && !plan.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({webglFailure,isolate,root,url:base,game,meta:appMeta,shots,log,report});
156
156
  } catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
157
157
  }
158
158
  const chrome = findChrome();
159
159
  if (!chrome) return { ok: false, command: 'check', why: noChrome() };
160
160
  let puppeteer;
161
161
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
162
- if (appMeta) return checkApp({ isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
162
+ if (appMeta) return checkApp({ webglFailure, isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
163
+ if(webglFailure)return {ok:false,command:'check',why:'--webgl-failure requires an app with UI checks'};
163
164
  if (shots) mkdirSync(shots, { recursive: true });
164
165
  const profiles = [];
165
166
  const browsers = [];
@@ -354,7 +355,7 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
354
355
  }
355
356
 
356
357
  /** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
357
- export async function checkApp({ isolate = true, root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
358
+ export async function checkApp({ webglFailure = false, isolate = true, root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
358
359
  let plan;
359
360
  try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
360
361
  const secret=localCredential(root,url);
@@ -364,6 +365,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
364
365
  const proof = meta.check??{};
365
366
  const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
366
367
  if (!baseline && !plan) return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
368
+ if(webglFailure&&!baseline&&!plan?.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return {ok:false,command:'check',why:'--webgl-failure requires UI steps or assertions'};
367
369
  const captureWarnings=[];
368
370
  const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
369
371
  let browser;
@@ -382,6 +384,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
382
384
  const page=await context.newPage();
383
385
  answerDialogs(page,name,dialogs,report);
384
386
  await page.evaluateOnNewDocument(observeEffects);
387
+ if(webglFailure)await page.evaluateOnNewDocument(forceWebglFailure);
385
388
  const [width,height]=dimensions[name];
386
389
  await page.setViewport({width,height,isMobile:name!=='wall',hasTouch:name!=='wall'});
387
390
  const params=new URLSearchParams(proof.params??{});params.set('room',room);params.set('role',role);params.set('surface',name==='staff'?'tablet':name==='phone-two'?'phone':name);
@@ -423,7 +426,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
423
426
  const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
424
427
  if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
425
428
  const ok = !needs || needs.every(n => n.status === 'met');
426
- return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
429
+ return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
427
430
  } catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
428
431
  finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
429
432
  }