@foldspace_npm/harness 0.1.33 → 0.1.35

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/CLAUDE.md CHANGED
@@ -11,15 +11,23 @@ session ends at something they can **see on their own page** - not at
11
11
  ## Every session starts the same way
12
12
 
13
13
  1. **Harness version.** Run `npx foldspace help --json` (from an older install,
14
- `npx --yes @foldspace_npm/harness@latest upgrade --check`). If
15
- `update.outdated` is true, say which version the project is on and which is
16
- newest, and **ask before upgrading**; then `npx foldspace upgrade --yes` and
17
- `npm run build`. Never upgrade as a side effect of build, attach or
18
- implementing an action. `build` and `attach` check too: on an older version
19
- they print one line and carry on; on a version Foldspace **withdrew** they
20
- refuse to run and say why. Tell the human the reason and ask to upgrade.
21
- Only if they decide not to, rerun with `FOLDSPACE_SKIP_UPDATE_CHECK=1` -
22
- never set it on your own. If the project `CLAUDE.md` does not contain
14
+ `npx --yes @foldspace_npm/harness@latest upgrade --check`). Several people
15
+ can build one agent, so every session runs the newest harness:
16
+ - **`update.autoUpgrade` is true** - a newer version in the same line, such
17
+ as 0.1.30 to 0.1.34. Run `npx foldspace upgrade --yes` and
18
+ `npm run build` without asking, and tell the human in one line: *"I've
19
+ updated the Foldspace tools to the newest version."*
20
+ - **`update.outdated` is true, `autoUpgrade` is not** - the newest is a new
21
+ line (0.1 to 0.2). Say which version the project is on and which is
22
+ newest, and **ask before upgrading**; then the same two commands.
23
+
24
+ `build` and `attach` never upgrade by themselves. They check too: on an
25
+ older version they print one line and carry on - upgrade then, the same
26
+ way. On a version Foldspace **withdrew** they refuse to run and say why:
27
+ within the line, upgrade without asking; across a line, tell the human the
28
+ reason and ask. Only if they decide not to, rerun with
29
+ `FOLDSPACE_SKIP_UPDATE_CHECK=1` - never set it on your own. If the project
30
+ `CLAUDE.md` does not contain
23
31
  `@node_modules/@foldspace_npm/harness/CLAUDE.md`, ask before
24
32
  `npx foldspace upgrade --refresh-instructions`.
25
33
  2. **Is Foldspace connected? Check — never assume.** `region` in
@@ -49,22 +57,18 @@ session ends at something they can **see on their own page** - not at
49
57
  shows them.
50
58
  - **A code project.** Call `repo_read` with command `instructions` and
51
59
  follow what it returns. The project's `agent/` is the copy Foldspace
52
- builds and publishes, and it may hold work someone else saved. Before you
53
- build, replace this folder's `agent/` with it (`repo_read` commands `tree`
54
- and `file`, with the `ref` the instructions name - the default can be
55
- older than what someone saved): delete what is there rather than copying
56
- over the top, or their page shows the template instead of their agent.
57
- This folder's `agent/` may hold work a session never saved, so copy it to
58
- `.foldspace-dev/agent-kept/` first. Put back the action files the project
59
- lacks; where a file differs, the project's wins. If any differed, tell
60
+ builds and publishes, and it may hold work someone else saved. Before
61
+ you build, pull it into this folder (**Several builders, one agent**):
62
+ `npx foldspace sync` replaces the template with their agent, keeps a copy of this folder's
63
+ `agent/` in `.foldspace-dev/agent-kept/`, and puts back unsaved action
64
+ files the project lacks. Where a file differs, the project's wins: tell
60
65
  the human in one line which actions had changes on this computer that
61
66
  were never saved.
62
67
  - **`NO_REPO`.** Nothing is saved yet. Follow the next step `status` gives:
63
68
  it creates the code project in about a minute and changes nothing anyone
64
69
  sees - say you are setting up their project, no need to ask. Get the agent
65
70
  live in this folder meanwhile (L0 needs no code). Once `status` shows the
66
- project, replace this folder's `agent/` with its `agent/` as above, before
67
- you write the first action.
71
+ project, pull its `agent/` as above, before you write the first action.
68
72
  - **A refusal that names `foldspace-ai-access`.** This agent has saved
69
73
  code that Foldspace has not opened to these tools; that is final. Tell
70
74
  the human that Foldspace manages their agent's saved code, so this build
@@ -145,19 +149,29 @@ marks their turn: they may type whatever, whenever; the pill only ever asks them
145
149
  to wait. `npx foldspace badge label` puts the small corner tag back;
146
150
  `npx foldspace badge off` removes it, for a screenshot they will share.
147
151
 
148
- **After they have tried it, the second wave: tell Foldspace who the user is
149
- (item 3 - no question, one line in chat), and offer the rest as one choice,
150
- recommended first.** Once you have handed over (`badge ready`), the human has
151
- asked the agent the question and - with a code project - you have taken the
152
- steps in **Done, and what you say at the end**:
152
+ **After they have tried it, the second wave: write the page starters (item 2)
153
+ and tell Foldspace who the user is (item 3) - no question, one line in chat
154
+ each - and offer the rest as one choice, recommended first.** Once you have
155
+ handed over (`badge ready`), the human has asked the agent the question and -
156
+ with a code project - you have taken the steps in **Done, and what you say at
157
+ the end**:
153
158
 
154
159
  1. **Starters.** The sign-up starters point at actions the agent does not have
155
160
  (clicking one answers "that action isn't available"): replace them with the
156
161
  question this experience answers. `set_conversation_starters` replaces the
157
- whole set - send the full list. Their account, so their yes.
158
- 2. **The bottom bar** - `recipes/bottom-bar`: the agent's resting place at the
159
- foot of the page, with starters that change per screen. Ships in this
160
- project's start-up code; nothing in their source changes.
162
+ whole set - send the full list, **at most 3**, each in the right group
163
+ (**How the agent looks and opens is configuration**). Their account, so
164
+ their yes.
165
+ 2. **Page starters** - `recipes/bottom-bar`. Foldspace already shows the bar
166
+ at the foot of the page; this picks what it offers on each screen. It
167
+ ships in this project's start-up code; nothing in their source changes.
168
+ 1. `npx foldspace observe menu` lists the main-menu pages.
169
+ 2. For each page, write at most 3 starters the agent can answer today.
170
+ Early on most pages have none: leave them out, and they keep Agent
171
+ Studio's starters.
172
+ 3. `ACTION` when clicking it runs an action, `KNOWLEDGE` when the answer
173
+ comes from the knowledge base.
174
+ 4. `npm run build`, attach, and look at one page that has starters.
161
175
  3. **Tell Foldspace who the user is** - `recipes/who-is-the-user`: from then on
162
176
  their conversations and analytics stop being anonymous, and the agent has
163
177
  real-time context about who it is talking to.
@@ -184,7 +198,7 @@ Every question to the human is an **enumerated choice with the recommended
184
198
  option first** (in Claude Code, `AskUserQuestion`; elsewhere, the editor's own question tool), asked once - not repeated in your message. A first session asks only
185
199
  this: which agent (only if several), the app's hostname, which experience
186
200
  first - and a yes wherever this manual asks for one, such as agreeing the
187
- experience (below), upgrading, publishing an action, task agent or
201
+ experience (below), upgrading to a new line (0.1 to 0.2), publishing an action, task agent or
188
202
  navigation, publishing their code for their users, replacing their starters or
189
203
  adding hosts. The hostname is the only free-text question. Everything else -
190
204
  attach mode, folder, name, level - you **state and move on**.
@@ -229,6 +243,42 @@ command refuses the window.
229
243
  with both, the user sees an extra message and the agent takes a second
230
244
  turn.
231
245
 
246
+ ## Each customer on their own subdomain
247
+
248
+ Some products give each of their customers their own address -
249
+ `acme.example.com`, `globex.example.com` - and often a backend that carries
250
+ it too (`acme.api.example.com`). You build signed in to **one** of those
251
+ customers, and the code goes to **all** of them: an address you copy as you
252
+ saw it sends every user's agent to that one customer's data.
253
+
254
+ - **Find out before you write `API_BASE`.** `npx foldspace observe menu` and
255
+ `observe screen` report `customerSubdomain` when the page's address starts
256
+ with what looks like a customer's name, and `screen` names the backend
257
+ calls that carry it too. Ask the human once, as a choice: *"Does each of
258
+ your customers get their own address, like acme.example.com?"* Never decide
259
+ it from the address alone - `reports.` or `eu1.` is a part of the product,
260
+ and only they know.
261
+ - **Write their answer** in `agent/constants.ts`:
262
+ `PER_CUSTOMER_SUBDOMAIN = true` or `false`. Until it is set, observe keeps
263
+ asking and the build refuses an address that may be one customer's.
264
+ - **With `true`, `API_BASE` is built from the page's address**, at run time,
265
+ in the user's own browser:
266
+ - the backend is on the page's own host - `API_BASE = "/api"`;
267
+ - the backend's address carries the customer - write it with
268
+ `{subdomain}` where their name was:
269
+ `API_BASE = "https://{subdomain}.api.example.com"`;
270
+ - one backend for every customer (`api.example.com`, the customer known
271
+ from the sign-in) - the address as it is.
272
+
273
+ The runtime puts each user's own subdomain in (`apiBaseUrl()`), and
274
+ `apiFetch` uses it. A page whose address has no subdomain gets `reason:
275
+ "config"`, never another customer's backend.
276
+ - **Nothing else names one customer either:** a link is a path (`"/invoices/42"`),
277
+ never `https://acme.example.com/invoices/42`. The build refuses one
278
+ customer's address anywhere in `agent/` (`no-fixed-customer-address`).
279
+ - **Record it** in `docs/app-profile.md`: what the human said, and the
280
+ address you observed, with the customer's name marked as theirs.
281
+
232
282
  ## Find the call yourself
233
283
 
234
284
  Never guess `API_BASE`, `AUTH_SOURCE`, an endpoint or a schema. But guessing
@@ -269,6 +319,16 @@ and asking are not the only options — **observing is, and it comes first**:
269
319
  not open a path that does something (`/logout`, `/…/delete`); and `read`
270
320
  only goes to the app's own origin, a host configured for this project, or
271
321
  an origin `screen` saw the page call.
322
+
323
+ **A button you need to look behind** (a report tile, a "View details"):
324
+ ask the human first, naming it - *"May I click 'Staff Overview'? It's a
325
+ button, so it could do something."* Only after their yes in this chat, run
326
+ `npx foldspace observe screen --click "Staff Overview" --human-said-yes`.
327
+ One yes covers that one button, once. Never pass `--human-said-yes`
328
+ without it. Even with a yes, observe refuses a label that reads like an
329
+ action (Export, Send, Delete, Run…), a form submit, a download and signing
330
+ out: hand those to the human. If the result has `buttonWarning`, the button
331
+ sent changes to the server: tell the human what, in one line, first.
272
332
  3. **Ask the human to perform the workflow** — only for what you cannot reach:
273
333
  an MFA step, a paywall, a screen that needs a record the account does not
274
334
  have, or anything that would write.
@@ -277,10 +337,11 @@ Signing in is the human's step, because `inject` launches its own Chrome
277
337
  profile (empty the first time; it keeps the sign-in after that).
278
338
  Navigating is yours. **While you drive a signed-in account you are read-only:**
279
339
  GET only, never submit a form, never click anything that creates, sends or
280
- deletes.
340
+ deletes, and a plain button only after the human's yes (above).
281
341
 
282
342
  Capture at least one real HTTP 200 for the data the action needs. If there is
283
- none, pivot; do not create Foldspace resources. Write what you establish into
343
+ none, pivot; do not create Foldspace resources. Before that address goes into
344
+ `API_BASE`, settle **Each customer on their own subdomain**. Write what you establish into
284
345
  `docs/app-profile.md` **with how you know it**, at that moment. Do not promote
285
346
  an assumption by quoting that file later.
286
347
 
@@ -428,6 +489,33 @@ of the human's, read the attach log for the `[actions]` SDK callback and local
428
489
  execute/render lines. Those record names, statuses, durations and parameter
429
490
  keys only - never results or error bodies.
430
491
 
492
+ ## Several builders, one agent
493
+
494
+ With a code project, other people - or other machines - may save to the same
495
+ agent while you work. So this folder pulls the saved code again whenever
496
+ someone saved since it last did. Saving and publishing stay exactly as
497
+ `repo_read` command `instructions` says; this only keeps `agent/` current.
498
+
499
+ **Pull at session start (step 5), before each new experience, and before each
500
+ save or publish:**
501
+
502
+ 1. `repo_read` `status` gives `develop_head`. Then `repo_read` `tree` with
503
+ `ref` `foldspace/<Agent Key>` - the line of work the instructions save to -
504
+ gives its head (the sha after the ref). If that line does not exist yet,
505
+ nothing has been saved to it: use `develop` only.
506
+ 2. `npx foldspace sync check --develop <develop_head>`, plus
507
+ `--line <its head>` when the line exists. Its `baseSha` is the `base_sha`
508
+ for your next save, as the instructions say.
509
+ 3. If `pull` lists anything: read `agent/` at each `ref` (`tree` and `file`)
510
+ into its `into` folder, same paths (`<into>/agent/actions/x.ts`). Then
511
+ `npx foldspace sync apply`, and do what its `next` says. Files under
512
+ `conflicts` changed both here and in the saved code: they now hold the
513
+ saved version - put your change back on top from the kept copy. Then build.
514
+
515
+ **After each save**, `npx foldspace sync saved --head <commit_sha> --paths
516
+ <committed_paths, comma-separated>`, so your own save is not taken for
517
+ someone else's.
518
+
431
519
  ## Done, and what you say at the end
432
520
 
433
521
  A first session is done when the customer has seen one experience answer from
@@ -438,9 +526,16 @@ gates pass, and not when there is nothing left to improve.
438
526
  their users is yours to do. Once they have tried it in the test window, before
439
527
  you offer the second wave:
440
528
 
441
- 1. Say *"I'm saving your changes and publishing them to dev"* - no need to
442
- ask - then save with `repo_write` and publish with the `deploy` tool, the
443
- way `repo_read` command `instructions` says. One exception: a dev preview
529
+ 1. Before **every** save, review the experience with the
530
+ `review-experience` skill (`.claude/skills/review-experience/SKILL.md`;
531
+ any other editor: read
532
+ `node_modules/@foldspace_npm/harness/templates/agent-starter/skills/review-experience/SKILL.md`):
533
+ only the data the answer needs, a yes from the user before every write,
534
+ nothing the user cannot reach in the product. A serious finding stops the
535
+ save until it is fixed. Then say *"I'm saving your changes and publishing
536
+ them to dev"* - no need to
537
+ ask - then pull (**Several builders, one agent**), and save and publish
538
+ the way `repo_read` command `instructions` says. One exception: a dev preview
444
539
  that comes back with no warnings means this agent loads dev for its users -
445
540
  then ask first, as for production. In chat it is "saving your changes",
446
541
  "publishing to dev" and "publishing for your users" - never GitHub,
@@ -466,8 +561,8 @@ with `--bootstrap` or `--replace`, it does not yet: do step 1, keep attach
466
561
  running, and give the answer below for the rest - their site loading the agent
467
562
  is the step the Foldspace team does with them.
468
563
 
469
- A save refused because someone saved after you read is not final: the
470
- instructions say how to redo it. Any other refusal from the Foldspace tools
564
+ A save refused because someone saved after you read is not final: pull again
565
+ (**Several builders, one agent**), put your change on top and save. Any other refusal from the Foldspace tools
471
566
  here is final: then the answer below.
472
567
 
473
568
  **Without a code project**, the answer to *"how do I put this in front of my
@@ -489,7 +584,9 @@ Do not report success without all six:
489
584
 
490
585
  1. `npm run typecheck` compiles (`tsc --noEmit -p tsconfig.json`). Do not run
491
586
  `npx tsc` — it can install the wrong package.
492
- 2. `foldspace lint` reports no errors (`foldspace build` runs it first).
587
+ 2. `foldspace lint` reports no errors (`foldspace build` runs it first). It
588
+ also refuses what is unsafe for the product's users - **What the build
589
+ refuses**, below.
493
590
  3. The handler appears in `dist/index.js`.
494
591
  4. The browser reports `inspect_registration:registration_ok`, and for a named
495
592
  action the captured registry includes it.
@@ -508,12 +605,27 @@ Do not report success without all six:
508
605
  (**Agentic UI components**).
509
606
  6. Browser evidence came from the live target, not hand-authored examples.
510
607
 
608
+ ### What the build refuses
609
+
610
+ The first three are errors: the build stops, and the message says what to do
611
+ instead. Fix the code; never work round the rule. `known-hosts-only` is a
612
+ warning: the build goes on; mention it to the human in one line. Cards may draw
613
+ any markup and code they need - that is what they are for.
614
+
615
+ | Rule | Refuses | Do instead |
616
+ |---|---|---|
617
+ | `no-fixed-customer-address` | One customer's address in `API_BASE` or anywhere in `agent/`, on a product with a subdomain per customer - or one that may be, while `PER_CUSTOMER_SUBDOMAIN` is undecided | `"/api"` or `"https://{subdomain}.…"`; a path for a link (**Each customer on their own subdomain**) |
618
+ | `token-stays-home` | The user's sign-in token returned from `execute` or a card, handed to Foldspace, logged, stored again (storage, a cookie, `window`) or sent to a host that is not the product's backend | Let `apiFetch` add it; log `redact(token)` |
619
+ | `no-secrets` | API keys, JWTs, bearer tokens, cookie values, `.har` captures, storage dumps and `.env` files anywhere in the project | Names and where things live go in `docs/app-profile.md`; values never |
620
+ | `known-hosts-only` | A call (`fetch`, `sendBeacon`, `WebSocket`, `XMLHttpRequest`, `publicFetch`) to a host that is neither the product's own, Foldspace's (`*.foldspace.ai`), nor named in `agent/constants.ts` | The product's backend through `apiFetch`; another host only with the human's yes, as a constant in `agent/constants.ts` |
621
+
511
622
  ## Rules that are not negotiable
512
623
 
513
624
  1. **Handlers return data only.** Never `directive`, `instructions`, or a
514
625
  paragraph telling the agent what to say - those live in Agent Studio.
515
626
  Never return from `execute` a token, a cookie, any other secret or
516
- `ApiResult.detail` (it is for the console): the agent reads what
627
+ `ApiResult.detail` (it is for the console) - the build refuses the
628
+ user's token there (`token-stays-home`): the agent reads what
517
629
  `execute` returns for an action with no card or a view card (rule 2).
518
630
  Validate parameters and return sanitised errors.
519
631
  **What goes back to the agent states what happened - it never says what to
@@ -612,14 +724,29 @@ wins**. <https://docs.foldspace.ai/customize/configuration/>
612
724
  list. It changes their Foldspace account, so it needs their yes. Offer it in
613
725
  the second wave, replacing the sign-up starters that point at actions the
614
726
  agent does not have.
615
- - **Page-specific starters and the bottom bar are code** -
616
- `setConversationStarters` (`null` restores Agent Studio's) and
617
- `setConfiguration({ bottomBarSettings })` then `openBottomBar()`. Send one
618
- whole settings group per call, as the docs do. That code lives in this
619
- project's start-up (`recipes/bottom-bar`, kind `page-setup`) and ships with
620
- the actions bundle, so nothing in the customer's source changes; their own
621
- mount code is the other place it can live, their engineers' call. Offer it in
622
- the second wave.
727
+ 1. **At most 3, both groups together.** They are also the agent panel's
728
+ opening screen, and a long list buries the one that matters. When the
729
+ list already holds more, say which 3 you would keep and ask.
730
+ 2. **Each in its group.** `conversation_starters` takes
731
+ `{"KNOWLEDGE": [{"title": ...}], "ACTION": [{"title": ...}]}`, both keys
732
+ always. `ACTION` when clicking it runs one of the agent's actions (it
733
+ shows the action icon); `KNOWLEDGE` when the answer comes from the
734
+ knowledge base. Starters for the experiences you built go in `ACTION`.
735
+ 3. **`default_type`** picks the group shown first: `ACTION` when only that
736
+ group has starters.
737
+ - **Page starters are code** - `setConversationStarters` (`null` restores
738
+ Agent Studio's), the same 3-at-most and the same two groups;
739
+ `npx foldspace lint` refuses more than 3. That code lives in this project's
740
+ start-up (`recipes/bottom-bar`, kind `page-setup`) and ships with the
741
+ actions bundle, so nothing in the customer's source changes; their own mount
742
+ code is the other place it can live, their engineers' call. The first
743
+ session writes them for the main-menu pages (second wave, item 2).
744
+ - **The bottom bar is on by default** - Foldspace turns it on from its own
745
+ settings, so code never sends `enabled`. Send `bottomBarSettings` only for a
746
+ setting the human asks to change, one whole settings group per call, as the
747
+ docs do, with `enabled: true` in it (⚠️ whether a partial group merges with
748
+ Foldspace's own settings is not verified). `maxVisibleStarters` caps the
749
+ bar only, never the panel.
623
750
  - **An action's `execute` never calls `setConfiguration`**: a partial call from
624
751
  one can collapse the agent's panel to nothing.
625
752
 
@@ -712,7 +839,8 @@ Each of these cost a real build.
712
839
  a mismatch does not error, the action simply never fires.
713
840
  - `agent/api/` — one HTTP helper per endpoint
714
841
  - `agent/constants.ts` — agent, product, domain, plus empty `API_BASE` /
715
- `AUTH_SOURCE` and `LOAD_MODE`
842
+ `AUTH_SOURCE`, undecided `PER_CUSTOMER_SUBDOMAIN` and `LOAD_MODE`. Any
843
+ host outside the product the human agreed to is named here too
716
844
  - `agent/utils.ts` — configures the harness runtime and re-exports it. **The
717
845
  only import surface for actions.**
718
846
  - `agent/runtime/` — a code project only: this harness's runtime, which every
@@ -760,6 +888,7 @@ not import it — fix the import when you implement.
760
888
  | Helper | Use when | Do not use when |
761
889
  |---|---|---|
762
890
  | `apiFetch` / `apiFetchBinary` | The app's own JSON/file API, after a real 200 | Public marketing hosts (`publicFetch`); custom auth headers |
891
+ | `apiBaseUrl` | Your own `apiFetch` (custom header, cookies) builds its URLs from it - `"/api"` and `{subdomain}` resolved for this page | Reading `API_BASE` or `getConfig().apiBase` directly: on a product with a subdomain per customer that is the wrong backend |
763
892
  | `publicFetch` | Unauthenticated / marketing origin | Signed-in product APIs |
764
893
  | `getAuthToken` / `parseJwt` | `AUTH_SOURCE` is `localStorage` or `cookie` | Custom header schemes — override `apiFetch` in `utils.ts` |
765
894
  | `rankBy` | User typed a name; API search is exact or ignored | Domain ranking (invoices, coverage, industry classification codes) |
@@ -790,8 +919,8 @@ Import the helpers the way the project's own `agent/utils.ts` does - from
790
919
  `./runtime` - in a recipe file you copy too. `agent/runtime/` is this harness's
791
920
  runtime, copied in for that reason. A build that changes files there names
792
921
  them: save exactly those files in a save of their own as soon as the agent is
793
- on their page. Say you are bringing their project up to date - no need to ask,
794
- it changes nothing for their users. If their account cannot save, carry on.
922
+ on their page; a save of only those files needs no `review-experience`. Say you are bringing their project up to date - no
923
+ need to ask, it changes nothing for their users. If their account cannot save, carry on.
795
924
  Never edit a file there: the next build puts the harness's copy back. To change
796
925
  how a helper behaves, define it in `agent/utils.ts` and export it from there in
797
926
  place of the runtime's. A project with an `agent/utils/` folder has its own
@@ -830,7 +959,8 @@ import is right.
830
959
  goes. **Never widen your own access**: if something is refused for a site or
831
960
  a permission, say so and ask; do not try others.
832
961
  - Do not commit secrets, cookies, HAR files, browser storage, or
833
- `.foldspace-dev/`. Nothing transient goes in `docs/`.
962
+ `.foldspace-dev/` - the build refuses them in the project (`no-secrets`).
963
+ Nothing transient goes in `docs/`.
834
964
  - Local handler changes are not cloud publication.
835
965
  - Creating or publishing Foldspace resources needs the human's yes - except
836
966
  the agent's code: creating its code project, saving to it and publishing it
package/README.md CHANGED
@@ -157,11 +157,14 @@ to that import with `foldspace upgrade --refresh-instructions`.
157
157
  ### Keep the harness current
158
158
 
159
159
  Tenant projects pin an exact `@foldspace_npm/harness` version. Publishing a
160
- newer package does not move them. Check, ask the human, then bump:
160
+ newer package does not move them: each session checks and bumps. Several
161
+ builders can work on one agent, so within one line (0.1.x to a newer 0.1.y,
162
+ `update.autoUpgrade`) the coding agent upgrades without asking and says so in
163
+ one line; across a line (0.1 to 0.2) it asks the human first.
161
164
 
162
165
  ```bash
163
166
  npx --yes @foldspace_npm/harness@latest upgrade --check
164
- foldspace upgrade --yes # only after the human agrees
167
+ foldspace upgrade --yes # within the line: no question; across a line: after the human agrees
165
168
  ```
166
169
 
167
170
  `--check` (and a non-interactive `upgrade` with no `--yes`) never installs.
@@ -180,6 +183,27 @@ FOLDSPACE_SKIP_UPDATE_CHECK=1 npm run build
180
183
 
181
184
  That turns the check off entirely; it does not fix the withdrawn version.
182
185
 
186
+ ### Keep up with the saved code
187
+
188
+ With a code project, several builders can save to one agent. `foldspace sync`
189
+ keeps a folder's `agent/` up to date: it never reaches Foldspace itself - the
190
+ coding agent reads the saved code with the Foldspace tools and passes the
191
+ heads it read.
192
+
193
+ ```bash
194
+ npx foldspace sync check --develop <sha> [--line <sha>] # what to pull, and where to write it
195
+ npx foldspace sync apply # merge it into agent/
196
+ npx foldspace sync saved --head <commit_sha> --paths <a,b> # after a save
197
+ ```
198
+
199
+ `--line` is the head of `foldspace/<Agent Key>`, the line of work the code
200
+ project's instructions save to, once it exists. `apply` keeps a copy of
201
+ `agent/` in `.foldspace-dev/agent-kept/<time>/` and merges file by file
202
+ against what the folder last pulled (kept in `.foldspace-dev/saved-code/`): a
203
+ file only one side changed takes that side; a file both changed takes the
204
+ saved version and is listed under `conflicts`. Saving and publishing are
205
+ unchanged: they follow the code project's instructions.
206
+
183
207
  ### Withdrawing a release (maintainers)
184
208
 
185
209
  When a published version is broken badly enough that nobody should build on
@@ -234,6 +258,10 @@ v1 flags:
234
258
  - **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
235
259
  - **error** `card-contract` - `awaitUserInput: true` on a view card (its `render` uses no `callback`, `cancel` or `readyState`): the chat panel ignores it, but in headless mode the agent waits for a callback that never comes
236
260
  - **error** `no-test-mode-in-bundle` - `armAllInstances` used anywhere in `agent/` (called, or handed on to be called later), or `setTestMode(...)` with anything but a literal `false`. A code project's own copy of the runtime in `agent/runtime/` is not refused for the `setTestMode` inside its `armAllInstances`
261
+ - **error** `no-fixed-customer-address` - on a product with a subdomain per customer (`PER_CUSTOMER_SUBDOMAIN` in `agent/constants.ts`), one customer's address in `API_BASE` or anywhere in `agent/`; while that is undecided, an address whose first part looks like a customer's name (`acme.` - not `app.`, `eu1.`, `reports.`)
262
+ - **error** `token-stays-home` - the user's sign-in token (`getAuthToken`, a session-named storage key, `document.cookie`, followed through variables and helper functions) returned from `execute` or a card, handed to Foldspace, logged, stored again, or sent to a host that is not the product's backend
263
+ - **error** `no-secrets` - API keys, JWTs, bearer tokens, cookie values, secret-named strings, `.har` captures, storage dumps and `.env` files anywhere in the project (not `node_modules/`, `dist/`, `.foldspace-dev/`, `agent/runtime/`)
264
+ - **warning** `known-hosts-only` - `fetch`, `sendBeacon`, `WebSocket`, `EventSource`, `XMLHttpRequest`, `publicFetch` or `import()` to an address written out whose host is neither Foldspace's (`*.foldspace.ai`), the product's (`foldspace.dev.json` hosts, `API_BASE`, the app's own domain) nor written in `agent/constants.ts`
237
265
  - **warning** `card-contract` - any other `awaitUserInput` on a view card, `awaitUserInput` on an input card (not needed), or a `render` that declares `callback`, `cancel` or `readyState` and uses none of them (the SDK treats it as a view card)
238
266
  - **warning** `no-emoji` — emoji in handler or `agent/views` string literals
239
267
  - **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
package/bin/inject.mjs CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  waitForOwnedCdp,
21
21
  writeLaunchSentinel,
22
22
  } from "../src/cdp-ownership.mjs";
23
- import { findChrome } from "../src/chrome-binary.mjs";
23
+ import { findChrome, testWindowArgs } from "../src/chrome-binary.mjs";
24
24
 
25
25
  // Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
26
26
  // hosted builder point the harness at a workspace it controls.
@@ -205,23 +205,7 @@ function nameProfile() {
205
205
  }
206
206
  nameProfile();
207
207
 
208
- const chromeArgs = [
209
- `--user-data-dir=${profileDir}`,
210
- `--remote-debugging-port=${debugPort}`,
211
- // The theme only paints the isolated window Foldspace blue. SDK and action
212
- // code are loaded later by attach over CDP.
213
- `--load-extension=${themeDir}`,
214
- `--disable-extensions-except=${themeDir}`,
215
- // Chrome 136+ dropped --load-extension. Keep the legacy switch for older
216
- // builds, and enable the CDP Extensions domain so we can load it at runtime.
217
- "--disable-features=DisableLoadExtensionCommandLineSwitch",
218
- "--enable-unsafe-extension-debugging",
219
- "--no-first-run",
220
- "--no-default-browser-check",
221
- "--test-type",
222
- "--remote-allow-origins=*",
223
- startUrl,
224
- ];
208
+ const chromeArgs = testWindowArgs({ profileDir, debugPort, themeDir, startUrl });
225
209
 
226
210
  console.log(`\nOpening the test window - profile ${path.relative(root, profileDir)}, debugging port :${debugPort}.`);
227
211
  console.log("The human signs in there; the profile keeps what the site remembers between runs.\n");
package/bin/observe.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * foldspace observe pages
8
8
  * foldspace observe menu
9
- * foldspace observe screen [--click "<label>"] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
9
+ * foldspace observe screen [--click "<label>" [--human-said-yes]] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
10
10
  * foldspace observe read <path-or-url>
11
11
  * foldspace observe auth [--wait 5000]
12
12
  * foldspace observe styles
@@ -30,6 +30,7 @@ import {
30
30
  NAV_SCOPE_SELECTOR,
31
31
  analyzeAuthHeaders,
32
32
  buildAuthSummary,
33
+ buttonRefusal,
33
34
  clickRefusal,
34
35
  displayUrl,
35
36
  isCandidateRequest,
@@ -50,13 +51,14 @@ import {
50
51
  summarizeJson,
51
52
  toHex,
52
53
  } from "../src/observe-core.mjs";
54
+ import { customerSubdomainHint, readCustomerSubdomainSetting } from "../src/customer-subdomain.mjs";
53
55
  import { recordEvent } from "../src/session-events.mjs";
54
56
  import { resolveProjectDir } from "../src/upgrade.mjs";
55
57
 
56
58
  const SUBCOMMANDS = {
57
59
  pages: { values: [], flags: [] },
58
60
  menu: { values: [], flags: [] },
59
- screen: { values: ["click", "goto", "match", "wait"], flags: [] },
61
+ screen: { values: ["click", "goto", "match", "wait"], flags: ["human-said-yes"] },
60
62
  read: { values: [], flags: [] },
61
63
  auth: { values: ["wait"], flags: [] },
62
64
  styles: { values: [], flags: ["write"] },
@@ -123,11 +125,16 @@ async function evaluateInFrames(page, expression, { stopWhen } = {}) {
123
125
  return values;
124
126
  }
125
127
 
126
- /** Page code shared by `menu` (click = null) and `screen --click`. */
127
- function navigationScript(clickLabel) {
128
+ /**
129
+ * Page code shared by `menu` (click = null) and `screen --click`. With
130
+ * humanSaidYes, a plain button anywhere on the page may be clicked too, once
131
+ * buttonRefusal lets its label through.
132
+ */
133
+ function navigationScript(clickLabel, humanSaidYes = false) {
128
134
  return `(() => {
129
135
  ${pickByLabel}
130
136
  ${clickRefusal}
137
+ ${buttonRefusal}
131
138
  ${VISIBLE_JS}
132
139
  const labelOf = (el) =>
133
140
  (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim() ||
@@ -180,17 +187,48 @@ function navigationScript(clickLabel) {
180
187
  }
181
188
  const want = ${JSON.stringify(clickLabel ?? null)};
182
189
  if (want === null) return { items };
190
+ // A button the human said yes to: screened by buttonRefusal, then clicked.
191
+ const clickButton = (el, item) => {
192
+ const why = buttonRefusal(item);
193
+ if (why) return { clicked: false, refused: why };
194
+ el.click();
195
+ return { clicked: true, item, button: true };
196
+ };
183
197
  const index = pickByLabel(items, want);
184
198
  if (index < 0) {
185
199
  const lower = want.toLowerCase();
186
- const elsewhere = [...document.querySelectorAll("a, button, input[type=submit], [role=button], [role=menuitem], [role=tab], [role=link]")]
187
- .some((el) => visible(el) && (labelOf(el) || el.value || "").toLowerCase().includes(lower));
200
+ const anywhere = [...document.querySelectorAll("a, button, [role=button], [role=menuitem], [role=tab], [role=link]")]
201
+ .filter((el) => visible(el) && !el.closest("form") && (labelOf(el) || "").length <= 60);
202
+ const elsewhere = anywhere.some((el) => (labelOf(el) || "").toLowerCase().includes(lower));
203
+ if (${JSON.stringify(Boolean(humanSaidYes))} && elsewhere) {
204
+ const found = anywhere.map((el) => ({ el, label: labelOf(el) }));
205
+ const at = pickByLabel(found, want);
206
+ if (at >= 0) {
207
+ const el = found[at].el;
208
+ const href = el.getAttribute("href");
209
+ if (el.tagName === "A" && href && href !== "#" && !/^javascript:/i.test(href)) {
210
+ return { clicked: false, refused: '"' + found[at].label + '" is a link outside the navigation: open it with --goto ' + new URL(href, location.href).pathname + "." };
211
+ }
212
+ return clickButton(el, {
213
+ label: found[at].label,
214
+ kind: kindOf(el),
215
+ path: null,
216
+ submitsForm: false,
217
+ download: el.hasAttribute("download"),
218
+ filePicker: el.matches("input[type=file], label[for]"),
219
+ expands: el.hasAttribute("aria-expanded") || el.hasAttribute("aria-haspopup"),
220
+ });
221
+ }
222
+ }
188
223
  return { clicked: false, outsideNavigation: elsewhere, available: items.map((item) => item.label) };
189
224
  }
190
225
  const refused = clickRefusal(items[index]);
191
- if (refused) return { clicked: false, refused };
192
- elements[index].click();
193
- return { clicked: true, item: items[index] };
226
+ if (!refused) {
227
+ elements[index].click();
228
+ return { clicked: true, item: items[index] };
229
+ }
230
+ if (!${JSON.stringify(Boolean(humanSaidYes))} || items[index].kind === "link" && items[index].path) return { clicked: false, refused };
231
+ return clickButton(elements[index], items[index]);
194
232
  })()`;
195
233
  }
196
234
 
@@ -326,6 +364,21 @@ async function runPages() {
326
364
  };
327
365
  }
328
366
 
367
+ // Whether the project has settled PER_CUSTOMER_SUBDOMAIN in agent/constants.ts:
368
+ // once it has, observe stops asking.
369
+ function customerSubdomainSetting() {
370
+ try {
371
+ return readCustomerSubdomainSetting(fs.readFileSync(path.join(projectDir, "agent", "constants.ts"), "utf8"));
372
+ } catch {
373
+ return undefined;
374
+ }
375
+ }
376
+
377
+ function customerSubdomainFields(pageUrl, requestUrls) {
378
+ const found = customerSubdomainHint(pageUrl, requestUrls, customerSubdomainSetting());
379
+ return found ? { customerSubdomain: found } : {};
380
+ }
381
+
329
382
  async function runMenu(page) {
330
383
  const perFrame = await evaluateInFrames(page, navigationScript(null));
331
384
  const seen = new Set();
@@ -351,6 +404,7 @@ async function runMenu(page) {
351
404
  count: items.length,
352
405
  items,
353
406
  ...(siblings ? { otherHosts: siblings.hosts, hostsHint: siblings.hint } : {}),
407
+ ...customerSubdomainFields(url, []),
354
408
  ...(items.length ? {} : { note: "No visible navigation found. The app may still be loading, or showing a login page." }),
355
409
  };
356
410
  }
@@ -371,7 +425,7 @@ async function runScreen(page, flags) {
371
425
  if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
372
426
  }
373
427
  if (flags.click !== undefined) {
374
- const results = await evaluateInFrames(page, navigationScript(String(flags.click)), {
428
+ const results = await evaluateInFrames(page, navigationScript(String(flags.click), Boolean(flags["human-said-yes"])), {
375
429
  stopWhen: (value) => value?.clicked === true,
376
430
  });
377
431
  const hit = results.find((value) => value?.clicked);
@@ -381,7 +435,7 @@ async function runScreen(page, flags) {
381
435
  const error = refused
382
436
  ? refused.refused
383
437
  : outside
384
- ? `"${flags.click}" is on the page but not in the app's navigation. observe only clicks navigation, never a control that could create, send, save or delete.`
438
+ ? `"${flags.click}" is on the page but not in the app's navigation. Ask the human in chat whether you may click it, naming it; only after their yes, run this again with --human-said-yes.`
385
439
  : `No navigation item is labelled "${flags.click}".`;
386
440
  throw new ObserveExit(
387
441
  {
@@ -392,7 +446,7 @@ async function runScreen(page, flags) {
392
446
  1,
393
447
  );
394
448
  }
395
- reached = { how: "click", ...publicItem(hit.item) };
449
+ reached = { how: "click", ...publicItem(hit.item), ...(hit.button ? { button: true } : {}) };
396
450
  }
397
451
  if (!reached) {
398
452
  // Nothing to reach: reload so this screen's own requests are seen.
@@ -432,6 +486,7 @@ async function runScreen(page, flags) {
432
486
  writes.set(`${entry.method} ${entry.path}`, entry);
433
487
  }
434
488
  const ranked = rankCandidates(candidates, words, 8);
489
+ const buttonWrote = reached.button && writes.size > 0;
435
490
  return {
436
491
  ok: true,
437
492
  reached,
@@ -440,6 +495,10 @@ async function runScreen(page, flags) {
440
495
  requestsSeen: all.filter(isApiCall).length,
441
496
  candidates: ranked,
442
497
  writesSeen: [...writes.values()].slice(0, 20),
498
+ ...(buttonWrote
499
+ ? { buttonWarning: `The button "${reached.label}" sent ${writes.size} change(s) to the server (writesSeen). Tell the human what it sent, in one line, before anything else.` }
500
+ : {}),
501
+ ...customerSubdomainFields(url, all.filter(isApiCall).map((request) => request.url)),
443
502
  ...(ranked.length
444
503
  ? {}
445
504
  : { note: "No successful JSON GET was seen. The data may load after another click, or arrive inside the HTML." }),