@foldspace_npm/harness 0.1.26 → 0.1.28

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 (120) hide show
  1. package/CLAUDE.md +402 -204
  2. package/README.md +84 -36
  3. package/bin/ask.mjs +19 -9
  4. package/bin/attach.mjs +91 -56
  5. package/bin/badge.mjs +28 -8
  6. package/bin/cli.mjs +23 -6
  7. package/bin/inject.mjs +3 -4
  8. package/bin/observe.mjs +22 -6
  9. package/bin/published.mjs +141 -0
  10. package/package.json +6 -2
  11. package/recipes/INDEX.md +13 -8
  12. package/recipes/README.md +30 -7
  13. package/recipes/account-overview/README.md +6 -6
  14. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  15. package/recipes/account-overview/agent/views/brand.ts +23 -14
  16. package/recipes/account-overview/agent/views/overview.ts +13 -17
  17. package/recipes/bottom-bar/README.md +12 -9
  18. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  19. package/recipes/create-a-record/README.md +97 -0
  20. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  21. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  22. package/recipes/create-a-record/agent/projects.ts +145 -0
  23. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  24. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  25. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  26. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  27. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  28. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  29. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  30. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  31. package/recipes/create-a-record/recipe.json +13 -0
  32. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  33. package/recipes/find-by-name/README.md +3 -1
  34. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  35. package/recipes/lookup-then-view/README.md +73 -0
  36. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  37. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  38. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  39. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  40. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  41. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  42. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  43. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  44. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  45. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  46. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  47. package/recipes/lookup-then-view/recipe.json +11 -0
  48. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  49. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  50. package/recipes/opportunities-at-risk/README.md +5 -5
  51. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  52. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  53. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  54. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  55. package/recipes/pick-from-a-list/README.md +7 -6
  56. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  57. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  58. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  59. package/recipes/prepare-for-a-meeting/README.md +4 -4
  60. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  61. package/recipes/records-list/README.md +6 -6
  62. package/recipes/records-list/agent/accounts.ts +3 -1
  63. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  64. package/recipes/records-list/agent/views/brand.ts +26 -14
  65. package/recipes/server-rendered-forms/README.md +35 -0
  66. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  67. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  68. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  69. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  70. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  71. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  72. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  73. package/recipes/server-rendered-forms/recipe.json +10 -0
  74. package/recipes/several-hosts/README.md +10 -13
  75. package/recipes/several-hosts/agent/hosts.ts +19 -136
  76. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  77. package/recipes/several-hosts/recipe.json +3 -3
  78. package/recipes/swap-the-login-method/README.md +16 -5
  79. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  80. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  81. package/recipes/update-meeting-notes/README.md +10 -4
  82. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  83. package/recipes/upload-contacts/README.md +5 -5
  84. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  85. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  86. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  87. package/recipes/who-is-the-user/README.md +7 -3
  88. package/src/ask-core.mjs +16 -4
  89. package/src/attach-daemon.mjs +9 -2
  90. package/src/attach-helpers.mjs +105 -0
  91. package/src/attach-preflight.mjs +34 -11
  92. package/src/badge-core.mjs +1 -1
  93. package/src/cdp-client.mjs +15 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +102 -47
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/keep-focus.mjs +17 -13
  100. package/src/lint/index.mjs +8 -1
  101. package/src/lint/rules.mjs +240 -43
  102. package/src/observe-core.mjs +163 -34
  103. package/src/published-core.mjs +392 -0
  104. package/src/region.mjs +1 -1
  105. package/src/runtime/agent.ts +16 -12
  106. package/src/runtime/http.ts +25 -15
  107. package/src/runtime/match.ts +1 -1
  108. package/src/runtime/render.ts +4 -4
  109. package/src/transports/README.md +2 -2
  110. package/src/upgrade.mjs +33 -10
  111. package/templates/agent-starter/CLAUDE.md +8 -5
  112. package/templates/agent-starter/README.md +32 -33
  113. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  114. package/templates/agent-starter/agent/actions/index.ts +2 -2
  115. package/templates/agent-starter/agent/constants.ts +4 -3
  116. package/templates/agent-starter/agent/utils.ts +15 -18
  117. package/templates/agent-starter/claude-settings.json +2 -1
  118. package/templates/agent-starter/docs/app-profile.md +19 -14
  119. package/templates/agent-starter/foldspace.dev.json +1 -1
  120. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
@@ -0,0 +1,192 @@
1
+ // Reading a server-rendered form and building the body a browser would post.
2
+ // The form is read live at run time, so the hidden fields the app expects (its
3
+ // CSRF token, a nonce, a form id) go along unchanged; the fields to fill are
4
+ // found by their labels. Nothing here touches the network; api/pages.ts does.
5
+
6
+ export type Field = { name: string; label: string; type: string; value: string };
7
+ export type FoundForm = { doc: Document; form: Element; fields: Field[] };
8
+
9
+ const clean = (text: string | null | undefined) => (text ?? "").replace(/[*\s]+/g, " ").trim();
10
+
11
+ /** The label a person sees next to a field, lower-cased - or "" when there is none. */
12
+ export function labelText(doc: Document, field: Element): string {
13
+ const id = field.getAttribute("id");
14
+ const byFor = id ? doc.querySelector(`label[for="${id.replace(/"/g, '\\"')}"]`) : null;
15
+ const label = byFor ?? field.closest("label");
16
+ const own = clean(label?.textContent).toLowerCase();
17
+ if (own) return own;
18
+ return clean(field.getAttribute("aria-label") || field.getAttribute("placeholder")).toLowerCase();
19
+ }
20
+
21
+ /** The form on the page that has a field with this label, or null (a sign-in page, for one). */
22
+ export function findForm(doc: Document, hasField: RegExp): FoundForm | null {
23
+ for (const form of Array.from(doc.querySelectorAll("form"))) {
24
+ const controls = Array.from(form.querySelectorAll("input,textarea,select"));
25
+ if (!controls.some((field) => hasField.test(labelText(doc, field)))) continue;
26
+ const fields = controls
27
+ .map((field) => ({
28
+ name: field.getAttribute("name") ?? "",
29
+ label: labelText(doc, field),
30
+ type: (field.getAttribute("type") ?? field.tagName.toLowerCase()).toLowerCase(),
31
+ value: valueOf(field),
32
+ }))
33
+ .filter((field) => field.name);
34
+ return { doc, form, fields };
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function valueOf(field: Element): string {
40
+ if (field.tagName === "TEXTAREA") return field.textContent ?? "";
41
+ if (field.tagName === "SELECT") {
42
+ const chosen = field.querySelector("option[selected]") ?? field.querySelector("option");
43
+ return chosen?.getAttribute("value") ?? chosen?.textContent ?? "";
44
+ }
45
+ return field.getAttribute("value") ?? "";
46
+ }
47
+
48
+ /** The field a person would type into for this label, or null. Hidden fields are never matched. */
49
+ export function fieldByLabel(found: FoundForm, pattern: RegExp): Field | null {
50
+ return found.fields.find((field) => field.type !== "hidden" && pattern.test(field.label)) ?? null;
51
+ }
52
+
53
+ /**
54
+ * What the browser sends when the submit button is pressed: every named,
55
+ * enabled field - hidden ones included, file inputs left out - with the values
56
+ * given here on top. An unchecked box, a radio group nobody chose and a
57
+ * multiple list with nothing chosen send nothing, as in a browser; nor does a
58
+ * field in a disabled fieldset.
59
+ */
60
+ export function formBody(found: FoundForm, values: Record<string, string>): URLSearchParams {
61
+ const body = new URLSearchParams();
62
+ for (const field of Array.from(found.form.querySelectorAll("input,textarea,select"))) {
63
+ const name = field.getAttribute("name");
64
+ if (!name || field.hasAttribute("disabled") || field.closest("fieldset[disabled]")) continue;
65
+ const type = (field.getAttribute("type") ?? "").toLowerCase();
66
+ if (type === "submit" || type === "button" || type === "reset" || type === "image" || type === "file") continue;
67
+ // A box or radio with no value of its own sends "on", as in a browser.
68
+ const ownValue = field.getAttribute("value") ?? "on";
69
+ if (name in values) {
70
+ if (type === "radio" || type === "checkbox") {
71
+ if (ownValue === values[name]) body.append(name, values[name]);
72
+ continue;
73
+ }
74
+ if (!body.has(name)) body.append(name, values[name]);
75
+ continue;
76
+ }
77
+ if (type === "checkbox" || type === "radio") {
78
+ if (field.hasAttribute("checked")) body.append(name, ownValue);
79
+ continue;
80
+ }
81
+ // A multiple list sends each chosen option, and nothing when none is chosen.
82
+ if (field.tagName === "SELECT" && field.hasAttribute("multiple")) {
83
+ for (const option of Array.from(field.querySelectorAll("option[selected]"))) {
84
+ body.append(name, option.getAttribute("value") ?? (option.textContent ?? "").replace(/\s+/g, " ").trim());
85
+ }
86
+ continue;
87
+ }
88
+ body.append(name, valueOf(field));
89
+ }
90
+ const submit = found.form.querySelector("[type=submit][name],button[name]:not([type])");
91
+ if (submit?.getAttribute("name")) body.append(submit.getAttribute("name")!, submit.getAttribute("value") ?? "");
92
+ return body;
93
+ }
94
+
95
+ /** Where the form posts, as a full address. */
96
+ export function formAction(found: FoundForm, pageUrl: string): string {
97
+ return new URL(found.form.getAttribute("action") || pageUrl, pageUrl).toString();
98
+ }
99
+
100
+ export type Enctype = "urlencoded" | "multipart";
101
+ export function formEnctype(found: FoundForm): Enctype {
102
+ return /multipart/i.test(found.form.getAttribute("enctype") ?? "") ? "multipart" : "urlencoded";
103
+ }
104
+
105
+ /** The same body as multipart/form-data, for forms that declare it. */
106
+ export function asMultipart(body: URLSearchParams): FormData {
107
+ const data = new FormData();
108
+ body.forEach((value, name) => data.append(name, value));
109
+ return data;
110
+ }
111
+
112
+ /**
113
+ * Validation messages on a re-rendered form: the detail, not just the banner.
114
+ * A banner with a success, info or notice class, or with "success" in its
115
+ * text, is skipped; any other banner counts as an error.
116
+ */
117
+ export function formErrors(doc: Document): string[] {
118
+ const messages: string[] = [];
119
+ const add = (text: string | null | undefined) => {
120
+ const line = (text ?? "").replace(/\s+/g, " ").replace(/^×\s*/, "").trim();
121
+ if (line && line.length < 400 && !messages.includes(line)) messages.push(line);
122
+ };
123
+ const banners = ".alert-danger,.alert-error,.alert,.errors,.error_explanation,#error_explanation,.flash-error,[role=alert]";
124
+ for (const box of Array.from(doc.querySelectorAll(banners))) {
125
+ if (box.matches(".alert-success,.alert-info,.notice,.flash-notice") || /success/i.test(box.textContent ?? "")) continue;
126
+ const items = Array.from(box.querySelectorAll("li"));
127
+ if (items.length) items.forEach((item) => add(item.textContent));
128
+ else add(box.textContent);
129
+ }
130
+ const inline = ".help-block.error,.help-inline,.invalid-feedback,.error-message,.field-error,[aria-invalid=true] + .help";
131
+ for (const node of Array.from(doc.querySelectorAll(inline))) add(node.textContent);
132
+ for (const wrap of Array.from(doc.querySelectorAll(".field_with_errors,.has-error,[aria-invalid=true]"))) {
133
+ const field = wrap.matches("input,textarea,select") ? wrap : wrap.querySelector("input,textarea,select");
134
+ const label = field ? labelText(doc, field) : "";
135
+ if (label && !messages.some((line) => line.toLowerCase().includes(label))) add(`The ${label} field was marked as not valid.`);
136
+ }
137
+ return messages.slice(0, 8);
138
+ }
139
+
140
+ /** A 400 or 422 answer to a form post: the app refused the form, and its page says why. */
141
+ export function refusedForm(status: number): boolean {
142
+ return status === 400 || status === 422;
143
+ }
144
+
145
+ /**
146
+ * Did the write land? `created` only when the app did not refuse the form, the
147
+ * answer came from a different path than the form page, the same form (found
148
+ * by the same label, posting where this one posted) is not on it, and
149
+ * formErrors found nothing there. Anything else is `not_created`, with the
150
+ * messages formErrors found. A form on the answer page that posts elsewhere -
151
+ * the new record's own edit form, or a quick-add box in the header, say - is
152
+ * not the form again.
153
+ */
154
+ export function judgeSubmit(
155
+ formUrl: string,
156
+ answer: { url: string; html: string; status?: number },
157
+ knownBy: RegExp,
158
+ postedTo: string,
159
+ ): { status: "created" | "not_created"; url: string; errors: string[] } {
160
+ const doc = new DOMParser().parseFromString(answer.html, "text/html");
161
+ const errors = formErrors(doc);
162
+ const refused = refusedForm(answer.status ?? 200);
163
+ const movedOn = new URL(answer.url).pathname !== new URL(formUrl).pathname;
164
+ // Every form with the label counts, not only the first on the page.
165
+ const postedPath = new URL(postedTo).pathname;
166
+ const formAgain = Array.from(doc.querySelectorAll("form")).some(
167
+ (form) =>
168
+ Array.from(form.querySelectorAll("input,textarea,select")).some((field) => knownBy.test(labelText(doc, field))) &&
169
+ new URL(form.getAttribute("action") || answer.url, answer.url).pathname === postedPath,
170
+ );
171
+ return !refused && movedOn && !formAgain && !errors.length
172
+ ? { status: "created", url: answer.url, errors: [] }
173
+ : { status: "not_created", url: answer.url, errors };
174
+ }
175
+
176
+ /** Rows of a table on a server-rendered list page: header names -> cell text. */
177
+ export function tableRows(doc: Document, tableSelector = "table"): Record<string, string>[] {
178
+ const table = doc.querySelector(tableSelector);
179
+ if (!table) return [];
180
+ const headers = Array.from(table.querySelectorAll("thead th, thead td")).map((cell) => clean(cell.textContent));
181
+ const rows: Record<string, string>[] = [];
182
+ for (const row of Array.from(table.querySelectorAll("tbody tr, tr"))) {
183
+ const cells = Array.from(row.querySelectorAll("td"));
184
+ if (!cells.length) continue;
185
+ const record: Record<string, string> = {};
186
+ cells.forEach((cell, index) => {
187
+ record[headers[index] || `column${index + 1}`] = clean(cell.textContent);
188
+ });
189
+ rows.push(record);
190
+ }
191
+ return rows;
192
+ }
@@ -0,0 +1,9 @@
1
+ <!doctype html><html><body>
2
+ <div class="alert alert-danger">Unable to create record, there is 1 error</div>
3
+ <form action="/records" method="post">
4
+ <input type="hidden" name="_token" value="nonce-8f3a">
5
+ <div class="field_with_errors"><label for="rec_title">Title *</label><input id="rec_title" name="record[title]" type="text" value=""></div>
6
+ <span class="help-block error">Title can't be blank</span>
7
+ <input type="submit" name="commit" value="Create record">
8
+ </form>
9
+ </body></html>
@@ -0,0 +1,17 @@
1
+ <!doctype html><html><body>
2
+ <h1>New record</h1>
3
+ <form action="/records" method="post">
4
+ <input type="hidden" name="_token" value="nonce-8f3a">
5
+ <input type="hidden" name="_form" value="add">
6
+ <label for="rec_title">Title *</label><input id="rec_title" name="record[title]" type="text">
7
+ <label for="rec_desc">Description</label><textarea id="rec_desc" name="record[description]"></textarea>
8
+ <fieldset><legend>Visibility</legend>
9
+ <label><input type="radio" name="record[visibility]" value="everyone"> Everyone</label>
10
+ <label><input type="radio" name="record[visibility]" value="team"> My team</label>
11
+ </fieldset>
12
+ <label><input type="checkbox" name="record[notify]" value="1"> Notify followers</label>
13
+ <label><input type="checkbox" name="record[archived]" value="1" checked> Archived</label>
14
+ <select name="record[kind]"><option value="note">Note</option><option value="task" selected>Task</option></select>
15
+ <input type="submit" name="commit" value="Create record">
16
+ </form>
17
+ </body></html>
@@ -0,0 +1,9 @@
1
+ <!doctype html><html><body>
2
+ <table class="records">
3
+ <thead><tr><th>Title</th><th>Kind</th><th>Updated</th></tr></thead>
4
+ <tbody>
5
+ <tr><td><a href="/records/1">Quarterly review</a></td><td>Task</td><td>Sep 29, 2026</td></tr>
6
+ <tr><td><a href="/records/2">Welcome note</a></td><td>Note</td><td>Sep 28, 2026</td></tr>
7
+ </tbody>
8
+ </table>
9
+ </body></html>
@@ -0,0 +1,4 @@
1
+ <!doctype html><html><body>
2
+ <div class="alert alert-success">Record was successfully created</div>
3
+ <h1>Quarterly review</h1>
4
+ </body></html>
@@ -0,0 +1,10 @@
1
+ {
2
+ "title": "Server-rendered pages and forms",
3
+ "level": "L4",
4
+ "family": "server-rendered",
5
+ "kind": "action",
6
+ "action": "create_record_via_form",
7
+ "entry": "agent/actions/create_record_via_form.ts",
8
+ "outcome": "On a product that renders its pages on the server and has no JSON API, records are read off its pages and a record is created through the same form a person would fill in",
9
+ "provenBy": 1
10
+ }
@@ -1,35 +1,32 @@
1
1
  # One product, several hosts
2
2
 
3
- **The product lives on more than one host** — `www.`, `analyze.`, `communicate.`, `admin.` under one domain — **and the agent should work on all of them.**
3
+ **The product lives on more than one host** - `www.example.com`, `app.example.com`, `reports.example.com`, `admin.example.com` - **and the agent should work on all of them.**
4
4
 
5
5
  **Proven by 1 production build**, which met each part of this the hard way, one at a time.
6
6
 
7
7
  ## Find out at the start
8
8
 
9
- `npx foldspace observe menu` names the hosts a page links to that belong to the same product and that this project does not cover (`otherHosts`, `hostsHint`). `init` covers only the host you gave it. Tell the human which hosts you found and ask for a yes before adding the pattern (`"*.example.com"`) to `hosts` in `foldspace.dev.json`; then `npm run inject` and attach again. After that you open screens on the other hosts yourself: `npx foldspace observe screen --goto https://analyze.example.com/reports`.
9
+ `npx foldspace observe menu` names the hosts a page links to that belong to the same product and that this project does not cover (`otherHosts`, `hostsHint`). `init` covers the host you gave it and that host's subdomains, not its sibling hosts. Tell the human which hosts you found and ask for a yes before adding the pattern (`"*.example.com"`) to `hosts` in `foldspace.dev.json`. On a yes: add it, then `npx foldspace attach --stop`, `npm run inject`, and attach again with the flags it ran with (`--daemon`, plus `--bootstrap` or `--replace` if it had one) - a running attach keeps the hosts it started with. After that you open screens on the other hosts yourself: `npx foldspace observe screen --goto https://reports.example.com/`. On a no: build only on the hosts the project covers.
10
10
 
11
11
  ## What this recipe gives
12
12
 
13
13
  | Piece | What it does |
14
14
  |---|---|
15
- | `wrongHost(tool)` | First line of an action. On the right host: `null`. On another: `{ status: "wrong_page", required_page, current_page }`, and no request is made |
16
- | `installResumeAcrossHosts()` | **A registered workaround.** After the agent moves the user to the right host, the conversation carries on there by itself |
15
+ | `wrongHost(part)` | First line of an action. On the right host: `null`. On another: `{ status: "wrong_page", required_page, current_page }`, and no request is made |
17
16
 
18
- In Agent Studio, each action's instructions say: *on `wrong_page`, navigate to that tool's route, then call the action again.* Give each tool a navigation route with a full address.
17
+ The SDK does the rest. After the agent moves the user to another host under the same domain (`reports.example.com` from `app.example.com`), the user, their conversations and the agent's turn carry across: the SDK keeps its resume note in a cookie on that shared domain (SDK of 2026-10-04, PLG-5962). ⚠️ Not when the page's Foldspace install sets the SDK option `fullDomainCookie`: the SDK then keeps its cookies - the user's session and the resume note - on the host that wrote them, so the agent does not carry on after the move. The option is not in the public docs; this was read from the SDK script. If the install sets it, tell the human in one sentence that the agent does not carry on after it moves someone to another host, and add a row for it under Workarounds in `docs/app-profile.md`. This recipe is the guard for an action asked from the wrong host.
18
+
19
+ In Agent Studio, add to each guarded action's instructions: *On `wrong_page`, navigate to the route for the page named in `required_page`, then call this action again.* Give each part of the product a navigation route with a full address.
19
20
 
20
21
  ## Adapt it
21
22
 
22
23
  | File | Change |
23
24
  |---|---|
24
- | `agent/hosts.ts` → `TOOLS` | The product's tools and how to recognise each one's host (and path, if two tools share a host) |
25
- | `agent/actions/index.ts` | `installResumeAcrossHosts()` once, next to the registry |
25
+ | `agent/hosts.ts` → `PARTS` | The parts of the product and how to recognise each one's host (and path, if two share a host) |
26
26
  | each action | `const wrong = wrongHost("reports"); if (wrong) return { success: true, ...wrong };` before any request |
27
- | `docs/app-profile.md` → Workarounds | One row for the resume workaround: what, why, `agent/hosts.ts`, verified or assumed, "goes when PLG-5962 ships" |
28
27
 
29
28
  ## What that build learned the hard way
30
29
 
31
- - **An action can only read the host it runs on.** Asked from Analyze, a Communicate action failed its first read in 150 ms. That is the browser, not the app.
32
- - **Who the user is, and their conversations, do carry across hosts** — Foldspace keeps the identity on the shared domain. What does not carry is the agent *continuing its turn* after it navigated: the note it leaves itself is kept per host, and the page that is leaving reports the navigation as finished.
33
- - **Reopening the conversation is not enough.** The first attempt restored the note on the new host; the right chat opened and nothing happened. It took a message to start the next turn — it shows as a user bubble, "Continue on this page."
34
- - **Ask the browser for the shared domain.** Counting dots gets `.co.uk` wrong.
35
- - **Remove it when the SDK carries on by itself.** This is Foldspace's to fix, not the customer's code to keep.
30
+ - **An action reads only what the page it runs on may read.** Asked from the reports host, a messages action failed its first read in 150 ms. That is the browser, not the app.
31
+ - **Who the user is, and their conversations, carry across hosts** - Foldspace keeps the identity on the shared domain (not with `fullDomainCookie`, above).
32
+ - **The agent's turn did not carry across until PLG-5962.** The build handed the conversation over itself, in a cookie on the shared domain (`installResumeAcrossHosts`). The SDK does it now. If a project still has that code, remove it, its call in `agent/actions/index.ts` and its row under Workarounds in `docs/app-profile.md`. ⚠️ Left running alongside the SDK's own resume, it would send an extra "Continue on this page." from the user and start a second turn (read from the code, not seen live).
@@ -1,146 +1,29 @@
1
- // One product, several hosts (www. / analyze. / communicate. / admin. ...).
1
+ // One product, several hosts (www. / app. / reports. / admin. ...).
2
2
  //
3
- // Two facts decide everything here:
4
- // 1. An action runs inside the page, and a page may only read its own host.
5
- // Asked from the wrong host, the request cannot succeed - so say which
6
- // page is needed instead of failing (`wrongHost`).
7
- // 2. WORKAROUND - registered in docs/app-profile.md, goes when PLG-5962 ships.
8
- // When the agent moves the user to another host, the SDK does not carry
9
- // on with the turn: its resume note is kept per host, and the page that
10
- // is leaving reports the navigation as done. The user and the
11
- // conversation do carry over; only the "carry on" is lost. So the
12
- // conversation id is handed across in a 60-second cookie on the domain
13
- // the hosts share, and the new page reopens it and starts the next turn
14
- // (`installResumeAcrossHosts`). Established on a live build, 2026-09-25.
15
-
16
- import { getAgent } from "./utils";
17
-
18
- /** ADAPT: the tools of this product and the host each one lives on. */
19
- export const TOOLS: Record<string, { label: string; onThisHost: (hostname: string, path: string) => boolean }> = {
20
- reports: { label: "Reports", onThisHost: (hostname) => hostname.startsWith("analyze.") },
21
- messages: { label: "Messages", onThisHost: (hostname) => hostname.startsWith("communicate.") },
3
+ // An action runs inside the page, and a page reads another host's signed-in
4
+ // data only if that host allows it. When it does not, the request from the
5
+ // wrong host fails - so say which page is needed instead (`wrongHost`). The
6
+ // agent moves the user there with a navigation route, and the SDK carries the
7
+ // user, their conversation and the agent's turn across to another host under
8
+ // the same domain (PLG-5962, SDK of 2026-10-04; the README names the one SDK
9
+ // option that stops it).
10
+
11
+ /** ADAPT: the parts of this product and the host each one lives on. */
12
+ export const PARTS: Record<string, { label: string; onThisHost: (hostname: string, path: string) => boolean }> = {
13
+ reports: { label: "Reports", onThisHost: (hostname) => hostname.startsWith("reports.") },
14
+ messages: { label: "Messages", onThisHost: (hostname) => hostname.startsWith("app.") },
22
15
  };
23
16
 
24
17
  export type WrongPage = { status: "wrong_page"; required_page: string; current_page: string };
25
18
 
26
- let resumeExpected = false;
27
-
28
19
  /**
29
- * First line of every action that reads or writes one tool: null on the right
30
- * host, otherwise the state to hand back - no request is made. The action's
31
- * Studio instructions say: on `wrong_page`, navigate to that tool's route,
32
- * then call the action again.
20
+ * First line of every action that reads or writes one part of the product:
21
+ * null on the right host, otherwise the state to hand back - no request is
22
+ * made. The action's Studio instructions say: on `wrong_page`, navigate to the
23
+ * route for the page named in `required_page`, then call this action again.
33
24
  */
34
- export function wrongHost(tool: keyof typeof TOOLS): WrongPage | null {
35
- const wanted = TOOLS[tool];
25
+ export function wrongHost(part: keyof typeof PARTS): WrongPage | null {
26
+ const wanted = PARTS[part];
36
27
  if (!wanted || wanted.onThisHost(window.location.hostname, window.location.pathname)) return null;
37
- resumeExpected = true;
38
28
  return { status: "wrong_page", required_page: wanted.label, current_page: window.location.hostname };
39
29
  }
40
-
41
- // ---------------------------------------------------------------- the workaround
42
-
43
- const SDK_NAV_KEY = "_eucera_nav"; // the SDK's own resume note (per host)
44
- const COOKIE = "fs_resume";
45
- const TTL_SECONDS = 60;
46
- export const RESUME_MESSAGE = "Continue on this page.";
47
-
48
- type Carried = { conversationId: string; origin: string; expiry: number };
49
-
50
- function readCookie(name: string): string | null {
51
- const hit = document.cookie.split("; ").find((entry) => entry.startsWith(`${name}=`));
52
- return hit ? hit.slice(name.length + 1) : null;
53
- }
54
-
55
- /**
56
- * The widest domain the browser lets this page set a cookie on - the domain
57
- * the product's hosts share. Asked of the browser, the way the SDK does it:
58
- * guessing from the number of dots gets `.co.uk` wrong.
59
- */
60
- export function sharedCookieDomain(): string | null {
61
- const labels = window.location.hostname.split(".");
62
- if (labels.length < 2) return null;
63
- const probe = "fs_domain_probe";
64
- for (let from = labels.length - 1; from >= 0; from -= 1) {
65
- const domain = labels.slice(from).join(".");
66
- document.cookie = `${probe}=1; domain=${domain}; path=/; max-age=30`;
67
- if (readCookie(probe) === "1") {
68
- document.cookie = `${probe}=; domain=${domain}; path=/; max-age=0`;
69
- return domain;
70
- }
71
- }
72
- return null;
73
- }
74
-
75
- /** The page that is leaving: hand the conversation id to the next host. */
76
- function carryOut(): void {
77
- if (!resumeExpected) return;
78
- try {
79
- const raw = window.localStorage.getItem(SDK_NAV_KEY);
80
- if (!raw) return;
81
- const record = JSON.parse(raw) as { payload?: { conversationId?: string }; normalizedUrl?: string };
82
- const conversationId = record.payload?.conversationId;
83
- if (!conversationId || !record.normalizedUrl || !/^https?:\/\//.test(record.normalizedUrl)) return;
84
- const target = new URL(record.normalizedUrl);
85
- const domain = sharedCookieDomain();
86
- // Only to another host of the same product - never off the shared domain.
87
- if (!domain || target.origin === window.location.origin) return;
88
- if (target.hostname !== domain && !target.hostname.endsWith(`.${domain}`)) return;
89
- const carried: Carried = { conversationId, origin: target.origin, expiry: Date.now() + TTL_SECONDS * 1000 };
90
- const secure = window.location.protocol === "https:" ? "; secure" : "";
91
- document.cookie = `${COOKIE}=${encodeURIComponent(JSON.stringify(carried))}; domain=${domain}; path=/; max-age=${TTL_SECONDS}; samesite=lax${secure}`;
92
- } catch (error) {
93
- console.warn("[several-hosts] could not hand the conversation over", error);
94
- }
95
- }
96
-
97
- function waitForAgent(timeoutMs: number): Promise<any | null> {
98
- const started = Date.now();
99
- return new Promise((resolve) => {
100
- const tick = () => {
101
- const agent = getAgent();
102
- if (agent && typeof agent.message === "function") return resolve(agent);
103
- if (Date.now() - started > timeoutMs) return resolve(null);
104
- setTimeout(tick, 250);
105
- };
106
- tick();
107
- });
108
- }
109
-
110
- /** The page that arrives: reopen the conversation and start its next turn. */
111
- async function bringIn(waitMs: number, settleMs: number): Promise<"resumed" | "nothing" | "no_agent"> {
112
- try {
113
- const value = readCookie(COOKIE);
114
- if (!value) return "nothing";
115
- const domain = sharedCookieDomain();
116
- // Used once: whatever happens next, the note is gone.
117
- if (domain) document.cookie = `${COOKIE}=; domain=${domain}; path=/; max-age=0`;
118
- const carried = JSON.parse(decodeURIComponent(value)) as Carried;
119
- if (carried.origin !== window.location.origin || carried.expiry < Date.now()) return "nothing";
120
- const agent = await waitForAgent(waitMs);
121
- if (!agent) {
122
- console.warn("[several-hosts] the agent did not appear; the conversation was not resumed");
123
- return "no_agent";
124
- }
125
- // The chat frame has to load before it can switch conversation and send.
126
- await new Promise((resolve) => setTimeout(resolve, settleMs));
127
- agent.selectConversation(carried.conversationId);
128
- agent.message(RESUME_MESSAGE);
129
- return "resumed";
130
- } catch (error) {
131
- console.warn("[several-hosts] could not resume the conversation", error);
132
- return "nothing";
133
- }
134
- }
135
-
136
- let installed: Promise<"resumed" | "nothing" | "no_agent"> | null = null;
137
-
138
- /** Call once from agent/actions/index.ts, next to the registry. Safe to call twice. */
139
- export function installResumeAcrossHosts({ waitMs = 20_000, settleMs = 2_500 } = {}): Promise<
140
- "resumed" | "nothing" | "no_agent"
141
- > {
142
- if (installed) return installed;
143
- installed = bringIn(waitMs, settleMs);
144
- window.addEventListener("pagehide", carryOut);
145
- return installed;
146
- }
@@ -0,0 +1,6 @@
1
+ {
2
+ "note": "This recipe makes no network calls. What wrongHost(\"reports\") hands back when the user is on app.example.com:",
3
+ "status": "wrong_page",
4
+ "required_page": "Reports",
5
+ "current_page": "app.example.com"
6
+ }
@@ -2,9 +2,9 @@
2
2
  "title": "One product, several hosts",
3
3
  "level": "any",
4
4
  "family": "several-hosts",
5
- "kind": "page-setup",
5
+ "kind": "guard",
6
6
  "entry": "agent/hosts.ts",
7
- "setup": { "import": "installResumeAcrossHosts", "from": "../hosts" },
8
- "outcome": "An action asked from the wrong host says so instead of failing, and the conversation carries on after the agent moves the user to the right host",
7
+ "guard": { "import": "wrongHost", "from": "../hosts", "part": "reports" },
8
+ "outcome": "An action asked from the wrong host says which page it needs instead of failing",
9
9
  "provenBy": 1
10
10
  }
@@ -16,13 +16,21 @@ because the browser session was missing.
16
16
  ## Adapt it
17
17
 
18
18
  Copy the variant you need over your project's `agent/utils.ts`. Actions keep
19
- importing from `../utils`, so nothing else changes — that is what the seam is
20
- for.
19
+ importing from `../utils`; the variant re-exports every helper the recipes
20
+ import.
21
+
22
+ `getAuthToken` is among them, but returns null with either variant: neither
23
+ passes `AUTH_SOURCE` to `configure`. So `who-is-the-user` compiles on these
24
+ apps and identifies no one. To identify the user, change its `sessionIdentity`
25
+ to read the id from where you **observed** it: a user object in
26
+ `localStorage`, or an app call that describes the signed-in person, not the
27
+ account (check it against a second account member first, as that recipe
28
+ says). If you observed neither, leave the user anonymous.
21
29
 
22
30
  | In the file | Change |
23
31
  |---|---|
24
32
  | `SESSION_STORAGE_KEY`, `SESSION_HEADER` | The key and header name you **observed** |
25
- | The cookies variant's extra headers | Only what you observed the app sending. One build needed `x-requested-with`; most do not |
33
+ | `EXTRA_HEADERS` in the cookies variant | What you **observed** the app sending besides the cookies; most apps send none |
26
34
  | `API_BASE` in `constants.ts` | For a same-origin app, the app's own origin. Empty stays a failure on purpose, so a guessed host cannot look like success |
27
35
 
28
36
  ## What those builds learned the hard way
@@ -36,5 +44,8 @@ for.
36
44
  inside an object. Strip the quotes; one app switched between the first two.
37
45
  - **With cookies there is no token to check first**, so "signed out" is only
38
46
  known from the 401. Let it through as `signed_out` instead of pre-empting it.
39
- - **Do not `export *`** alongside your own `apiFetch` — name what you re-export,
40
- so the default cannot come back by accident.
47
+ - **Name what you re-export; never `export *`.** The harness's own helpers
48
+ that call the app's API return a config failure here, because the variants
49
+ set no `authSource`. Naming each one makes a new harness helper a choice -
50
+ re-export it, or give it its own version like `apiFetch` - instead of a
51
+ silent re-export.
@@ -24,8 +24,17 @@ export async function apiFetch<T = unknown>(
24
24
  init: RequestInit = {},
25
25
  timeoutMs = 15_000,
26
26
  ): Promise<ApiResult<T>> {
27
+ // Empty API_BASE is a failure on purpose: a guessed host cannot look like
28
+ // success. `error` is drawn on the card and read by the agent - it says what
29
+ // is true; the builder's hint goes in `detail`, which only the console sees.
27
30
  if (!API_BASE) {
28
- return { ok: false, status: 0, reason: "config", error: "API_BASE is not set — capture the app's XHR first." };
31
+ return {
32
+ ok: false,
33
+ status: 0,
34
+ reason: "config",
35
+ error: "This is not set up yet.",
36
+ detail: "API_BASE is not set - capture the app's XHR first.",
37
+ };
29
38
  }
30
39
  const session = readSession();
31
40
  if (!session) return { ok: false, status: 401, reason: "signed_out", error: "Not signed in." };
@@ -54,8 +63,45 @@ export async function apiFetch<T = unknown>(
54
63
  }
55
64
  }
56
65
 
57
- // Everything else still comes from the harness. Named, NOT `export *` — that
58
- // would put the default apiFetch back next to this one.
66
+ /** The same sign-in as `apiFetch`, for a file: the answer comes back as bytes. */
67
+ export async function apiFetchBinary(
68
+ path: string,
69
+ init: RequestInit = {},
70
+ timeoutMs = 15_000,
71
+ ): Promise<ApiResult<Uint8Array>> {
72
+ if (!API_BASE) {
73
+ return {
74
+ ok: false,
75
+ status: 0,
76
+ reason: "config",
77
+ error: "This is not set up yet.",
78
+ detail: "API_BASE is not set - capture the app's XHR first.",
79
+ };
80
+ }
81
+ const session = readSession();
82
+ if (!session) return { ok: false, status: 401, reason: "signed_out", error: "Not signed in." };
83
+
84
+ const controller = new AbortController();
85
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
86
+ try {
87
+ const res = await fetch(`${API_BASE}${path}`, {
88
+ ...init,
89
+ signal: controller.signal,
90
+ headers: { [SESSION_HEADER]: session, ...(init.headers ?? {}) },
91
+ });
92
+ if (!res.ok) return httpFailure(res, "That file could not be returned.");
93
+ return { ok: true, status: res.status, data: new Uint8Array(await res.arrayBuffer()) };
94
+ } catch (error) {
95
+ return networkFailure(error, "Download timed out.", "Could not reach the server.");
96
+ } finally {
97
+ clearTimeout(timer);
98
+ }
99
+ }
100
+
101
+ // Everything else comes from the harness, by name. Give a helper that calls
102
+ // the app's API its own version above, as apiFetch and apiFetchBinary have:
103
+ // the harness's returns a config failure here, because this file sets no
104
+ // `authSource`. For the same reason getAuthToken returns null (see the README).
59
105
  export {
60
106
  getAgent,
61
107
  armAllInstances,
@@ -69,5 +115,8 @@ export {
69
115
  renderError,
70
116
  renderFatal,
71
117
  renderFailure,
118
+ httpFailure,
119
+ networkFailure,
120
+ getAuthToken,
72
121
  } from "@foldspace_npm/harness/runtime";
73
122
  export type { ApiResult, ApiFailure, FailureReason, ViewHost } from "@foldspace_npm/harness/runtime";