@homie-rocks/studio 0.32.0 → 0.33.0

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 (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,9 +17,92 @@ 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.33.0] - 2026-10-08
21
+
22
+ **Plugin 0.34.0** · [#57](https://github.com/homie-rocks/homie/pull/57)
23
+
24
+ A game's rules can run on your own Cloudflare instead of in a player's browser. This is the first piece of it: the format, the server that runs it, and one example game. Your existing games are not touched.
25
+
26
+ ### Added
27
+
28
+ - Public games can offer X a picture with a play button: its phone apps open the game full screen inside X; its website opens the posted link in a new tab. X decides what it shows, and its written card policy is for audio/video, so this may change. Studio and game switches turn it off.
29
+ - The player joins a public room without sign-in or storage and works as a page of its own and framed; opened as its own page, a reload keeps the room, seat and guest name. Account and purchase links leave a frame, and no purchase starts in the player.
30
+ - The build measures the card's picture and offers the card for any picture X's reference says will render (JPG, PNG, WEBP or GIF, at least 68,600 pixels, under 5 MB), whatever its shape; a refused picture is a warning naming the file and the rule. `site.twitterSite` is optional.
31
+ - Ember Vale's new-hero card fits a short screen: a phone on its side, or a small frame.
32
+ - **A room survives restarts and updates.** Server-hosted games save the whole match, including seats, scores, AI companions and pending decisions. Players reconnect to the same room after a restart; with the default settings, at most one second of play is replayed from the last successful save. If storage is unavailable, play continues and the office reports that recovery may lose more.
33
+ - **Updates keep a running match when its saved state still fits.** Players and watchers load the new game and reclaim their seats. A change to the shape of the saved state starts a fresh match. A game that is slow to load after an update is given up to a minute. A load that fails is tried again a few times, the page says what it is doing while it waits, and it asks you to reload if it has to give up. Older browser-hosted games still update at the round break.
34
+ - **A game written as rules plus view.** The rules (`src/rules.ts`, with the movement code in `src/move.ts`) say what is true in the game: who took which coin, every score, when a round ends. The view (`src/view.ts`) draws what it is told and sends what the player presses. `@homie-rocks/studio/rules` is the format (`defineRules`, `defineMove`, `f` for the type of every piece of state) and `@homie-rocks/studio/rules/view` is what a view uses (`openRoom()`).
35
+ - **The server is the room's host.** The rules of such a game run in the room's own server object on your Cloudflare. No player's browser is in charge, so a changed browser cannot change a score, a pickup or a round, and closing any one tab does not interrupt the others. Seats, chat, votes, watchers and the owner's controls work as before.
36
+ - **Homie checks the rules before they go anywhere.** The build refuses rules that use anything outside a short list of safe operations and names the line (no clock, no network, no `Math.sin` that two machines may round differently, no state outside the declared fields). It then adds a guard, so a rule that never ends or grabs memory is stopped while the room carries on, and a room whose rules keep failing is closed and named in the log.
37
+ - **Every room feature works with the server in charge.** Seats and coming back into the same body, waiting for a seat, watching, chat, votes, your owner controls, servers and their policies, AI seats with a pass, the skill dial, and a game's own decisions (`world.ask`) all behave in a server-hosted room as they do in a browser-hosted one. On a hybrid or beginner server the AI companions keep their reserved seats and their own names, and a guide in such a room follows the goals and lines in your `agents.json`.
38
+ - **`coin-dash`, an example.** Copy it with `homie-studio game new <id> --from coin-dash` to see the two halves. It is an example, not yet a starter the chat offers: see the upgrade notes.
39
+ - **The deploy plan says it.** For each such game: that its rules run on Cloudflare, and roughly what an hour of play in one full room uses of the free plan's daily allowances.
40
+ - **The netplay contract is revision 10** (`netplay/NETPLAY.md`, section 29): the server as a host, input as steps stamped with the room's tick, and the life of such a room. Nothing a game written before sends or receives has changed.
41
+
42
+ ### Changed
43
+
44
+ - **Your shop, your prices.** Studios choose their own shop prices, tips, catalog size, item kinds, durations, refunds and referral terms. Homie no longer sets sales or spending ceilings. Optional spending caps and refund windows belong to the studio; Stripe currency requirements still apply, including currencies without cents.
45
+ - **Your shop policies are editable.** Shop policies are readable and editable presets: protective, adults-only and custom. New shops start protective, including the existing protections for younger players. Existing shops keep their written settings and protective policies unless they choose otherwise. Purchase flood protection now checks both the account and the address, with separate rates the studio can change.
46
+ - **Any name is yours to use in the directory.** The directory no longer keeps a list of Homie's own games' names. A studio, game, song, video or part is listed under whatever plain-text name you gave it, and `studio_request_grant` and `studio_grant_status` are gone from the Homie MCP server.
47
+ - **The toolkit comes from npm only.** homie.rocks no longer serves `homie-studio-<version>.tgz`. A studio pins the npm registry's exact version, as every studio made since 0.10.0 already does.
48
+ - A room of such a game pauses when the last person leaves and ends a minute later if nobody returns. An empty room uses nothing.
49
+ - `homie-studio perf` accepts a room with no browser host.
50
+ - A new studio's `site/src/worker.mjs` hands the Worker the rules of its server-hosted games (`site/src/rules/`, written by every build and committed with the studio), and its `wrangler.jsonc` turns off code made from text while the Worker starts.
51
+
52
+ ### Fixed
53
+
54
+ - A goal, a line, an ask or an argument named after something every JavaScript object has (`constructor`, `toString` and the like) is no longer taken for one of your vocabulary's. Before, an AI guide could be made to "say" such a line, which threw an error in every player's browser, in browser-hosted games too.
55
+ - Only the room's host can speak as a guide. A player or a watcher sending a line marked as the guide's is ignored.
56
+ - An AI pass opened twice keeps one seat: the newer connection takes it, and reconnecting is paced so a copied pass cannot be used to flood a room.
57
+
58
+ ### Upgrade notes
59
+
60
+ - **Check settings you left out of shop.json.** Written settings keep working; no limit is set unless the studio sets one. Omitted settings no longer receive the toolkit's old defaults. To keep those old choices, write `"capPerPlayerMonth": 5000` (monthly spending), `"refundDays": 14` (self-service refunds), `"min": 200, "max": 5000` on each tip, and `"windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500` inside `referrals`. Without them there is no monthly spending cap, no self-service refund window (buyers ask the studio), no tip range beyond provider currency units, no referral window or per-player referral cap, no hold and no minimum invoice. Older `shop init` wrote these amounts explicitly; hand-edited files may have omitted them. All amounts are minor units of your currency.
61
+ - **Write whole amounts.** Fractional minor-unit amounts now stop the build with the setting's name and an example, before publication. For example, replace `"capPerPlayerMonth": 2500.7` with the whole amount you intend, such as `2500`. Every amount setting follows this rule; none is silently rounded. Decimal numeric strings remain accepted; padded strings, hexadecimal strings, lists and booleans now stop the build. Fractional `days` and `refundDays` retain their fraction, rounded to milliseconds, instead of being floored to whole days.
62
+ - **Flood protection allows shared addresses.** `purchaseAttemptsPerMinute` is the account rate (6 by default); `purchaseAttemptsPerAddressPerMinute` is the separate address rate (600 by default, to allow households, schools and venues). Checkout, parent-link and parent-payment attempts have separate counts. Rotating addresses does not bypass the account rate. Referral statements retain a global rate of 30 per minute as well as 30 per address; both are settings in the site configuration; invalid values produce a named error. New guest buyers have a configurable address rate, `guestBuyersPerAddressPerHour`, defaulting to 600 an hour so venues and schools can share a connection; guest buyers and ordinary new players have separate counters.
63
+ - **The withdrawal acknowledgement stays.** Existing checkout and refund-page wording is restored. `policy.withdrawalAcknowledgement` is true in every preset; edit it only as a considered studio policy. Account and younger-player protections remain in the protective preset. `policy.childAge` and `policy.adultAge` default to 13 and 18 and can be edited.
64
+ - **Large shops and referral books use pages.** The shop, in-game sheet and office offer navigation. The office lists referrers with exact totals once each; sales page inside each referrer. `shop statements --send` and the office button send all statements in one action, saving immutable pages and progress in D1. Keep the command or tab open; restart after closing to resume safely. Failed pages retry three times with backoff; persistent failures name the referrer and the rest continue. One CLI office key is renewed during a run. Signed continuation pages contain lines without repeating totals; a new statement replaces the old pages for that period and currency. Mark paid selects one currency and does not change an edition already being sent. Read-only statement views allocate no editions. Sends use Workers-compatible manual redirects and refuse every redirect response.
65
+ - **The optional spending cap retains unresolved payments.** Sessionless rows age out after 32 minutes; sessions past expiry and bank payments still processing count until Stripe confirms payment, expiry or failure. Shop GETs never call Stripe. Only a cap-refused purchase waits for up to three parallel reads with two-second timeouts; successful purchases continue in the background. Atomic claims and per-row intervals (one minute, five for processing payments, growing with age to an hour) bound successful reads; failed reads retry after about one minute and log the order and reason. More than three lost rows can require another attempt. Paid rows use Stripe's charge time; pending, missing payment time or unreachable rows keep counting. Stripe's [resource_missing](https://docs.stripe.com/error-codes#resource-missing) means this key's account has no such session; it frees the reservation and shows the missing row in the office. The office also offers a release recorded with the confirming owner's account (or a fingerprint of the office sign-in session) and time, and asks Stripe to [expire an open Checkout Session](https://docs.stripe.com/api/checkout/sessions/expire). Neither mark prevents a verified payment from granting the item, counting toward spending again and being refundable, even if the freed space has been spent. Internal states and notes stay out of player account data. Late events are idempotent; test rows never count toward live spending. Free orders remain available after an owner lowers a cap. Partial refunds keep the full price counted; full refunds release it. No cap means no spending SUM. Deploy applies `0010_shop_reservations.sql` and `0011_shop_statements.sql`; missing schema reports not ready with the migration names.
66
+ - **Guest checkouts can answer the age question.** A valid answer creates a guest session when the studio permits guests. A refused Stripe checkout returns the guest cookie so retrying uses the same account. Idle guest cleanup keeps owners, including future entitlements, and continues removing other unused guests.
67
+ - Allowed odds and countdown fields that are not published now produce a warning telling the studio to put buyer-facing terms in `blurb`. Three-decimal amounts using the smallest minor unit warn, without an undocumented divisible-by-ten refusal; Stripe's error is shown if it refuses them, with the failure note in the office order row. Free orders can be taken back without contacting Stripe. Stripe settlement minimums remain warnings.
68
+ - A studio made before 0.10.0 pins an address on homie.rocks that no longer answers, so `npm install` in it fails until it is upgraded. Run `npx -y @homie-rocks/studio@latest upgrade --apply`, then `npm install`: the upgrade replaces the address with the registry's exact version. Nothing else about the studio changes.
69
+ - **Nothing to do for the games you have.** A game with no `src/rules.ts` builds and runs exactly as before, in a player's browser. The build says so in one line. Asking for the server on such a game (`"room": { "host": "server" }`) stops the build with the reason.
70
+ - **Do not put a game of your own on this yet.** A server-hosted room has no save in this version: a restart of the room, or any deploy of your site, starts its match again, where today's rooms carry on. That arrives in a later version, and `coin-dash` is offered as a starter only then.
71
+ - In this version a player's own character still moves as today: their browser says where it is and the server holds it to its top speed. A changed browser cannot move faster than that, but it can walk through a wall. Scores and pickups are decided on the server either way.
72
+ - Rules with 3D bodies, rules hosted by a player's browser, offline play of a rules game, and AI guides driven by the server are not in this version.
73
+ - An existing studio gets the new `site/src/worker.mjs` and the empty `site/src/rules/index.mjs` from `homie-studio upgrade --apply` (a Worker file you changed yourself is kept, and the upgrade shows the three lines to add). The next deploy writes the new `wrangler.jsonc`.
74
+ - This version has been run on a local Cloudflare runtime only. It has not been run or measured on Cloudflare itself.
75
+
76
+ ## [0.32.1] - 2026-10-07
77
+
78
+ **Plugin 0.33.1** · [#55](https://github.com/homie-rocks/homie/pull/55) · [release-2026-10-07-studio-0.32.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-07-studio-0.32.1)
79
+
80
+ A standalone game can be put on your own iPhone or iPad with one command, and the guide says what has now been run on a real phone and corrects what an older site means for a copy.
81
+
82
+ ### Added
83
+
84
+ - **`homie-studio standalone run <game> --for ios --device`** builds the game for the one iPhone or iPad plugged in to your Mac, signs it for your Apple team, installs it over the cable, starts it, and then looks for it among the phone's running programs. It says the game is running only when it found it there. In chat it is `game_standalone` with `device: true`.
85
+ - **`--device` is your yes to what that changes outside your computer.** It adds the phone to your Apple team's list of development devices (a team may register a limited number a year, and a device stays on the list until the membership year renews) and lets Xcode make a development profile for the app. The app is signed for that phone only: a build to try, never one for the store.
86
+ - Every way it can stop is an answer with what to do: no phone connected, more than one (it lists them by model and builds nothing), Developer Mode off (on the phone: Settings, Privacy & Security, Developer Mode; the phone restarts), a phone that does not trust the Mac, a locked phone (it waits a minute for you to unlock it), no Apple account signed in to Xcode, a team with no registered phone, a build that failed. It never says or returns the phone's id, its name or your team's id.
87
+ - The team is `HOMIE_APPLE_TEAM`, else the one team among your keychain's development identities; it says which it used. With none, or more than one, it stops and asks for `HOMIE_APPLE_TEAM`.
88
+ - `setup status` and `standalone plan` have a row for a phone to try a build on: which model is connected and whether its Developer Mode is on, or "could not check".
89
+
90
+ ### Fixed
91
+
92
+ - **What an older site means for a copy was said too broadly.** Only **Quick play** needs your live site on 0.32.0 or later: it asks the site which room to join, and an older site does not let an app read the answer. A room made in the app or joined by its code connects through an older site too. We saw that against a site on 0.31.0; it is not promised for every older version. The plan, the build, the chat tool, the skill and the guide now say this.
93
+
94
+ ### Changed
95
+
96
+ - The guide's "What has been run and what has not" now has what was run on 2026-10-07: an iOS build on a real iPhone, signed for a development team and installed over the cable, playing offline with its bots, and an online private room from that phone through a studio's live site. The new command built, signed and installed a game on that iPhone; the phone was locked, so its last two steps (starting the game and finding it running) have been run against stand-ins only. Still never run: anything under Steam, Developer ID signing and notarization, the iOS archive for the App Store, the GitHub workflow, the Windows and Linux builds started on any computer, a real Android phone, and Quick play online from a phone.
97
+
98
+ ### Upgrade notes
99
+
100
+ - A run on a phone needs a Mac with Xcode signed in to your Apple account, and the phone unlocked, trusting the Mac, with Developer Mode on.
101
+ - There is no `--device` for an Android phone yet: `standalone run <game> --for android` hands over to Capacitor, which asks which phone or emulator. That has been run on an emulator only.
102
+
20
103
  ## [0.32.0] - 2026-10-06
21
104
 
22
- **Plugin 0.33.0** · [#54](https://github.com/homie-rocks/homie/pull/54)
105
+ **Plugin 0.33.0** · [#54](https://github.com/homie-rocks/homie/pull/54) · [release-2026-10-06-studio-0.32.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-06-studio-0.32.0)
23
106
 
24
107
  A game can now be an app of its own: a desktop app for macOS, Windows and Linux and a phone app for iOS and Android, the same web game in a thin shell that still plays through your studio's own site.
25
108
 
@@ -46,7 +129,7 @@ A game can now be an app of its own: a desktop app for macOS, Windows and Linux
46
129
 
47
130
  ### Upgrade notes
48
131
 
49
- - **Upgrade and deploy before a standalone copy can find rooms.** A copy talks to your live site, and only a site deployed with 0.32.0 or later answers it. Until then every copy plays offline.
132
+ - **Upgrade and deploy before Quick play can find a room in a standalone copy.** Quick play asks your live site which room to join, and only a site deployed with 0.32.0 or later lets an app read the answer. Until then Quick play is offline with the game's bots. (Corrected in 0.32.1: a room made or joined by its code connects through an older site too.)
50
133
  - The address of your site is built into every copy and cannot be changed in a copy that has shipped. Put the studio on its own domain before you ship: a workers.dev address belongs to the Cloudflare account.
51
134
  - Before you ship, name the game's revision in `game.json` (`"netplay": { "version": "1" }`) and raise it with every change an older copy cannot play with. Older copies then keep playing with each other, and say "Update <Name> to play online with everyone." when they meet a newer one.
52
135
  - In an app the game's code is trusted with everything the app's page has, and on phones with Capacitor's native bridge. That is fine for your own code; do not load scripts from the network in a game you ship as an app.
package/README.md CHANGED
@@ -207,6 +207,24 @@ the owner's session), so `localStorage` throws inside it: a setting or a persona
207
207
  `net.link` says whether the browser is in its room, reconnecting, or playing alone because the room
208
208
  never answered; `guardGestures()` stops a long press on a phone from selecting text in a touch game.
209
209
 
210
+ ## A game's rules on the server (an example, 0.33.0)
211
+
212
+ A game can be written as **rules plus view**: `src/rules.ts` and `src/move.ts` say what is true in the game and run
213
+ in the room's own server object on the studio's Cloudflare; `src/view.ts` draws what it is told and sends what the
214
+ player presses. No player's browser is the room's host, so a changed browser cannot change a score, a pickup or a
215
+ round. `homie-studio build` checks the rules first and refuses anything outside a short list of safe operations,
216
+ naming the line, then guards them so a rule that never ends is stopped while the room carries on
217
+ (`netplay/NETPLAY.md` section 29).
218
+
219
+ ```sh
220
+ npx --no-install homie-studio game new coin-dash --from coin-dash # the example
221
+ npx --no-install homie-studio dev # its rules run in the local room object
222
+ ```
223
+
224
+ `coin-dash` is an example, not yet a starter to build your own game on: a server-hosted room has no save in this
225
+ version, so a deploy of the site restarts its match. A game with no `src/rules.ts` is not touched by any of this and
226
+ runs in a player's browser exactly as before. So far this has run on a local Cloudflare runtime only.
227
+
210
228
  ## Player accounts and cloud saves
211
229
 
212
230
  A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
@@ -433,13 +451,14 @@ npx homie-studio lounge mod <player id> # a mode
433
451
 
434
452
  A studio sells items for its games (0.24.0; Stripe's own agent tools since 0.24.3) with **its own Stripe account**: the studio is the seller, money goes
435
453
  straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
436
- currency; not a Connect platform). Real-money prices only, nothing random, the kids rules built in (no shop on a kids
437
- server, spending off until a neutral age question says adult, nothing for under-13s, 13-17 through a parent's own
438
- checkout, a monthly cap), and the television only shows a code to buy on a phone.
454
+ currency; not a Connect platform). The studio chooses its prices, item kinds and optional spending and refund
455
+ settings, with no Homie ceiling. New shops start with the editable `protective` policy: accounts and an age
456
+ question, no purchases for under-13s, parent checkout for teens, no shop on kids servers, and no paid randomness.
457
+ The studio can inspect and change each rule in shop.json; [shop/SHOP.md](shop/SHOP.md) explains every preset.
439
458
 
440
459
  ```sh
441
460
  npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
442
- npx homie-studio shop check # the kit's rules; every build checks them too
461
+ npx homie-studio shop check # the studio settings and provider requirements; every build checks them too
443
462
  npx homie-studio shop catalog [--have <file>] # the items as Products in Stripe: the read, then the exact writes (Stripe's MCP)
444
463
  npx homie-studio shop connect # a page on THIS computer: the owner pastes one restricted key; it makes the webhook
445
464
  npx homie-studio shop # open (test or live) or what is missing
@@ -823,12 +842,19 @@ every other target says "built, not started on this computer".
823
842
 
824
843
  **What the standalone game does not have (v1):** player accounts and sign-in, cloud saves (saves stay on the
825
844
  device and are lost when the app is uninstalled), the shop, room chat, watching and the big screen, servers
826
- other than the public one, automatic updates, and a signed Windows build. A studio must be on 0.32.0 or later
827
- **and deployed** before a copy can find rooms.
845
+ other than the public one, automatic updates, and a signed Windows build. For Quick play to find a room, a
846
+ studio must be on 0.32.0 or later **and deployed**; a room made or joined by its code works with an older site
847
+ too (seen against 0.31.0, not promised for every older version).
848
+
849
+ **On your own iPhone or iPad:** `standalone run <id> --for ios --device` builds the game for the one phone
850
+ plugged in, signs it for your Apple team, installs it over the cable, starts it and looks for it among the
851
+ phone's running programs. `--device` is your yes to what it changes: it adds the phone to your Apple team's list
852
+ of development devices.
828
853
 
829
854
  **What has not been run:** the Windows and Linux builds have never been started; nothing has been started by
830
855
  Steam (its overlay, which Electron apps often do not show, included); a Developer ID signature, notarization and
831
- the iOS archive have never been made; nothing has run on a real phone. The guide lists it all.
856
+ the iOS archive have never been made; nothing has run on a real Android phone, and Quick play has not been seen
857
+ online from a phone. The guide lists it all.
832
858
 
833
859
  ## How it feels: the Game Lab (`homie-studio lab`)
834
860
 
@@ -1193,8 +1219,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
1193
1219
  Versions are immutable: a published version never changes, so a studio that pinned one
1194
1220
  never changes by surprise. A change ships as a new `version` on the npm registry as
1195
1221
  `@homie-rocks/studio`, which is what `homie-studio new` pins (the exact version; package-lock.json keeps its
1196
- integrity). The registry is what Workers Builds and a Claude Code cloud session reach by default. Older studios
1197
- pinned a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`, which stays.
1222
+ integrity). The registry is what Workers Builds and a Claude Code cloud session reach by default.
1198
1223
 
1199
1224
  ## Beta
1200
1225
 
package/agents/agents.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  * vocabulary line with arguments that fit, on a server whose AI may talk).
25
25
  * =============================================================================
26
26
  */
27
- import { aiName, stripAi, type Netplay, type NetEvent, type Policy, type Slot } from '../netplay/netplay';
27
+ import { aiName, stripAi, type Netplay, type NetEvent, type Policy, type Slot } from '../netplay/netplay.ts';
28
28
 
29
29
  /* ------------------------------------------------------------------ the vocabulary (agents.json) */
30
30
 
@@ -56,6 +56,13 @@ export interface GoalEvent { slot: number; goal: Goal; prev: Goal | null; askAt:
56
56
  export interface AskButton { k: string; args: Record<string, unknown>; text: string }
57
57
 
58
58
  export interface AgentsOptions {
59
+ /** Runtime integration: a room clock and an explicit beat replace the browser timer. */
60
+ now?: () => number;
61
+ manual?: boolean;
62
+ /** Keep an asked-for goal through scripted floor decisions until completion or sixty seconds. */
63
+ carryFloor?: boolean;
64
+ restore?: AgentsSaved;
65
+
59
66
  /** Host: what a guide in this slot sees. Game state only: never an account, an address or typed text. Under 2 KB. */
60
67
  view: (slot: number) => Record<string, unknown>;
61
68
  /** The scripted floor: runs with no AI, over budget, and between AI decisions. Synchronous; never waits. */
@@ -70,7 +77,14 @@ export interface AgentsOptions {
70
77
 
71
78
  export interface AgentsStats { views: number; dos: number; says: number; asks: number; drops: number; floors: number; viewBytes: number }
72
79
 
80
+ export interface AgentsSaved { goals: [number, Goal][]; asks: [number, Ask[]][]; avoid: [number, [number, number][]][]; sayAt: [number, number][]; viewAt: [number, number][]; floorAt: [number, number][]; askAt: [number, number][] }
73
81
  export interface Agents {
82
+ tick(): void;
83
+ save(): AgentsSaved;
84
+ forget(slot: number): void;
85
+ /** Drop one slot's goal so its floor may run at once. Its pacing, its asks and who asked to be left alone all stay. */
86
+ dropGoal(slot: number): void;
87
+
74
88
  readonly vocab: Vocabulary;
75
89
  /** Whether this server's AI may talk (its brain is workers-ai or owner-key, and speech is not off). */
76
90
  readonly talking: boolean;
@@ -95,6 +109,7 @@ export interface Agents {
95
109
  stop(): void;
96
110
  }
97
111
 
112
+ const own = <T>(table: Record<string, T> | undefined, key: unknown): T | undefined => table && typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
98
113
  const TALK = ['workers-ai', 'owner-key'];
99
114
  export const AGENT_RULES = Object.freeze({ viewMs: 2050, floorMs: 1000, quietMs: 8000, leaveMs: 10 * 60_000, askMs: 30_000, holdMs: 45_000, askWaitMs: 4000, carryMs: 60_000, viewBytes: 1900 });
100
115
  const VIEW_ARG = /^view\.([A-Za-z][A-Za-z0-9_]{0,23})$/;
@@ -127,7 +142,7 @@ export function argsWhy(types: Record<string, ArgType> | undefined, args: unknow
127
142
  return null;
128
143
  }
129
144
 
130
- export const labelOf = (vocab: Vocabulary, value: unknown): string => vocab.labels?.[String(value)] ?? String(value ?? '').replace(/[-_]+/g, ' ');
145
+ export const labelOf = (vocab: Vocabulary, value: unknown): string => own(vocab.labels, String(value)) ?? String(value ?? '').replace(/[-_]+/g, ' ');
131
146
 
132
147
  /* ------------------------------------------------------------------ useAgents */
133
148
 
@@ -145,7 +160,15 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
145
160
  const askAt = new Map<number, number>();
146
161
  const handlers = { say: new Set<(e: SayEvent) => void>(), goal: new Set<(e: GoalEvent) => void>(), ask: new Set<(e: Ask & { slot: number }) => void>() };
147
162
  const st: AgentsStats = { views: 0, dos: 0, says: 0, asks: 0, drops: 0, floors: 0, viewBytes: 0 };
148
- const wall = (): number => Date.now();
163
+ const wall = opts.now ?? (() => Date.now());
164
+ if (opts.restore) {
165
+ const entries = (v: unknown): [number, any][] => Array.isArray(v) ? v.slice(0, 256).filter((e) => Array.isArray(e) && e.length === 2 && Number.isSafeInteger(e[0]) && e[0] >= 0) : [];
166
+ const stamp = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= wall();
167
+ for (const [k, v] of entries(opts.restore.goals)) if (v && own(vocab.goals, v.goal) && stamp(v.at) && v.args && typeof v.args === 'object' && !Array.isArray(v.args)) goals.set(k, { ...v });
168
+ for (const [k, v] of entries(opts.restore.asks)) if (Array.isArray(v)) asks.set(k, v.slice(0, 3).filter((a) => a && own(vocab.asks, a.k) && stamp(a.at) && Number.isSafeInteger(a.from) && a.from >= 0 && a.args && typeof a.args === 'object' && !Array.isArray(a.args)));
169
+ for (const [k, v] of entries(opts.restore.avoid)) avoid.set(k, new Map(entries(v).filter(([, until]) => typeof until === 'number' && Number.isFinite(until) && until > wall() && until <= wall() + AGENT_RULES.leaveMs)));
170
+ for (const [dst, src] of [[sayAt, opts.restore.sayAt], [viewAt, opts.restore.viewAt], [floorAt, opts.restore.floorAt], [askAt, opts.restore.askAt]] as const) for (const [k, v] of entries(src)) if (stamp(v)) dst.set(k, v);
171
+ }
149
172
 
150
173
  const guides = (): Slot[] => (net.slots ?? []).filter((s) => s.agent && roles.includes(s.agent.role as 'guide' | 'party'));
151
174
  const slotOf = (slot: number): Slot | undefined => guides().find((s) => s.slot === slot);
@@ -171,7 +194,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
171
194
  };
172
195
 
173
196
  function render(id: string, args: Record<string, unknown>, slot: number | null, kind: 'lines' | 'asks' = 'lines'): string | null {
174
- const def = kind === 'asks' ? vocab.asks?.[id] : vocab.lines?.[id];
197
+ const def = kind === 'asks' ? own(vocab.asks, id) : own(vocab.lines, id);
175
198
  if (!def) return null;
176
199
  const s = slot === null ? undefined : (net.slots ?? []).find((x) => x.slot === slot);
177
200
  const me = s ? stripAi(s.name) : 'Guide';
@@ -180,7 +203,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
180
203
 
181
204
  /** A line the host lets a guide say: from the vocabulary, its arguments fit, not within 8 s of its last. */
182
205
  function say(slot: number, line: string, args: Record<string, unknown>): boolean {
183
- const def = vocab.lines?.[line];
206
+ const def = own(vocab.lines, line);
184
207
  const now = wall();
185
208
  if (!def || !talksOn(net.policy) || now - (sayAt.get(slot) ?? -Infinity) < AGENT_RULES.quietMs) { st.drops += 1; return false; }
186
209
  if (argsWhy(def.args, args, lastView.get(slot) ?? null, people()) || touchesAvoided(def.args, args, slot)) { st.drops += 1; return false; }
@@ -197,7 +220,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
197
220
 
198
221
  function setGoal(slot: number, d: Decision, from: 'brain' | 'floor', answering = true): boolean {
199
222
  if (!d.goal) return false;
200
- const def = vocab.goals[d.goal];
223
+ const def = own(vocab.goals, d.goal);
201
224
  const args = (d.args ?? {}) as Record<string, unknown>;
202
225
  if (!def || argsWhy(def.args, args, lastView.get(slot) ?? opts.view(slot), people()) || touchesAvoided(def.args, args, slot)) { st.drops += 1; return false; }
203
226
  const prev = goals.get(slot) ?? null;
@@ -216,13 +239,13 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
216
239
  // A person's ask is answered the way agents.json says (its `goal`): a brain's decision that does something else
217
240
  // leaves the ask open, and the floor answers it once the brain has had askWaitMs (a fixed rule, not the model's).
218
241
  const open = askAt.has(slot) ? (asks.get(slot) ?? []).at(-1) ?? null : null;
219
- const want = open ? vocab.asks?.[open.k]?.goal : undefined;
220
- const answering = !open || from === 'floor' || !want || want === d.goal;
242
+ const want = open ? own(vocab.asks, open.k)?.goal : undefined;
243
+ const answering = !open || !want || (want === d.goal && Object.entries(open.args).every(([k, v]) => !own(own(vocab.goals, want)?.args, k) || d.args?.[k] === v));
221
244
  // What a person asked for is carried through until it is done (or 60 s): a brain's other goal waits.
222
245
  const cur = goals.get(slot);
223
- if (!open && from === 'brain' && cur && cur.asked && cur.state === 'active' && wall() - cur.at < AGENT_RULES.carryMs && d.goal !== cur.goal) { st.drops += 1; if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>); return; }
246
+ if (!open && from === 'brain' && cur && cur.asked && cur.state === 'active' && wall() - cur.at < AGENT_RULES.carryMs && (d.goal !== cur.goal || JSON.stringify(d.args ?? {}) !== JSON.stringify(cur.args))) { st.drops += 1; if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>); return; }
224
247
  setGoal(slot, d, from, answering);
225
- if (open && answering) { askAt.delete(slot); asks.set(slot, []); const g = goals.get(slot); if (g && d.goal === g.goal) g.asked = true; }
248
+ if (open && answering && goals.get(slot)?.goal === d.goal && JSON.stringify(goals.get(slot)?.args) === JSON.stringify(d.args ?? {})) { askAt.delete(slot); asks.set(slot, []); const g = goals.get(slot); if (g && d.goal === g.goal) { g.asked = true; g.at = wall(); } }
226
249
  if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>);
227
250
  }
228
251
 
@@ -242,21 +265,23 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
242
265
 
243
266
  /** The floor for one slot: the game's own decide, on its view. */
244
267
  function floor(slot: number, held: boolean): void {
245
- if (!opts.decide) return;
268
+ if (!opts.decide) { floorAt.set(slot, wall()); askAt.delete(slot); asks.set(slot, []); return; }
246
269
  const v = viewOf(slot);
247
270
  lastView.set(slot, v);
248
271
  let d: Decision | null = null;
249
272
  try { d = opts.decide(v, { slot, goal: goals.get(slot) ?? null, asks: asksFor(slot) }); } catch { d = null; }
250
273
  floorAt.set(slot, wall());
251
- if (!d) return;
274
+ // One floor attempt settles an ask, even if it declines or fails.
275
+ if (!d) { askAt.delete(slot); asks.set(slot, []); return; }
252
276
  st.floors += 1;
253
277
  // The floor speaks only for a guide no AI holds (a held guide's own brain speaks for it).
254
278
  apply(slot, d, 'floor', !held);
279
+ askAt.delete(slot); asks.set(slot, []);
255
280
  }
256
281
 
257
282
  /** A person's ask reached the host (theirs, or its own player's). */
258
283
  function onAsk(slot: number, k: string, args: Record<string, unknown>, from: number): void {
259
- const def = vocab.asks?.[k];
284
+ const def = own(vocab.asks, k);
260
285
  const s = slotOf(slot);
261
286
  if (!def || !s || argsWhy(def.args, args, lastView.get(slot) ?? viewOf(slot), null)) { st.drops += 1; return; }
262
287
  const now = wall();
@@ -267,7 +292,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
267
292
  emit('ask', { slot, k, args, from, at: now });
268
293
  const held = s.agent?.seat !== null && s.agent?.seat !== undefined;
269
294
  // No AI holds this guide: the floor answers at once. Held: its brain has askWaitMs to answer first.
270
- if (!held) floor(slot, false);
295
+ if (!held) { if (opts.manual) floorAt.delete(slot); else floor(slot, false); }
271
296
  }
272
297
 
273
298
  const offEvent = net.on('event', (e: NetEvent) => {
@@ -286,12 +311,13 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
286
311
  if (ask && e.from !== null && !net.isAgent(e.from) && Number.isInteger(d['slot'])) { onAsk(d['slot'] as number, ask, (d['args'] ?? {}) as Record<string, unknown>, e.from); return; }
287
312
  return;
288
313
  }
314
+ if (k === 'agent:offer' && e.from === net.host?.seat && Number.isInteger(d['slot']) && d['view'] && typeof d['view'] === 'object') { lastView.set(d['slot'] as number, d['view'] as Record<string, unknown>); return; }
289
315
  // Every other browser: a guide's line the host relayed (Quiet AI already dropped it in the helper). Its words are
290
316
  // this browser's own copy of the vocabulary; an argument is a seat, a value of the line's list, or an id.
291
317
  const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
292
- if (line && d['ai'] === true && Number.isInteger(d['slot'])) {
318
+ if (e.from === net.host?.seat && line && d['ai'] === true && Number.isInteger(d['slot'])) {
293
319
  const args = (d['args'] ?? {}) as Record<string, unknown>;
294
- const def = vocab.lines?.[line];
320
+ const def = own(vocab.lines, line);
295
321
  const ids = Object.fromEntries(Object.entries(def?.args ?? {}).map(([n, t]) => [n, t]).filter(([, t]) => typeof t === 'string' && VIEW_ARG.test(t)).map(([n, t]) => [VIEW_ARG.exec(t as string)![1], /^[a-z0-9][a-z0-9_-]{0,39}$/.test(String(args[n as string] ?? '')) ? [args[n as string]] : []]));
296
322
  if (!def || argsWhy(def.args, args, ids, null)) { st.drops += 1; return; }
297
323
  const text = render(line, args, d['slot'] as number);
@@ -300,7 +326,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
300
326
  });
301
327
 
302
328
  // The host's beat: views to the guides an AI holds (at most one every 2 s each), the floor for the rest.
303
- const timer = setInterval(() => {
329
+ const beat = (): void => {
304
330
  if (!net.isHost || net.offline) return;
305
331
  const now = wall();
306
332
  for (const s of guides()) {
@@ -322,21 +348,27 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
322
348
  const waiting = held && askAt.has(s.slot) && now - (askAt.get(s.slot) as number) < askWaitMs;
323
349
  // The floor decides for a guide with no AI's decision in force, at most once a second; an ask an AI left
324
350
  // unanswered for askWaitMs is answered by the floor (silently: a held guide's lines are its brain's).
325
- if ((!brainFresh && !waiting && now - (floorAt.get(s.slot) ?? -Infinity) >= AGENT_RULES.floorMs) || (held && askAt.has(s.slot) && !waiting)) floor(s.slot, held);
351
+ const carried = opts.carryFloor && g?.asked && g.state === 'active' && now - g.at < AGENT_RULES.carryMs && !askAt.has(s.slot);
352
+ if (!carried && ((!brainFresh && !waiting && now - (floorAt.get(s.slot) ?? -Infinity) >= AGENT_RULES.floorMs) || (held && askAt.has(s.slot) && !waiting))) floor(s.slot, held);
326
353
  }
327
354
  // Slots no longer an AI's: forget them.
328
355
  const live = new Set(guides().map((s) => s.slot));
329
- for (const k of [...goals.keys()]) if (!live.has(k)) goals.delete(k);
330
- }, 250);
356
+ for (const map of [goals, asks, avoid, sayAt, viewAt, floorAt, askAt, lastView]) for (const k of [...map.keys()]) if (!live.has(k)) map.delete(k);
357
+ };
358
+ const timer = opts.manual ? null : setInterval(beat, 250);
331
359
 
332
360
  const agents: Agents = {
361
+ tick: beat,
362
+ forget: (slot) => { for (const map of [goals, asks, avoid, sayAt, viewAt, floorAt, askAt, lastView]) map.delete(slot); },
363
+ dropGoal: (slot) => { goals.delete(slot); floorAt.delete(slot); },
364
+ save: () => ({ goals: [...goals], asks: [...asks], avoid: [...avoid].map(([k, v]) => [k, [...v]]), sayAt: [...sayAt], viewAt: [...viewAt], floorAt: [...floorAt], askAt: [...askAt] }),
333
365
  vocab,
334
366
  get talking() { return talksOn(net.policy); },
335
367
  guides,
336
368
  goalOf: (slot) => goals.get(slot) ?? null,
337
369
  asksFor,
338
370
  ask(slot, k, args = {}) {
339
- const def = vocab.asks?.[k];
371
+ const def = own(vocab.asks, k);
340
372
  const s = slotOf(slot);
341
373
  if (!def || !s) return false;
342
374
  const seat = s.agent?.seat ?? null;
@@ -353,6 +385,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
353
385
  return true;
354
386
  },
355
387
  askButtons(slot, offer = {}) {
388
+ if (!slotOf(slot)) return [];
356
389
  const out: AskButton[] = [];
357
390
  const view = { ...(lastView.get(slot) ?? {}), ...offer };
358
391
  for (const [k, def] of Object.entries(vocab.asks ?? {})) {
@@ -362,7 +395,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
362
395
  const name = names[0] as string;
363
396
  const type = (def.args as Record<string, ArgType>)[name];
364
397
  const m = typeof type === 'string' ? VIEW_ARG.exec(type) : null;
365
- const values = Array.isArray(type) ? [...type] : m ? (Array.isArray(view[m[1] as string]) ? (view[m[1] as string] as unknown[]) : []) : [];
398
+ const values = Array.isArray(type) ? [...type] : type === 'player' ? (net.seat === null ? [] : [net.seat]) : m ? (Array.isArray(view[m[1] as string]) ? (view[m[1] as string] as unknown[]) : []) : [];
366
399
  for (const v of values.slice(0, 4)) {
367
400
  const value = v && typeof v === 'object' ? (v as { id?: unknown }).id : v;
368
401
  const text = render(k, { [name]: value }, slot, 'asks');
@@ -378,7 +411,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
378
411
  g.at = wall();
379
412
  const s = slotOf(slot);
380
413
  // A guide no AI holds decides again at once; one an AI holds hears it in its next view.
381
- if (s && (s.agent?.seat ?? null) === null) floor(slot, false);
414
+ if (s && (s.agent?.seat ?? null) === null) { if (opts.manual) floorAt.delete(slot); else floor(slot, false); }
382
415
  },
383
416
  render,
384
417
  on(kind: 'say' | 'goal' | 'ask', fn: (e: never) => void) {
@@ -386,7 +419,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
386
419
  return () => { (handlers[kind] as Set<unknown>).delete(fn); };
387
420
  },
388
421
  stats: () => ({ ...st }),
389
- stop() { clearInterval(timer); offEvent(); },
422
+ stop() { if (timer) clearInterval(timer); offEvent(); },
390
423
  } as Agents;
391
424
  return agents;
392
425
  }
@@ -169,7 +169,7 @@
169
169
  * spent from the guides' day; --model compares a model before setting it)
170
170
  * homie-studio shop is the studio's shop selling (its own Stripe), and if not, what is missing
171
171
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed] shop.json and SELLING.md
172
- * homie-studio shop check shop.json against the kit's rules (real money, nothing random, the kids rules)
172
+ * homie-studio shop check shop.json against studio settings and provider requirements
173
173
  * homie-studio shop connect [--managed] [--live] a page on THIS computer where the owner pastes the studio's
174
174
  * restricted Stripe key; with it this makes the webhook (0.24.3), and the key
175
175
  * and the webhook's secret go straight to the Worker secrets, never a chat or a
@@ -295,6 +295,13 @@
295
295
  * id, and an unsigned release is said as UNSIGNED and is not a success); run:
296
296
  * the built copy, here; steam: Steam's build file; ci: a GitHub workflow that
297
297
  * builds all five. Nothing is ever uploaded to Steam or a store
298
+ * homie-studio standalone run <game> --for ios --device
299
+ * the game on the ONE iPhone or iPad plugged in to this Mac: built, signed for
300
+ * your Apple team (HOMIE_APPLE_TEAM, else the keychain's only one), installed
301
+ * over the cable, started, and then looked for among the phone's running
302
+ * programs. --device is your yes to what it changes outside this computer: it
303
+ * adds the phone to your Apple team's list of development devices. A phone with
304
+ * Developer Mode off, locked, or not trusting this Mac is said with its fix
298
305
  *
299
306
  * homie-studio statusline the current build in one line (what Claude Code's status line shows)
300
307
  * homie-studio statusline --install [--project <folder>]
@@ -361,7 +368,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
361
368
 
362
369
  const argv = process.argv.slice(2);
363
370
  const flags = new Map();
364
- const BOOL_FLAGS = ['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', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release'];
371
+ const BOOL_FLAGS = ['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', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
365
372
  const positional = [];
366
373
  for (let i = 0; i < argv.length; i++) {
367
374
  const a = argv[i];
@@ -803,9 +810,11 @@ const OUTWARD_FLAGS = {
803
810
  'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
804
811
  'chat remove': ['url', 'all'], 'chat budget': ['url'],
805
812
  'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
806
- 'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send'],
813
+ 'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send', 'cursor'],
807
814
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
808
815
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
816
+ // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
817
+ 'standalone run': ['for', 'device', 'site'],
809
818
  };
810
819
  /** Every command takes these: how it answers, never what it does. */
811
820
  const ANY_COMMAND = ['json', 'help', 'version'];
@@ -1046,7 +1055,7 @@ async function main() {
1046
1055
  }
1047
1056
  if (sub === 'orders') return shopOrders(root, { url });
1048
1057
  if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note') });
1049
- if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), send: flags.has('send') });
1058
+ if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), cursor: flags.get('cursor') ?? '', send: flags.has('send') });
1050
1059
  return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, catalog, orders, refund, statements)` };
1051
1060
  }
1052
1061
  if (cmd === 'agents') {