@homie-rocks/studio 0.51.2 → 0.51.4

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/CHANGELOG.md CHANGED
@@ -17,6 +17,47 @@ 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.4] - 2026-10-14
21
+
22
+ **Plugin 0.51.4** · Studio events through experimental MCP Events.
23
+
24
+ Your studio’s declared events reach supporting AI clients through experimental MCP polling, SSE streams and signed webhooks.
25
+
26
+ ### Added
27
+
28
+ - Every declared studio feed speaks the Triggers & Events draft: role-filtered `events/list`, cursor/replay polling, SSE push with heartbeat and resume, and authenticated webhook subscribe/refresh/unsubscribe with negotiated TTLs and client-supplied Standard Webhooks secrets.
29
+ - Extension and configured webhooks share the durable delivery engine. Owners can see retries and errors; rotation dual-signs for five minutes. Existing resource subscriptions and cursor tools keep working.
30
+ - Servers, app and office guides explain subscription setup and Cloudflare's own Workers Issues events through the person's existing Cloudflare connection.
31
+
32
+ ### Upgrade notes
33
+
34
+ - Experimental wire format tracks the working-group draft as inspected on 2026-10-14. Declare exact trusted HTTPS callback URLs in each feed's `receivers` array for extension webhooks. Workers fetch cannot pin DNS with original TLS SNI, so arbitrary callbacks are refused; the guide documents this draft limitation. Poll and push need no callback setup.
35
+
36
+ ## [0.51.3] - 2026-10-14
37
+
38
+ **Plugin 0.51.3**
39
+
40
+ Business and cause apps use their own words, count every paid monthly gift, and test real Stripe checkout locally.
41
+
42
+ ### Added
43
+
44
+ - Local real Stripe test checkout: `shop connect --local --profile <sandbox> --url <Wi-Fi address>` syncs test Payment Links without Cloudflare or a public webhook. Stripe CLI forwards signed events to `dev`; phones return to the same Wi-Fi thank-you page.
45
+ - App acceptance needs can name a checkout popup, interact with it, and continue asserting the original phone.
46
+
47
+ ### Fixed
48
+
49
+ - Monthly subscription invoices create deduplicated paid orders and app events with the original reference, so every gift reaches fulfilment.
50
+ - Business/cause menus name their own pages and apps. App shells use app names, website buttons have independent wording, thank-you pages name the organisation, and default phone sharing stays above bottom tabs.
51
+ - Gathering is a task check-in starter with its own records and no giving or quiz workflow. App style defaults use illustration and AI refinements retain AI attribution.
52
+ - `dev` loads root `.dev.vars`. App collections no longer stop accepting records at 1,000.
53
+ - Requests and scheduled runs remove referral metadata recreated by an old Worker during a rolling deployment.
54
+ - The installed app reference documents the supported local payment/checker path and distinguishes local MCP tools from hosted connector tools.
55
+
56
+ ### Upgrade notes
57
+
58
+ - Restart local dev after updating `.dev.vars`. Local Stripe links are test-only; live connection and deployment are unchanged.
59
+ - Existing app `screen.share` choices and authored site headers remain in control. Subscription renewals now emit `order.paid` as well as lifecycle events; fulfilment listening to both should deduplicate by payment identity.
60
+
20
61
  ## [0.51.2] - 2026-10-14
21
62
 
22
63
  **Plugin 0.51.2** · Deploying and listing do what the studio chose, under one address.
@@ -39,14 +39,8 @@
39
39
  "many": "people",
40
40
  "action": "Check in",
41
41
  "total": "Check-ins",
42
- "choice": "Choose an option",
43
- "monthly": "Gift frequency",
44
- "once": "One time",
45
- "month": "Monthly",
46
- "give": "Give securely on Stripe",
47
- "goal": "Intention goal \u00b7 not payments received",
42
+ "choice": "Choose a task",
48
43
  "prompt": "Choose what works for you.",
49
- "finished": "All three answers are in. Thank you!",
50
44
  "complete": "Mark complete",
51
45
  "done": "Complete",
52
46
  "pending": "Checked in",
@@ -68,14 +62,6 @@
68
62
  "type": "string",
69
63
  "required": true,
70
64
  "maxLength": 60
71
- },
72
- "amount": {
73
- "type": "number",
74
- "required": true
75
- },
76
- "monthly": {
77
- "type": "boolean",
78
- "required": true
79
65
  }
80
66
  }
81
67
  }
@@ -129,34 +115,6 @@
129
115
  "Packing",
130
116
  "Deliveries"
131
117
  ],
132
- "color": "#184b43",
133
- "currency": "CAD",
134
- "goal": 2500,
135
- "questions": [
136
- {
137
- "text": "Which planet is called the red planet?",
138
- "choices": [
139
- "Venus",
140
- "Mars",
141
- "Jupiter"
142
- ]
143
- },
144
- {
145
- "text": "How many sides does a hexagon have?",
146
- "choices": [
147
- "5",
148
- "6",
149
- "8"
150
- ]
151
- },
152
- {
153
- "text": "Which is a mammal?",
154
- "choices": [
155
- "Dolphin",
156
- "Trout",
157
- "Octopus"
158
- ]
159
- }
160
- ]
118
+ "color": "#184b43"
161
119
  }
162
120
  }
@@ -1,3 +1,3 @@
1
1
  <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
2
2
  *{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:58px}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
3
- </style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><div id="goal"><p id="goal-label"></p><div><span id="amount"></span> / <span id="target"></span></div><progress></progress></div><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><label id="frequency"><span id="frequency-label"></span><select id="monthly"><option id="once" value="once"></option><option id="month" value="month"></option></select></label><button data-action></button><a id="giving-link" href="/shop/" target="_blank" rel="noopener"></a></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
3
+ </style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><button data-action></button></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
@@ -3,7 +3,7 @@ import { openRoom } from '@homie-rocks/studio/rules/view';
3
3
  import type rules from './rules';
4
4
  import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
5
5
  import { randomId } from '@homie-rocks/studio/links';
6
- type Entry = { choice: string; status: string; amount: number; monthly: boolean };
6
+ type Entry = { choice: string; status: string };
7
7
  const room = openRoom<typeof rules>();
8
8
  const net = room.net;
9
9
  const role = net.params.role ?? 'customer';
@@ -11,41 +11,29 @@ const words: Record<string, string> = app.words;
11
11
  const config = app.starter;
12
12
  const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
13
13
  const records = createAppRecords<Entry>({ collection: 'entries' });
14
- let rows: AppRecord<Entry>[] = [], busy = false, ready = false, question = 0;
14
+ let rows: AppRecord<Entry>[] = [], busy = false, ready = false;
15
15
  const action = $<HTMLButtonElement>('[data-action]');
16
16
  document.body.className = role;
17
17
  document.body.style.setProperty('--tone', config.color);
18
18
  $('h1').textContent = app.name; $('#intro').textContent = app.blurb;
19
19
  $('#label').textContent = words.total; action.textContent = words.action;
20
20
  $('#choice-label').textContent = words.choice;
21
- $('#frequency-label').textContent = words.monthly;
22
- $('#once').textContent = words.once; $('#month').textContent = words.month;
23
- $('#giving-link').textContent = words.give;
24
- $('#giving-link').hidden = config.kind !== 'giving';
25
- $('#frequency').hidden = config.kind !== 'giving';
26
- $('#goal').hidden = config.kind !== 'giving';
27
21
  $('#controls').hidden = role === 'wall';
28
22
  const select = $<HTMLSelectElement>('#choice');
29
23
  function choices() {
30
24
  select.replaceChildren();
31
- const list = config.kind === 'quiz' ? config.questions[question]?.choices ?? [] : config.choices;
25
+ const list = config.choices;
32
26
  for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
33
- $('#prompt').textContent = config.kind === 'quiz' ? config.questions[question]?.text ?? words.finished : words.prompt;
34
- action.hidden = config.kind === 'quiz' && question >= config.questions.length;
27
+ $('#prompt').textContent = words.prompt;
35
28
  }
36
29
  choices();
37
30
  function paint() {
38
31
  $('[data-count]').textContent = String(rows.length);
39
- const amount = rows.reduce((n, r) => n + r.data.amount, 0);
40
- $('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
41
- $('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
42
- $<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
43
- $('#goal-label').textContent = words.goal;
44
32
  action.disabled = busy || !ready;
45
33
  $('#entries').replaceChildren();
46
34
  for (const row of rows.slice(-6).reverse()) {
47
35
  const li = document.createElement('li');
48
- li.textContent = `${row.data.choice} · ${row.data.status}${row.data.monthly ? ' · ' + words.month : ''}`;
36
+ li.textContent = `${row.data.choice} · ${row.data.status}`;
49
37
  if (role === 'staff' && row.data.status !== words.done) {
50
38
  const button = document.createElement('button'); button.textContent = words.complete;
51
39
  button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
@@ -61,9 +49,8 @@ action.onclick = async () => {
61
49
  busy = true; paint();
62
50
  try {
63
51
  const choice = select.value;
64
- await records.create({ choice: config.kind === 'quiz' ? `${question + 1}. ${choice}` : choice, status: words.pending, amount: config.kind === 'giving' ? Number(choice) : 0, monthly: $<HTMLSelectElement>('#monthly').value === 'month' }, randomId());
52
+ await records.create({ choice, status: words.pending }, randomId());
65
53
  $('#status').textContent = words.thanks;
66
- if (config.kind === 'quiz') { question++; choices(); }
67
54
  room.command('refresh', {}); await refresh();
68
55
  } catch (error) { $('#status').textContent = (error as Error).message; }
69
56
  finally { busy = false; paint(); }
package/apps/APPS.md CHANGED
@@ -18,7 +18,7 @@ await queue.remove(rows[0]);
18
18
  queue.dispose();
19
19
  ```
20
20
 
21
- The shell handles requests only from its own frame and uses its own role, link and account, never authority claimed by the message. The Worker checks every request. Roles declare `signIn: boolean` and `can: ["read:queue", "create:queue", "update:queue", "delete:queue"]`. Collections declare fields with `type` (string/number/boolean), optional `required`, `maxLength`, `enum`, and `initial` (the only value allowed on create). Unknown fields are refused. Records are at most 8 KB, collection reads at most 1,000 records, request bodies at most 12 KB. A full collection refuses creates. This is a small operational helper, not an unbounded ledger.
21
+ The shell handles requests only from its own frame and uses its own role, link and account, never authority claimed by the message. The Worker checks every request. Roles declare `signIn: boolean` and `can: ["read:queue", "create:queue", "update:queue", "delete:queue"]`. Collections declare fields with `type` (string/number/boolean), optional `required`, `maxLength`, `enum`, and `initial` (the only value allowed on create). Unknown fields are refused. Records are at most 8 KB and request bodies at most 12 KB. Homie imposes no collection count limit; the studio’s database capacity applies.
22
22
 
23
23
  Generate a fresh ID for every new record; never reuse a deleted ID. Updates and deletes compare `version` atomically. Conflicts return 409; reload and resolve them. A successful write is committed before acknowledgement. Records are scoped by app and collection, not by room. Deleting a room does not delete records. With `records.persist: false`, use netplay keyed state for transient data instead. Database failures are visible and never replaced with fabricated local success.
24
24
 
@@ -127,3 +127,57 @@ Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
127
127
  Records-only apps support the same shop bridge as room apps. In the sandboxed
128
128
  view, use `createShop({target: parent})` to send checkout and booking requests to
129
129
  the authenticated outer shell; a room simulation is not required for payments.
130
+
131
+ ## Real Stripe test checkout on this computer
132
+
133
+ Use the installed toolkit's app guide and this reference for its exact version.
134
+ The local toolkit MCP exposes `parts_find`; the hosted directory connector may not.
135
+ With a shell, `homie-studio parts find "giving"` is the supported equivalent. Read a
136
+ part's contract: a pledge-only example is not payment fulfilment. The giving starter
137
+ uses the shop bridge; `shop.catalog()` is available in this version. Field names
138
+ start with a lowercase letter and continue with lowercase letters, digits or `_`.
139
+
140
+ No public HTTPS address or Cloudflare account is needed for local Stripe test
141
+ payments. Use the owner's existing Stripe CLI sandbox profile; log in only when
142
+ that profile is unavailable or expired. Never create an imitation checkout.
143
+
144
+ 1. Create the app and `shop init --giving --currency CAD` (choose the actual currency).
145
+ 2. Choose the computer's Wi-Fi address and a fixed dev port. Run
146
+ `homie-studio shop connect --local --profile <sandbox-profile> --url http://192.168.1.8:8787`.
147
+ This syncs real test Products, Prices and Payment Links through Stripe CLI and
148
+ saves local bindings in the project's `.dev.vars`; it does not deploy or create
149
+ a public webhook. Local products use a separate identity from deployed products.
150
+ 3. Run `stripe --project-name <sandbox-profile> listen --all-snapshot --forward-to http://localhost:8787/api/shop/hook`.
151
+ Put its signing secret in root `.dev.vars` as `STRIPE_WEBHOOK_SECRET=whsec_…`.
152
+ Keep that listener running. Never commit the secrets file or print its contents.
153
+ 4. Run `homie-studio dev --lan --port 8787`. Dev loads root `.dev.vars` beside its
154
+ generated Wrangler configuration. Restart after changing bindings. If dev reports
155
+ another port, update the listener and repeat local shop connection with that port.
156
+ 5. Open **the same Wi-Fi address** on desktop and phones. Checkout returns there,
157
+ so its guest cookie and thank-you page work on both. Localhost is only suitable
158
+ when every browser is on this computer. Use Stripe's test card; verify the signed
159
+ webhook, paid order and app record before reporting success.
160
+
161
+ The first Checkout emits `order.paid`. Later paid subscription-cycle invoices each
162
+ create a distinct paid order and emit `order.paid` with the original app and reference.
163
+ Invoice retries do not count again; the initial invoice does not duplicate Checkout.
164
+ Keep fulfilment idempotent by order/event ID. Subscription lifecycle events remain
165
+ available separately. Failed invoices never create paid gifts.
166
+
167
+ `check` uses isolated records on localhost by default; `--no-isolate` opts into the
168
+ existing records. Stripe webhooks use the ordinary local database, so for a real
169
+ payment acceptance run use a disposable studio and `check --no-isolate`. Test money
170
+ must not be mistaken for live giving. Named staff/tablet/kiosk surfaces and roles
171
+ are supported; only the surfaces named by each need are asserted.
172
+
173
+ For a Give button opening a new tab, name its popup in the click step:
174
+
175
+ ```json
176
+ {"surface":"phone","selector":"[data-give]","click":true,"popup":"checkout"}
177
+ ```
178
+
179
+ Later steps and assertions can use `"surface":"checkout"` for the provider page.
180
+ The phone remains its own surface; assert its confirmed gift after checkout. The
181
+ checker brings each page forward before reading or capturing it. Use the real
182
+ provider's controls, never a test-only app action. App sharing defaults to the top
183
+ right, clear of bottom tabs; `screen.share` remains an author-controlled override.
@@ -846,7 +846,7 @@ const OUTWARD_FLAGS = {
846
846
  'chat remove': ['url', 'all'], 'chat budget': ['url'],
847
847
  'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
848
848
  'parts reissue': ['claim-hash', 'receipt-verified', 'buyer'], 'parts retire': [], 'parts keys': [], 'parts refund': ['game'],
849
- 'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'],
849
+ 'shop connect': ['local', 'profile', 'url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'],
850
850
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
851
851
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
852
852
  'events webhook': ['url','after'],
@@ -1115,6 +1115,10 @@ async function main() {
1115
1115
  if (!sub) return shopStatus(root, { url });
1116
1116
  if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), giving: flags.has('giving'), currency: flags.get('currency') ?? null, price: flags.get('price') ?? 500, managed: flags.has('managed') });
1117
1117
  if (sub === 'check') return shopCheck(root);
1118
+ if (sub === 'connect' && flags.has('local')) {
1119
+ if(flags.has('live'))throw new Error('Local checkout requires test mode');
1120
+ return (await import('../lib/shop-local.mjs')).shopConnectLocal(root,{url:flags.get('url'),profile:flags.get('profile')});
1121
+ }
1118
1122
  if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1119
1123
  if (sub === 'disconnect') return shopDisconnect(root);
1120
1124
  if (sub === 'catalog') {
@@ -0,0 +1,68 @@
1
+ # Experimental MCP Events
2
+
3
+ Studio MCP implements the [Triggers & Events working group's design sketch](https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/13e0a59749cde947374aaa8e3a65f0b424468d5a/docs/design-sketch-proposal.md) as inspected on 2026-10-14. This is an experimental draft, not an adopted MCP standard. Method names, envelopes, schemas, metadata keys and provisional error codes live in `worker/mcp-events-wire.mjs` so draft changes have one wire-format boundary. The studio's existing `resources/subscribe`, event resources and cursor-read tool continue to work.
4
+
5
+ Declare feeds in `studio.json` under `mcp.events`. Each map key is the extension's event `name`. For example:
6
+
7
+ ```json
8
+ {
9
+ "mcp": {
10
+ "events": {
11
+ "bookings": {
12
+ "event": "record.changed",
13
+ "app": "cafe",
14
+ "collection": "bookings",
15
+ "audience": { "app": "cafe", "role": "staff" },
16
+ "receivers": ["https://your-ai-receiver.example/hooks/studio"]
17
+ }
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ `receivers` is an owner-controlled list of exact HTTPS callback URLs, without credentials or fragments. Include only endpoints whose operators have agreed to receive events. A feed's existing configured `webhook.url` is also accepted. Rebuild and deploy the declaration through the studio's usual authorized workflow. Poll and push need no receiver declaration. `maxSubscriptions`, when explicitly declared on a feed, limits its active extension webhooks; otherwise there is no subscription quota. Event arguments are currently `{}`: the declared feed already supplies the app, collection and audience filter.
24
+
25
+ ## An owner's AI connects and subscribes
26
+
27
+ 1. Use an MCP Events-capable client and connect to `<studio-site>/mcp/auth`. Approve with the person's existing studio account in the browser. Staff approve their own account. Do not request an office key. Initialization advertises `capabilities.events.listChanged: true`.
28
+ 2. Call `events/list`. It returns only feeds the current account can read, with `delivery: ["poll", "push", "webhook"]`, input and payload schemas. Re-list when the general MCP GET channel receives `notifications/events/list_changed`.
29
+ 3. Choose a delivery mode. These are protocol requests made by the client/SDK, not model-visible tool calls:
30
+ - **Poll:** call `events/poll` with `{ "name": "bookings", "arguments": {}, "cursor": null }`. The first call returns no history and a cursor at now. Persist each returned cursor and pass it on the next poll. Drain `hasMore: true` immediately; otherwise wait `nextPollMs`. Optional `maxEvents` caps a page (server maximum 100); `maxAgeMs` bounds replay.
31
+ - **Push:** POST `events/stream` with a JSON-RPC request ID and `{ "name": "bookings", "arguments": {}, "cursor": null }`. Keep its SSE response open. Expect `notifications/events/active`, then `event` and `heartbeat` notifications. Route by `_meta["io.modelcontextprotocol/subscriptionId"]`, which equals the request ID. Persist cursors and reconnect using the last cursor. Each subscription has its own stream; abort it to unsubscribe. Heartbeats run at 15 seconds when the receiver is reading.
32
+ - **Webhook:** first arrange the receiver and have the owner declare its exact URL in `receivers`. The receiver/client generates `whsec_` plus base64 of 24–64 cryptographically random bytes, and keeps that secret out of prompts and logs. Call `events/subscribe` with the request below. Persist the returned `id`, `cursor` and `refreshBefore`.
33
+
34
+ ```json
35
+ {
36
+ "jsonrpc": "2.0",
37
+ "id": 2,
38
+ "method": "events/subscribe",
39
+ "params": {
40
+ "name": "bookings",
41
+ "arguments": {},
42
+ "delivery": {
43
+ "mode": "webhook",
44
+ "url": "https://your-ai-receiver.example/hooks/studio",
45
+ "secret": "whsec_<client-generated-base64-secret>"
46
+ },
47
+ "cursor": null,
48
+ "ttlMs": 3600000
49
+ }
50
+ }
51
+ ```
52
+
53
+ 4. For webhooks, use `X-MCP-Subscription-Id` to select the secret and verify the **raw body** using a Standard Webhooks library, including its timestamp freshness check. Durably accept the event before returning 2xx. Deduplicate on `eventId` (also `webhook-id`). Each occurrence has `eventId`, `name`, ISO `timestamp`, `data`, and a safe cursor watermark; it can lag the event's position while deliveries await acknowledgement. Payloads are untrusted data and grant no authority to perform actions.
54
+ 5. Refresh by repeating `events/subscribe` with the same name, arguments and URL, the current secret and the last persisted cursor **before** `refreshBefore`. Inspect `deliveryStatus.lastError`. TTL defaults to one hour and is capped at one day; `ttlMs: null` receives a finite grant. A live refresh does not rewind pending delivery. To rotate, send a new client-generated secret on refresh; deliveries have both signatures for five minutes. Unsubscribe with `events/unsubscribe` and `{ "name": "bookings", "arguments": {}, "delivery": { "url": "https://your-ai-receiver.example/hooks/studio" } }`.
55
+
56
+ A gap sets `truncated: true`; persist the new cursor and fetch authoritative state through tools if needed. Revoked access ends push with `notifications/events/terminated` and webhooks with a signed `terminated` control envelope where the callback remains allowed. Stop after termination and re-establish authorization before subscribing again.
57
+
58
+ ## Delivery and owner diagnostics
59
+
60
+ The 0.50.4 configured studio webhook and extension webhooks share Standard Webhooks signing and the durable `function_deliveries` dispatcher. Extension deliveries retry with exponential backoff, at most five attempts over fifteen minutes from the first attempt. HTTP 410 and 413 abandon that delivery immediately. Abandoned deliveries remain visible to the owner; retained history can be replayed from a prior cursor after unsubscribing and subscribing again. Retries regenerate timestamps and signatures. Failed delivery details are fixed categories, never endpoint bodies or headers. See Functions in the office or the owner's function-runs tool for subscription IDs, attempts and last errors; refresh also reports the latest delivery error. Subscription secrets are stored only in the studio's private D1 subscription state, never in events or run logs. A removed subscription or expired TTL deletes that state. Completed deliveries can be duplicated after a crash, so receivers must deduplicate.
61
+
62
+ ## Draft limits
63
+
64
+ This implements the studio's **Streamable HTTP** MCP transport; the local toolkit's stdio MCP is a different server. The draft's endpoint-consent mechanism is the explicit server-configured allowlist, not a challenge handshake. Arbitrary client-supplied callback URLs are refused with `CallbackEndpointError / challenge_failed`. Workers `fetch` does not provide DNS address pinning with original Host/TLS SNI, so this implementation cannot follow that draft MUST literally. Exact owner-approved URLs, rechecked at every attempt, and disabled redirects constrain the destination; this is not a claim of draft-complete DNS-rebinding protection. Only approve trusted receiver operators. No no-expiry grants or asymmetric server signatures are offered. Durable queue positions survive Worker restarts, extending the sketch's ephemeral in-flight bookkeeping while preserving client-owned replay cursors. A webhook's conservative safe watermark may lag the event being delivered by one event.
65
+
66
+ ## Production failures from Cloudflare
67
+
68
+ An owner's AI can also subscribe through the person's **existing Cloudflare connection** to Cloudflare's own API MCP at `https://mcp.cloudflare.com/mcp`: discover `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, then use `events/subscribe` with that descriptor's arguments scoped to the studio's Worker and the client's webhook. Enable Workers Issues on that Worker and grant **Notifications Write** and **Workers Observability Write**. Cloudflare posts signed events for new or recurring production issues, so reliability problems reach the AI without polling. Homie hosts nothing for this connection. See [Cloudflare's 2026-10-13 announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -6,6 +6,12 @@ compatibility: Node 22 and Chrome; the studio's pinned toolkit and Wrangler.
6
6
 
7
7
  # Make an app
8
8
 
9
+ For the installed version’s payment, records and local-check contracts, start with
10
+ `node_modules/@homie-rocks/studio/apps/APPS.md`. Its “Real Stripe test checkout on
11
+ this computer” section is the supported offline-hosting path: real Stripe test
12
+ checkout, CLI webhook forwarding, root `.dev.vars`, Wi-Fi return pages and named
13
+ checkout popups. Use an existing approved sandbox profile; no deploy is needed.
14
+
9
15
  An app is **one screen, its roles, and its parts**. The screen transforms with the person and the work: a camera moves, a ticket unfolds, controls appear in context. Do not build page-to-page navigation, a sidebar of links, or a conventional admin website inside it. A studio's public website can still have pages around the app.
10
16
 
11
17
  Read the person's brief and existing `apps/<id>/` first. Choose sensible engineering defaults from the brief; never ask the person how to implement shared state. A business or cause needs no game demo, mandatory entertainment, invented customers, scores or rounds. Never impose a shop, directory listing, sign-in for public visitors, or paid media. Make the fewest human steps possible; run commands yourself.
@@ -38,7 +44,7 @@ Before writing scene components:
38
44
 
39
45
  1. Run `style init <id> --prompt "<the person's own scene words>"`. Record the direction in the Codex: composition, camera, materials, colour, typography and motion. Use the same style machinery as games, not the starter's default theme.
40
46
  2. Inspect the installed `@homie-rocks/*` packages for mechanisms. `geom`, `props`, `render`, `camera`, `postfx` and `fx` can make the place; `input`, `ui`, `ui-world`, `device`, `audio` and `loop` make it usable. Read the package exports and examples rather than guessing imports. Install every dependency reported by the starter or parts before building.
41
- 3. Search `parts_find` for each meaningful piece; shell equivalent: `homie-studio parts find "<piece>"`. Read its contract, source studio and licence. Add fitting pieces with `part_add` / `parts add`; write the missing parts yourself. The example studios share real app pieces: search for venue, workshop, giving, arrivals, scene, queue and sound, not just game characters. If local example studios are available, read their shared `parts/` and manifests without modifying the source studios. A failed catalogue request is not proof that nothing exists.
47
+ 3. Search the local toolkit MCP `parts_find` for each meaningful piece (the hosted connector may not expose it); shell equivalent: `homie-studio parts find "<piece>"`. Read its contract, source studio and licence. Add fitting pieces with `part_add` / `parts add`; write the missing parts yourself. The example studios share real app pieces: search for venue, workshop, giving, arrivals, scene, queue and sound, not just game characters. If local example studios are available, read their shared `parts/` and manifests without modifying the source studios. A failed catalogue request is not proof that nothing exists.
42
48
  4. Use the art/models/sound/music skills with connected providers and an authorized budget. Generate distinctive assets when useful. If no provider is connected, continue with original geometry, locally drawn SVG, CC0 assets and local synthesized sound. Report the means honestly. Provider absence never justifies replacing the scene with a board.
43
49
  5. Record packages, parts with attribution, original work and media routes under **Built from**. Keep costs and provenance visible.
44
50
 
@@ -124,7 +130,7 @@ Without Stripe, keep real services and prices visible and say payment is not con
124
130
  - `apps/<id>/app.json` describes roles (`signIn`, `can`), surfaces (`phone`, `wall`, `kiosk`, `tablet`), wording, record collections, parts and the real UI action used by check. The app's entry and assets live beside it, just like a game.
125
131
  - Before inventing each component, inspect engine packages for the mechanism and `parts_find` for the piece. A queue, quiz, menu board, live total, music loop or video intro can be a part. Record its licence and credit. Sharing happens only when requested; selling parts is not part of this workflow.
126
132
  - Use the same engine packages as a game: `@homie-rocks/geom` and `props` for original scenery; `render`, `camera`, `postfx`, `fx` for light, composition and motion; `input`, `ui`, `ui-world`, `audio`, `device`, `loop` for interaction and feel. An app can be fully 3D and wild. Legibility and the actual task come first.
127
- - Use the `style`, `art`, `models`, `animate`, `sound`, `music`, `video` and `perf` skills where they help. It should look like a game studio made it: a coherent world with a useful HUD, not generic cards and navigation. Media cost and licensing decisions remain the person's.
133
+ - Use the `style`, `art`, `models`, `animate`, `sound`, `music`, `video` and `perf` skills where they help. Give the organisation a coherent scene with readable controls and purposeful motion. Media cost and licensing decisions remain the person's.
128
134
  - Keep native buttons at least 44 px, keyboard focus visible, text as text, motion reducible, and the wall QR clear of useful information. Pointer gesture guards belong on the canvas, not native click buttons.
129
135
 
130
136
  ## Roles and records
@@ -282,3 +288,27 @@ Upgrade compares copied app presets with recorded toolkit versions. Unchanged
282
288
  preset files and default values take the new version; custom scene files, names and
283
289
  edited labels remain the studio’s. An app wall uses its join label rather than game
284
290
  instructions. Rebuild after upgrade and inspect both the wall and giving page.
291
+
292
+ ## Experimental MCP Events
293
+
294
+ For an owner's AI that should hear a studio's declared events, read
295
+ `node_modules/@homie-rocks/studio/docs/mcp-events.md`. Connect the client to
296
+ `<site>/mcp/auth` through the person's existing studio approval, discover feeds
297
+ with `events/list`, and use `events/poll`, an SSE `events/stream`, or
298
+ `events/subscribe` with a client-generated `whsec_` secret and an exact
299
+ owner-declared `mcp.events.<feed>.receivers` URL. Refresh before `refreshBefore`,
300
+ persist cursors, verify Standard Webhooks signatures, deduplicate event IDs, and
301
+ use `events/unsubscribe` to stop a webhook. The feeds keep their current roles;
302
+ resources/subscribe and the cursor tool still work. This follows the experimental
303
+ Triggers & Events working-group draft, with the Workers DNS-pinning limitation
304
+ and receiver allowlist explained in that guide. Inspect Functions in the office
305
+ and refresh's `deliveryStatus` for failed deliveries; never put secrets in logs.
306
+
307
+ For production failures, the owner's AI can also use the person's existing
308
+ Cloudflare connection to `https://mcp.cloudflare.com/mcp`, discover
309
+ `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, and
310
+ subscribe with `events/subscribe` scoped to the studio's Worker. Enable Workers
311
+ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
312
+ posts signed events to the client's webhook so new or recurring reliability
313
+ problems reach the AI without polling; Homie hosts nothing for this. See
314
+ [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -156,3 +156,27 @@ in and continues there. A plain link-preview GET spends nothing; the loaded page
156
156
  submits the same-origin sign-in form. Keep the link private.
157
157
  Local office and role commands reuse the running dev server’s D1 configuration even
158
158
  after deploy records the production database id.
159
+
160
+ ## Experimental MCP Events
161
+
162
+ For an owner's AI that should hear a studio's declared events, read
163
+ `node_modules/@homie-rocks/studio/docs/mcp-events.md`. Connect the client to
164
+ `<site>/mcp/auth` through the person's existing studio approval, discover feeds
165
+ with `events/list`, and use `events/poll`, an SSE `events/stream`, or
166
+ `events/subscribe` with a client-generated `whsec_` secret and an exact
167
+ owner-declared `mcp.events.<feed>.receivers` URL. Refresh before `refreshBefore`,
168
+ persist cursors, verify Standard Webhooks signatures, deduplicate event IDs, and
169
+ use `events/unsubscribe` to stop a webhook. The feeds keep their current roles;
170
+ resources/subscribe and the cursor tool still work. This follows the experimental
171
+ Triggers & Events working-group draft, with the Workers DNS-pinning limitation
172
+ and receiver allowlist explained in that guide. Inspect Functions in the office
173
+ and refresh's `deliveryStatus` for failed deliveries; never put secrets in logs.
174
+
175
+ For production failures, the owner's AI can also use the person's existing
176
+ Cloudflare connection to `https://mcp.cloudflare.com/mcp`, discover
177
+ `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, and
178
+ subscribe with `events/subscribe` scoped to the studio's Worker. Enable Workers
179
+ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
180
+ posts signed events to the client's webhook so new or recurring reliability
181
+ problems reach the AI without polling; Homie hosts nothing for this. See
182
+ [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -157,3 +157,27 @@ For local function and tool proof, run `dev` and use its localhost URL. The priv
157
157
  dev credential permits headless owner tool calls; production still uses OAuth.
158
158
  App need sessions are scoped to the declared role and fresh records. See the app
159
159
  and functions guides for tablet/kiosk proof and atomic function storage.
160
+
161
+ ## Experimental MCP Events
162
+
163
+ For an owner's AI that should hear a studio's declared events, read
164
+ `node_modules/@homie-rocks/studio/docs/mcp-events.md`. Connect the client to
165
+ `<site>/mcp/auth` through the person's existing studio approval, discover feeds
166
+ with `events/list`, and use `events/poll`, an SSE `events/stream`, or
167
+ `events/subscribe` with a client-generated `whsec_` secret and an exact
168
+ owner-declared `mcp.events.<feed>.receivers` URL. Refresh before `refreshBefore`,
169
+ persist cursors, verify Standard Webhooks signatures, deduplicate event IDs, and
170
+ use `events/unsubscribe` to stop a webhook. The feeds keep their current roles;
171
+ resources/subscribe and the cursor tool still work. This follows the experimental
172
+ Triggers & Events working-group draft, with the Workers DNS-pinning limitation
173
+ and receiver allowlist explained in that guide. Inspect Functions in the office
174
+ and refresh's `deliveryStatus` for failed deliveries; never put secrets in logs.
175
+
176
+ For production failures, the owner's AI can also use the person's existing
177
+ Cloudflare connection to `https://mcp.cloudflare.com/mcp`, discover
178
+ `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, and
179
+ subscribe with `events/subscribe` scoped to the studio's Worker. Enable Workers
180
+ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
181
+ posts signed events to the client's webhook so new or recurring reliability
182
+ problems reach the AI without polling; Homie hosts nothing for this. See
183
+ [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -173,3 +173,11 @@ CLI fixture/API tokens; never enter card digits into a page. The local listener
173
173
  for development; production sends signed events directly to the studio's webhook.
174
174
  The optional keyed fixture remains available for testing explicitly selected session
175
175
  checkout. Label offline fixtures simulations.
176
+
177
+ ## Local Stripe test payments
178
+
179
+ Use `shop connect --local --profile <sandbox-profile> --url http://<Wi-Fi-IP>:8787`
180
+ and Stripe CLI `listen --all-snapshot --forward-to http://localhost:8787/api/shop/hook`. The full
181
+ recipe is in the installed toolkit’s `apps/APPS.md`, “Real Stripe test checkout on
182
+ this computer”. It uses root `.dev.vars`, needs no public HTTPS or Cloudflare, and
183
+ keeps the existing approved Stripe CLI session. Do not write a stand-in checkout.
package/lib/app-needs.mjs CHANGED
@@ -24,6 +24,8 @@ export function readAppNeeds(root, id) {
24
24
  /** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
25
25
  export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {} }) {
26
26
  const results = [];
27
+ const appBarrier=ready;
28
+ ready=async s=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s);};
27
29
  let activeRole;
28
30
  const surface = name => { const s = surfaces.find(s => s.name === name && (!activeRole || s.role === activeRole)) ?? surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
29
31
  for (const need of plan.needs) {
@@ -33,7 +35,8 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
33
35
  const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
34
36
  try {
35
37
  if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires assertions on its named surfaces');
36
- for (const name of names) { const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
38
+ const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
39
+ for (const name of names) { if(popupNames.has(name))continue; const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
37
40
  for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
38
41
  for (const step of need.steps ?? []) {
39
42
  const target=surface(step.surface);
@@ -43,7 +46,19 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
43
46
  if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); continue; }
44
47
  await waitInApp(target,step.selector,f=>f.waitForSelector(step.selector, { visible: true, timeout:10000 }),{ready});
45
48
  const frame=target.frame;
46
- if (step.click) await frame.click(step.selector);
49
+ if (step.click && step.popup) {
50
+ let listener,timer;
51
+ const opened=new Promise((resolve,reject)=>{
52
+ listener=popup=>resolve(popup);page.once('popup',listener);
53
+ timer=setTimeout(()=>reject(new Error('payment popup did not open')),10000);
54
+ });
55
+ try {
56
+ const [popup]=await Promise.all([opened,frame.click(step.selector)]);
57
+ surfaces.push({name:step.popup,page:popup,frame:popup.mainFrame(),external:true,role:target.role});
58
+ await page.bringToFront();
59
+ } finally {clearTimeout(timer);page.off('popup',listener);}
60
+ }
61
+ else if (step.click) {await frame.click(step.selector);await page.bringToFront?.();}
47
62
  else if (step.fill !== undefined) { await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }
48
63
  else if (step.select !== undefined) await frame.select(step.selector, String(step.select));
49
64
  else throw new Error('step needs click, fill, select or reload');
package/lib/app-ready.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  /** One arrival barrier for baseline proof, needs reloads and rebuilt frames. */
2
2
  export async function appReady(surface, {timeout=60000}={}) {
3
+ await surface.page.bringToFront?.();
3
4
  const end=Date.now()+timeout;
4
5
  let error;
5
6
  do {
package/lib/art-cli.mjs CHANGED
@@ -151,13 +151,13 @@ export async function styleCommand(root, sub, positional, flags, { log = () => {
151
151
  } else if (verb === 'set') {
152
152
  if (!args[1] || args[2] === undefined) return { ok: false, command: 'style set', why: `usage: homie-studio style set <id> <decision> <value> (decisions: ${CATALOGUE_IDS.slice(0, 10).join(', ')}, …)` };
153
153
  r = setDecision(root, id, args[1], args.slice(2).join(' '), { by: flags.get('by') === 'person' ? 'person' : 'ai', why: flags.get('why') ?? null, words: flags.get('words') ?? null, unlock: flags.has('unlock'), reason: flags.get('reason') ?? null, confirm: flags.has('confirm'), manifest: manifest() });
154
- } else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest() });
154
+ } else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
155
155
  else if (verb === 'lock') r = lockDecision(root, id, args[1] ?? 'style', { by: 'person', words: flags.get('words') ?? null });
156
156
  else if (verb === 'unlock') r = unlockDecision(root, id, args[1], { reason: flags.get('reason') ?? null, by: 'person' });
157
157
  else if (verb === 'board') { const { styleBoard } = await import('./style-board.mjs'); r = await styleBoard(root, id, { prompt: String(flags.get('prompt') ?? ''), log, library: !flags.has('no-library') }); }
158
158
  else if (verb === 'pick') {
159
159
  const mixes = Object.fromEntries(list(flags.get('mix')).map((x) => x.split('=')).filter(([k, v]) => k && v));
160
- r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest() });
160
+ r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
161
161
  } else if (verb === 'mood') { const { recordMood } = await import('./style-board.mjs'); r = await recordMood(root, id, args[1], resolve(String(flags.get('image') ?? '')), { usd: flags.get('usd') ?? null, receipt: flags.get('receipt') ?? null, model: flags.get('model') ?? null }); }
162
162
  else if (verb === 'golden') {
163
163
  if (args[1] === 'add') { const { addGolden } = await import('./style-board.mjs'); r = await addGolden(root, id, resolve(String(args[2] ?? flags.get('image') ?? '')), { from: flags.get('from') ?? null }); }
package/lib/art-tools.mjs CHANGED
@@ -223,11 +223,11 @@ export function artToolDefs(ctx, h) {
223
223
  const by = a.by === 'person' ? 'person' : 'ai';
224
224
  let r; let action;
225
225
  try {
226
- if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest }); }
226
+ if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest, by }); }
227
227
  else if (!a.decision) return fail('name the decision (decision: "style.palette"), or pick a direction (pick: "b")');
228
228
  else if (a.lock) { action = 'lock'; r = lockDecision(root, game, a.lock === true ? a.decision : String(a.lock), { by: 'person', words: a.words ?? (by === 'person' ? 'tapped Lock' : null) }); }
229
229
  else if (a.unlock && a.value === undefined && a.steer === undefined) { action = 'unlock'; r = unlockDecision(root, game, a.decision, { reason: a.reason ?? null, by: 'person' }); }
230
- else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest }); }
230
+ else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest, by }); }
231
231
  else if (a.value !== undefined) { action = 'set'; r = setDecision(root, game, a.decision, a.value, { by, why: null, words: a.words ?? null, unlock: a.unlock === true, reason: a.reason ?? null, confirm: a.confirm === true, manifest }); }
232
232
  else return fail('say what to do: value, steer, lock, unlock, pick or mix');
233
233
  } catch (error) { return fail(error.message); }
package/lib/check.mjs CHANGED
@@ -387,6 +387,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
387
387
  for(const name of ['wall','phone','phone-two'])await openSurface(name);
388
388
  const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
389
389
  const actor = surfaces[1];
390
+ await appReady(actor);
390
391
  await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
391
392
  before = await waitInApp(actor,proof.observe,textOf);
392
393
  await actor.frame.click(proof.action);
@@ -406,6 +407,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
406
407
  report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
407
408
  }
408
409
  for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
410
+ if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
409
411
  const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
410
412
  if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
411
413
  }