@foldspace_npm/harness 0.1.23 → 0.1.25

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
@@ -78,9 +78,11 @@ current CLI contract — risk, prerequisites, effects, next step.
78
78
  one that creates data - say so instead.
79
79
 
80
80
  **Say when the test window is yours, and when it is theirs - on the page and
81
- in chat.** You and the human share one Chrome window. Before you start looking
82
- at the app or testing in it, run `npx foldspace badge working` (a bar across
83
- the top of the window says Foldspace is building here, leave it alone) and say
81
+ in chat.** You and the human share one Chrome window. `observe` and `ask`
82
+ raise the working pill by themselves for as long as they run, and drop it
83
+ after. For the longer stretch - from the moment you start working on their
84
+ window until it is their turn - run `npx foldspace badge working` (the pill at
85
+ the foot of the window says Foldspace is building here, leave it alone) and say
84
86
  so in chat: *"I'm going to work in that Chrome window for a few minutes -
85
87
  please leave it alone until I tell you it's ready."* When it is their turn,
86
88
  `npx foldspace badge ready` takes the pill away (the small corner tag comes
@@ -123,6 +125,31 @@ Then agree the experience in the customer's words, before creating anything:
123
125
  *"Show my plan and credits needs one new action on your agent and one card to
124
126
  show it in. Sound right?"*
125
127
 
128
+ ## One product, several hosts
129
+
130
+ Many products live on more than one host - `www.`, `analyze.`, `admin.` under
131
+ one domain. `init` covers only the host it was given, so on the others the
132
+ agent does not appear and every command refuses the window.
133
+
134
+ - **Find out at the start.** Straight after the agent is live, `npx foldspace
135
+ observe menu` lists the hosts the page links to that belong to the same
136
+ product and that this project does not cover (`otherHosts`). `observe pages`
137
+ and the attach log say the same when the window lands on one.
138
+ - **Widening is the human's yes.** Name the hosts you found and ask once:
139
+ *"Your product also lives on analyze.… and communicate.…. Add them so the
140
+ agent works there too?"* Then add the one pattern (`"*.example.com"`) to
141
+ `hosts` in `foldspace.dev.json`, `npm run inject`, and attach again.
142
+ - **Then go there yourself.** `npx foldspace observe screen --goto
143
+ https://analyze.example.com/reports` opens a screen on any host the project
144
+ names. Do not ask the human to click a tile you can open.
145
+ - **An action reads only the host it runs on** - that is the browser. Build
146
+ each experience on the host its data lives on, and use
147
+ `recipes/several-hosts`: an action asked from the wrong host answers
148
+ `wrong_page` instead of failing.
149
+ - **The user and their conversations carry across hosts; the agent carrying
150
+ on after it navigated does not** (PLG-5962). `recipes/several-hosts` has the
151
+ workaround. It is a workaround: register it (**Rules**, 9).
152
+
126
153
  ## Find the call yourself
127
154
 
128
155
  Never guess `API_BASE`, `AUTH_SOURCE`, an endpoint or a schema. But guessing
@@ -376,6 +403,21 @@ Do not report success without all six:
376
403
  8. **Subagents investigate; they do not build.** A read-only investigation
377
404
  that returns a summary belongs in a subagent because it floods context.
378
405
  Never delegate a handler or a gate — a summary of a gate is not a gate.
406
+ 9. **A platform gap never stops the session - work round it, and register
407
+ it.** When something the experience needs looks missing or broken on
408
+ Foldspace's side - the SDK, the harness, the panel:
409
+ - **Check before you conclude.** `search_docs`, `npx foldspace help --json`
410
+ and the recipes first: it may be supported in a way you have not found.
411
+ Say "I did not find it", never "Foldspace does not support it".
412
+ - **Build the narrowest workaround that gives them a working experience.**
413
+ A prospect is never left stuck on a Foldspace limitation.
414
+ - **Register it, in two places.** A comment in the code where it lives -
415
+ what it works around, how you established that, and that it goes when the
416
+ fix ships - and a row under **Workarounds** in `docs/app-profile.md`:
417
+ what, why, where in the code, verified or assumed. An unregistered
418
+ workaround is copied as house style by whoever comes next, and hides the
419
+ gap from the people who can close it.
420
+ - **Tell the human in one sentence**, and carry on.
379
421
 
380
422
  ## Product defaults
381
423
 
@@ -447,10 +489,15 @@ Each of these cost a real build.
447
489
  - **A card matches the panel, not the page.** The card sits in the agent's
448
490
  panel - white by default (the agent's theme: `backgroundColor #FFFFFF`,
449
491
  `primaryColor` for accents; a `darkTheme` when the panel is dark, and the
450
- panel follows the OS colour scheme, not the app's own toggle). `observe
451
- styles` samples the page: a dark app gives a dark card in a white panel
452
- (seen 2026-09-22). Start from the recipe palettes, put the agent's
453
- `primaryColor` in `accent`, and take only font and radius from the page.
492
+ panel follows the OS colour scheme, not the app's own toggle). **Before the
493
+ first card, make the brand reference:** `npx foldspace observe styles
494
+ --write` samples the app's own accent, text, border, font and radius and
495
+ writes `agent/views/brand.ts` for the panel's white surface - every card
496
+ imports it, and the recipes' `brand.ts` files are placeholders for it. A
497
+ dark app keeps its accent and font; its light-on-dark text would vanish on
498
+ white, so that falls back to greys and the file says so. Record where the
499
+ values came from in the profile's Brand section. Never copy colours by eye
500
+ (seen 2026-09-22: a dark card in a white panel).
454
501
  - **Plain hex or rgba only.** The panel saves every card to the conversation
455
502
  history as an image drawn with html2canvas, which cannot read `oklch()`,
456
503
  `lab()` or `color-mix()`: one such value and the saved card is blank
@@ -486,7 +533,8 @@ version you have installed, and every one is built and run on each release.
486
533
 
487
534
  After the first experience → `who-is-the-user`. L2 → `find-by-name`, and
488
535
  `pick-from-a-list` when the user has to choose. The user's records on one
489
- card → `records-list`. Show one account on a card → `account-overview`. Upload a
536
+ card → `records-list`. The product lives on several hosts → `several-hosts`.
537
+ Show one account on a card → `account-overview`. Upload a
490
538
  CSV of contacts → `upload-contacts`. Save a note on a meeting →
491
539
  `update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
492
540
  attention, with reasons → `opportunities-at-risk`.
package/bin/ask.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { ATTACH_DAEMON_LOG_FILE } from "../src/attach-daemon.mjs";
28
28
  import { CdpPage, parseArgs, printJson, sleep } from "../src/cdp-client.mjs";
29
29
  import { recordEvent } from "../src/session-events.mjs";
30
+ import { withWorkingBadge } from "../src/badge-auto.mjs";
30
31
  import { resolveProjectDir } from "../src/upgrade.mjs";
31
32
 
32
33
  const PANEL_ID = "eucera-agent-iframe";
@@ -300,11 +301,15 @@ async function main() {
300
301
 
301
302
  let attempts = 0;
302
303
  let turn;
303
- do {
304
- if (attempts > 0) await sleep(everyS * 1000);
305
- turn = await askOnce();
306
- attempts += 1;
307
- } while (turn.sent && shouldRetry({ ...turn, attempts, retries }));
304
+ // The working pill is up while the question is typed and answered - the
305
+ // human must not type into the same composer - and down before the pictures.
306
+ await withWorkingBadge(page, async () => {
307
+ do {
308
+ if (attempts > 0) await sleep(everyS * 1000);
309
+ turn = await askOnce();
310
+ attempts += 1;
311
+ } while (turn.sent && shouldRetry({ ...turn, attempts, retries }));
312
+ });
308
313
 
309
314
  if (!turn.sent) {
310
315
  finish({ ok: false, question, reason: "Could not type into the composer.", attempts, url }, 1);
package/bin/attach.mjs CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  import { serializeDiagnostic } from "../src/diagnostics.mjs";
46
46
  import { recordEvent } from "../src/session-events.mjs";
47
47
  import { withoutStealingFocus } from "../src/keep-focus.mjs";
48
+ import { pageCoverage } from "../src/observe-core.mjs";
48
49
  import {
49
50
  ERROR_CODES,
50
51
  createLifecycleResult,
@@ -809,6 +810,18 @@ async function prepare(ws, sessionId, url) {
809
810
  }
810
811
  }
811
812
 
813
+ // A tab on a host of the same product that this project does not name: the
814
+ // agent is not injected there, silently. Say so once per host, with the fix.
815
+ const uncoveredHostsSeen = new Set();
816
+ function noteUncoveredHost(url) {
817
+ if (!/^https?:/.test(String(url || ""))) return;
818
+ const coverage = pageCoverage(url, hostPatterns);
819
+ if (coverage.covered || !coverage.pattern || uncoveredHostsSeen.has(coverage.host)) return;
820
+ uncoveredHostsSeen.add(coverage.host);
821
+ console.log(` [hosts] the window is on ${coverage.host}, which this project does not cover - the agent is not there.`);
822
+ console.log(` Next: add "${coverage.pattern}" to "hosts" in foldspace.dev.json (with the human's yes), then inject and attach again.`);
823
+ }
824
+
812
825
  async function onEvent(ws, msg) {
813
826
  const { method, params, sessionId } = msg;
814
827
 
@@ -819,6 +832,8 @@ async function onEvent(ws, msg) {
819
832
  sessionUrls.set(params.sessionId, t.url);
820
833
  if (hostMatches(t.url, hostPatterns)) {
821
834
  await prepare(ws, params.sessionId, t.url);
835
+ } else {
836
+ noteUncoveredHost(t.url);
822
837
  }
823
838
  } else {
824
839
  // Auto-attach pauses workers on start until the attaching client says go.
@@ -843,6 +858,7 @@ async function onEvent(ws, msg) {
843
858
  if (hostMatches(t.url, hostPatterns)) {
844
859
  await prepare(ws, sid, t.url);
845
860
  } else {
861
+ noteUncoveredHost(t.url);
846
862
  const state = prepared.get(sid);
847
863
  if (state) {
848
864
  await cleanupPreparedSession(ws, sid, state, { reload: false });
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] [--match "deal stage"] [--wait 4000]
9
+ * foldspace observe screen [--click "<label>"] [--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
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
 
24
24
  import { CdpPage, parseArgs, printJson, readLaunchState, sleep } from "../src/cdp-client.mjs";
25
25
  import { withoutStealingFocus } from "../src/keep-focus.mjs";
26
+ import { withWorkingBadge } from "../src/badge-auto.mjs";
26
27
  import {
27
28
  NAV_ITEM_SELECTOR,
28
29
  NAV_SCOPE_SELECTOR,
@@ -41,7 +42,10 @@ import {
41
42
  rankCandidates,
42
43
  readHint,
43
44
  refuseWriteFlags,
44
- sameOriginPath,
45
+ brandFile,
46
+ gotoDestination,
47
+ pageCoverage,
48
+ uncoveredSiblings,
45
49
  summarizeJson,
46
50
  toHex,
47
51
  } from "../src/observe-core.mjs";
@@ -54,7 +58,7 @@ const SUBCOMMANDS = {
54
58
  screen: { values: ["click", "goto", "match", "wait"], flags: [] },
55
59
  read: { values: [], flags: [] },
56
60
  auth: { values: ["wait"], flags: [] },
57
- styles: { values: [], flags: [] },
61
+ styles: { values: [], flags: ["write"] },
58
62
  screenshot: { values: ["out"], flags: [] },
59
63
  "wait-login": { values: ["timeout"], flags: [] },
60
64
  };
@@ -294,10 +298,33 @@ async function runPages() {
294
298
  `The inject Chrome is not answering on :${state.port}. It was closed; run \`npm run inject\` again.`,
295
299
  );
296
300
  }
297
- const pages = targets
298
- .filter((target) => target.type === "page" && !String(target.url).startsWith("devtools://"))
299
- .map((target) => ({ url: printableUrl(target.url), title: target.title }));
300
- return { ok: true, pages };
301
+ const real = targets.filter(
302
+ (target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
303
+ );
304
+ const pages = real.map((target) => {
305
+ const coverage = /^https?:/.test(String(target.url)) ? pageCoverage(target.url, state.hosts) : { covered: true };
306
+ return {
307
+ url: printableUrl(target.url),
308
+ title: target.title,
309
+ ...(coverage.covered ? {} : { covered: false }),
310
+ };
311
+ });
312
+ // A tab on a host of the same product that this project does not name: every
313
+ // command would refuse it, and the agent would not appear there.
314
+ const off = real
315
+ .map((target) => pageCoverage(target.url, state.hosts))
316
+ .find((coverage) => !coverage.covered && coverage.pattern);
317
+ return {
318
+ ok: true,
319
+ pages,
320
+ ...(off
321
+ ? {
322
+ hostsHint:
323
+ `The window is on ${off.host}, which this project does not cover (hosts: ${state.hosts.join(", ")}). ` +
324
+ `If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
325
+ }
326
+ : {}),
327
+ };
301
328
  }
302
329
 
303
330
  async function runMenu(page) {
@@ -311,11 +338,20 @@ async function runMenu(page) {
311
338
  items.push(publicItem(entry));
312
339
  }
313
340
  const url = await page.evaluate("return location.href;");
341
+ // Links that leave this host but stay in the product (analyze., admin. ...).
342
+ // Looked for across the whole page: product tiles are often not navigation.
343
+ const leaving = await page
344
+ .evaluate(
345
+ `return [...document.querySelectorAll("a[href]")].map((a) => a.href).filter((href) => /^https?:/.test(href)).slice(0, 400);`,
346
+ )
347
+ .catch(() => []);
348
+ const siblings = uncoveredSiblings(Array.isArray(leaving) ? leaving : [], url, page.info.hosts);
314
349
  return {
315
350
  ok: true,
316
351
  url: printableUrl(url),
317
352
  count: items.length,
318
353
  items,
354
+ ...(siblings ? { otherHosts: siblings.hosts, hostsHint: siblings.hint } : {}),
319
355
  ...(items.length ? {} : { note: "No visible navigation found. The app may still be loading, or showing a login page." }),
320
356
  };
321
357
  }
@@ -324,7 +360,7 @@ async function runScreen(page, flags) {
324
360
  const waitMs = numberFlag(flags.wait, 4000, "wait");
325
361
  const words = parseMatchWords(flags.match);
326
362
  const before = await page.evaluate("return location.href;");
327
- const destination = flags.goto !== undefined ? sameOriginPath(flags.goto, before) : null;
363
+ const destination = flags.goto !== undefined ? gotoDestination(flags.goto, before, page.info.hosts) : null;
328
364
 
329
365
  await page.send("Network.enable");
330
366
  const network = recordNetwork(page);
@@ -332,7 +368,7 @@ async function runScreen(page, flags) {
332
368
 
333
369
  if (destination) {
334
370
  await withoutStealingFocus(() => page.send("Page.navigate", { url: destination.url }));
335
- reached = { how: "goto", path: destination.path };
371
+ reached = { how: "goto", path: destination.path, ...(destination.host ? { host: destination.host } : {}) };
336
372
  if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
337
373
  }
338
374
  if (flags.click !== undefined) {
@@ -557,7 +593,7 @@ function lowerKeys(wire = {}, pageSet = {}) {
557
593
  return Object.fromEntries(Object.entries(wire).filter(([name]) => !known.has(name.toLowerCase())));
558
594
  }
559
595
 
560
- async function runStyles(page) {
596
+ async function runStyles(page, flags = {}) {
561
597
  const raw = await page.evaluate(`
562
598
  ${VISIBLE_JS}
563
599
  const clear = (color) =>
@@ -653,7 +689,7 @@ async function runStyles(page) {
653
689
  radius: top(radii)[0] || null,
654
690
  };`);
655
691
  const { button } = raw;
656
- return {
692
+ const report = {
657
693
  ok: true,
658
694
  url: printableUrl(raw.url),
659
695
  sampledFrom: "computed styles of visible elements",
@@ -675,6 +711,15 @@ async function runStyles(page) {
675
711
  boxRadius: raw.radius,
676
712
  body: { ...raw.body, color: toHex(raw.body.color) },
677
713
  };
714
+ if (!flags.write) return report;
715
+ // The brand reference every card imports. Written, not printed: values
716
+ // copied by hand drift, and re-sampling must be one command.
717
+ const target = path.join(projectDir, "agent", "views", "brand.ts");
718
+ const existed = fs.existsSync(target);
719
+ const { content, notes } = brandFile(report, { url: report.url, date: new Date().toISOString().slice(0, 10) });
720
+ fs.mkdirSync(path.dirname(target), { recursive: true });
721
+ fs.writeFileSync(target, content, "utf8");
722
+ return { ...report, wrote: path.relative(projectDir, target), replaced: existed, notes };
678
723
  }
679
724
 
680
725
  async function runScreenshot(page, flags) {
@@ -762,11 +807,14 @@ async function main() {
762
807
 
763
808
  const page = await CdpPage.open({ projectDir });
764
809
  try {
765
- if (sub === "menu") return await runMenu(page);
766
- if (sub === "screen") return await runScreen(page, flags);
767
- if (sub === "read") return await runRead(page, flags, rest);
768
- if (sub === "auth") return await runAuth(page, flags);
769
- if (sub === "styles") return await runStyles(page);
810
+ // The working pill goes up while the harness drives the human's window,
811
+ // and comes down after - nobody has to remember it. Not for screenshot:
812
+ // the picture is for the human, and the pill would be in it.
813
+ if (sub === "menu") return await withWorkingBadge(page, () => runMenu(page));
814
+ if (sub === "screen") return await withWorkingBadge(page, () => runScreen(page, flags));
815
+ if (sub === "read") return await withWorkingBadge(page, () => runRead(page, flags, rest));
816
+ if (sub === "auth") return await withWorkingBadge(page, () => runAuth(page, flags));
817
+ if (sub === "styles") return await withWorkingBadge(page, () => runStyles(page, flags));
770
818
  return await runScreenshot(page, flags);
771
819
  } finally {
772
820
  page.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
package/recipes/INDEX.md CHANGED
@@ -13,6 +13,7 @@ Read this whole table, then open the recipe closest to the outcome.
13
13
  | [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An action with a card that waits for the user | 1 production build |
14
14
  | [`update-meeting-notes`](update-meeting-notes/) | **L4** | A note is saved on a meeting and the new note id comes back | A write. **No card** | 1 production build |
15
15
  | [`prepare-for-a-meeting`](prepare-for-a-meeting/) | **L2** | Meeting facts are gathered in code, then a task agent writes the summary | A data action plus a task agent. **No card** | 1 production build |
16
+ | [`several-hosts`](several-hosts/) | any | The product lives on several hosts (`www.`, `analyze.`, `admin.` ...): an action asked from the wrong host says which page it needs, and the conversation carries on after the agent moves the user there. **The resume is a registered workaround** (PLG-5962) | Page set-up, plus a guard each action calls | 1 production build |
16
17
  | [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
17
18
  | [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
18
19
 
@@ -5,5 +5,9 @@
5
5
  "kind": "page-setup",
6
6
  "entry": "agent/bottomBar.ts",
7
7
  "outcome": "The agent's resting entry point is a bar at the bottom of the page whose starters change with the page the user is on",
8
- "provenBy": 2
8
+ "provenBy": 2,
9
+ "setup": {
10
+ "import": "installBottomBar",
11
+ "from": "../bottomBar"
12
+ }
9
13
  }
@@ -0,0 +1,35 @@
1
+ # One product, several hosts
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.**
4
+
5
+ **Proven by 1 production build**, which met each part of this the hard way, one at a time.
6
+
7
+ ## Find out at the start
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`.
10
+
11
+ ## What this recipe gives
12
+
13
+ | Piece | What it does |
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 |
17
+
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.
19
+
20
+ ## Adapt it
21
+
22
+ | File | Change |
23
+ |---|---|
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 |
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
+
29
+ ## What that build learned the hard way
30
+
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.
@@ -0,0 +1,146 @@
1
+ // One product, several hosts (www. / analyze. / communicate. / admin. ...).
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.") },
22
+ };
23
+
24
+ export type WrongPage = { status: "wrong_page"; required_page: string; current_page: string };
25
+
26
+ let resumeExpected = false;
27
+
28
+ /**
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.
33
+ */
34
+ export function wrongHost(tool: keyof typeof TOOLS): WrongPage | null {
35
+ const wanted = TOOLS[tool];
36
+ if (!wanted || wanted.onThisHost(window.location.hostname, window.location.pathname)) return null;
37
+ resumeExpected = true;
38
+ return { status: "wrong_page", required_page: wanted.label, current_page: window.location.hostname };
39
+ }
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,5 @@
1
+ {
2
+ "payload": { "conversationId": "conv_123", "actionName": "navigate" },
3
+ "normalizedUrl": "https://communicate.example-campus.net/applications/acme/test",
4
+ "expiry": 9999999999999
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "title": "One product, several hosts",
3
+ "level": "any",
4
+ "family": "several-hosts",
5
+ "kind": "page-setup",
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",
9
+ "provenBy": 1
10
+ }
@@ -0,0 +1,42 @@
1
+ // The working pill, raised by the harness itself while it drives the shared
2
+ // window. `foldspace badge working` exists so the human knows when to leave the
3
+ // window alone - and across a long session of observe and ask it was never
4
+ // set, so both drove the same window (raised by the human, 2026-09-29). The
5
+ // commands know when they are about to drive it, so they raise the pill for
6
+ // as long as they run and put back whatever was there. A pill the agent set
7
+ // on purpose is left alone.
8
+
9
+ export const BADGE_KEY = "__foldspace_badge__";
10
+
11
+ export async function readBadge(page) {
12
+ try {
13
+ const raw = await page.evaluate(`return sessionStorage.getItem(${JSON.stringify(BADGE_KEY)});`);
14
+ return raw ? JSON.parse(raw) : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export async function writeBadge(page, payload) {
21
+ await page.evaluate(
22
+ `sessionStorage.setItem(${JSON.stringify(BADGE_KEY)}, ${JSON.stringify(JSON.stringify(payload))}); return true;`,
23
+ );
24
+ }
25
+
26
+ /** Run `action` with the working pill up, then restore what was there before. */
27
+ export async function withWorkingBadge(page, action) {
28
+ const before = await readBadge(page);
29
+ if (before?.state === "working") return action(); // set on purpose: theirs to drop
30
+ let raised = false;
31
+ try {
32
+ await writeBadge(page, { state: "working", auto: true });
33
+ raised = true;
34
+ } catch {
35
+ // no badge script on this page (attach not running): nothing to raise
36
+ }
37
+ try {
38
+ return await action();
39
+ } finally {
40
+ if (raised) await writeBadge(page, before ?? { state: "label" }).catch(() => {});
41
+ }
42
+ }
@@ -14,6 +14,7 @@ import path from "node:path";
14
14
  import { assertOwnedCdp, chromeProfileDir, ownershipErrorMessage } from "./cdp-ownership.mjs";
15
15
  import { resolveProjectDir } from "./upgrade.mjs";
16
16
 
17
+ import { pageCoverage } from "./observe-core.mjs";
17
18
  export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
19
 
19
20
  /** Where inject recorded the Chrome it launched. Never guess a port. */
@@ -76,8 +77,12 @@ export function choosePage(pages, hosts = []) {
76
77
  const match = real.find((page) => hostMatches(page.url, hosts));
77
78
  if (match) return match;
78
79
  if (hosts.length) {
80
+ const off = real.map((page) => pageCoverage(page.url, hosts)).find((coverage) => coverage.pattern);
79
81
  throw new Error(
80
- `The test window has no tab on ${hosts.join(", ")}. Open the product there (or finish signing in), then try again.`,
82
+ off
83
+ ? `The test window is on ${off.host}, which this project does not cover (hosts: ${hosts.join(", ")}). ` +
84
+ `If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`
85
+ : `The test window has no tab on ${hosts.join(", ")}. Open the product there (or finish signing in), then try again.`,
81
86
  );
82
87
  }
83
88
  return real[0];
@@ -306,10 +306,11 @@ export const CLI_COMMANDS = Object.freeze([
306
306
  ],
307
307
  options: [
308
308
  value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
309
- value("--goto", "path", "screen: a same-origin path"),
309
+ value("--goto", "path", "screen: a path on this host, or a full https address on a host this project names"),
310
310
  value("--match", "words", "screen: rank the requests seen by these words"),
311
311
  value("--wait", "ms", "screen/auth: how long to keep listening"),
312
312
  value("--out", "file", "screenshot: where to save the PNG"),
313
+ flag("--write", "styles: write agent/views/brand.ts - the brand reference every card imports"),
313
314
  value("--timeout", "seconds", "wait-login: give up after this long", { default: "600" }),
314
315
  ],
315
316
  prerequisites: ["foldspace inject has opened the test window"],
@@ -317,6 +318,7 @@ export const CLI_COMMANDS = Object.freeze([
317
318
  "GET only. Never submits a form; clicks navigation elements only",
318
319
  "Prints names, shapes and counts - never values, tokens or cookies",
319
320
  "Works while foldspace attach is running",
321
+ "Raises the working pill on the window while it runs (not for screenshot), and drops it after",
320
322
  ],
321
323
  next: ["Record what you established in docs/app-profile.md", "Write the handler"],
322
324
  }),
@@ -43,6 +43,63 @@ export function isSameSite(url, pageUrl, hosts = []) {
43
43
  }
44
44
  }
45
45
 
46
+ /**
47
+ * One product often lives on several hosts (www., analyze., admin. ...), and a
48
+ * project only works on the hosts it names. Given addresses seen from a page,
49
+ * say which hosts look like the same product but are NOT covered, and the one
50
+ * pattern that would cover them. The site is an approximation (see siteOf), so
51
+ * this only ever SUGGESTS: widening `hosts` is the human's decision.
52
+ */
53
+ export function uncoveredSiblings(urls, pageUrl, hosts = []) {
54
+ let pageHost;
55
+ try {
56
+ pageHost = new URL(pageUrl).hostname.toLowerCase();
57
+ } catch {
58
+ return null;
59
+ }
60
+ const site = siteOf(pageHost);
61
+ if (!site.includes(".") || /^[\d.]+$/.test(site)) return null;
62
+ const counts = new Map();
63
+ for (const url of urls) {
64
+ let host;
65
+ try {
66
+ const parsed = new URL(url);
67
+ if (!/^https?:$/.test(parsed.protocol)) continue;
68
+ host = parsed.hostname.toLowerCase();
69
+ } catch {
70
+ continue;
71
+ }
72
+ if (host === pageHost || siteOf(host) !== site || hostMatchesAny(host, hosts)) continue;
73
+ counts.set(host, (counts.get(host) || 0) + 1);
74
+ }
75
+ if (!counts.size) return null;
76
+ const found = [...counts].map(([host, seen]) => ({ host, seen })).sort((a, b) => b.seen - a.seen || a.host.localeCompare(b.host));
77
+ const pattern = `*.${site}`;
78
+ return {
79
+ hosts: found.slice(0, 12),
80
+ pattern,
81
+ hint:
82
+ `This product also lives on ${found.slice(0, 4).map((entry) => entry.host).join(", ")}` +
83
+ `${found.length > 4 ? ` and ${found.length - 4} more` : ""}, which this project does not cover. ` +
84
+ `To work there, add "${pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
85
+ };
86
+ }
87
+
88
+ /** Whether the page the window is on is one this project works on; if not, what would make it one. */
89
+ export function pageCoverage(pageUrl, hosts = []) {
90
+ let host;
91
+ try {
92
+ host = new URL(pageUrl).hostname.toLowerCase();
93
+ } catch {
94
+ return { covered: false, host: null, pattern: null };
95
+ }
96
+ if (!hosts.length || hostMatchesAny(host, hosts)) return { covered: true, host, pattern: null };
97
+ const shared = hosts
98
+ .map((pattern) => String(pattern).toLowerCase().replace(/^\*\./, ""))
99
+ .some((bare) => siteOf(bare) === siteOf(host));
100
+ return { covered: false, host, pattern: shared ? `*.${siteOf(host)}` : null };
101
+ }
102
+
46
103
  // ------------------------------------------------------------ navigation
47
104
 
48
105
  export const NAV_SCOPE_SELECTOR =
@@ -50,7 +107,45 @@ export const NAV_SCOPE_SELECTOR =
50
107
  export const NAV_ITEM_SELECTOR =
51
108
  "a[href], button, [role=menuitem], [role=tab], [role=link]";
52
109
 
53
- /** `--goto` accepts a path on the page's own origin and nothing else. */
110
+ /**
111
+ * Where `--goto` may go: a path on the page's own origin, or a full https
112
+ * address on a host this project names. Nowhere else - the window carries the
113
+ * human's session. The path is screened either way.
114
+ */
115
+ export function gotoDestination(input, pageUrl, hosts = []) {
116
+ const raw = String(input ?? "").trim();
117
+ if (!/^https?:\/\//i.test(raw)) return { ...sameOriginPath(raw, pageUrl), host: null };
118
+ let target;
119
+ try {
120
+ target = new URL(raw);
121
+ } catch {
122
+ throw new Error(`--goto: "${raw}" is not an address.`);
123
+ }
124
+ if (target.protocol !== "https:" && new URL(pageUrl).protocol === "https:") {
125
+ throw new Error("--goto must use https.");
126
+ }
127
+ if (target.username || target.password) throw new Error("--goto takes no credentials in the address.");
128
+ const page = new URL(pageUrl);
129
+ const sameOrigin = target.origin === page.origin;
130
+ if (!sameOrigin && !hostMatchesAny(target.hostname, hosts)) {
131
+ const coverage = pageCoverage(target.href, hosts);
132
+ throw new Error(
133
+ `--goto: ${target.hostname} is not one of this project's hosts (${hosts.join(", ") || "none named"}). ` +
134
+ (coverage.pattern
135
+ ? `If it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`
136
+ : "observe only opens screens of the product this project is for."),
137
+ );
138
+ }
139
+ const refused = pathRefusal(target.pathname);
140
+ if (refused) throw new Error(`--goto: ${refused}`);
141
+ return {
142
+ url: target.href,
143
+ path: target.pathname + target.search + target.hash,
144
+ host: sameOrigin ? null : target.hostname,
145
+ };
146
+ }
147
+
148
+ /** `--goto` with a path: the page's own origin and nothing else. */
54
149
  export function sameOriginPath(input, pageUrl) {
55
150
  const raw = String(input ?? "").trim();
56
151
  if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) {
@@ -610,8 +705,46 @@ export function looksLoggedIn({ url, hasPasswordField = false, hosts = [] }) {
610
705
  // ---------------------------------------------------------------- styles
611
706
 
612
707
  /** Computed colours come back as rgb()/rgba(); anything else passes through. */
708
+ // oklch / oklab, as modern apps write their palettes and as Chrome reports
709
+ // them back, converted to sRGB. Cards must be plain hex: the panel saves them
710
+ // to history as an image drawn by a library that cannot read these.
711
+ function okToHex(text) {
712
+ const match = /^(oklch|oklab)\(\s*([\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i.exec(text);
713
+ if (!match) return null;
714
+ const number = (value, scale = 1) => (value.endsWith("%") ? (Number(value.slice(0, -1)) / 100) * scale : Number(value));
715
+ const L = number(match[2]);
716
+ let a;
717
+ let b;
718
+ if (match[1].toLowerCase() === "oklch") {
719
+ const C = number(match[3], 0.4);
720
+ const H = (Number(match[4]) * Math.PI) / 180;
721
+ a = C * Math.cos(H);
722
+ b = C * Math.sin(H);
723
+ } else {
724
+ a = number(match[3], 0.4);
725
+ b = number(match[4], 0.4);
726
+ }
727
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
728
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
729
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
730
+ const linear = [
731
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
732
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
733
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
734
+ ];
735
+ const channel = (c) => {
736
+ const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.max(c, 0) ** (1 / 2.4) - 0.055;
737
+ return Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, "0");
738
+ };
739
+ const hex = `#${linear.map(channel).join("")}`;
740
+ const alpha = match[5] === undefined ? 1 : number(match[5]);
741
+ return alpha >= 1 ? hex : `${hex}${Math.round(alpha * 255).toString(16).padStart(2, "0")}`;
742
+ }
743
+
613
744
  export function toHex(cssColor) {
614
745
  const text = String(cssColor || "").trim();
746
+ const ok = okToHex(text);
747
+ if (ok) return ok;
615
748
  const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(text);
616
749
  if (!match) return text || null;
617
750
  const channel = (value) =>
@@ -625,3 +758,69 @@ export function toHex(cssColor) {
625
758
  const hex = `#${channel(match[1])}${channel(match[2])}${channel(match[3])}`;
626
759
  return alpha >= 1 ? hex : `${hex}${channel(alpha * 255)}`;
627
760
  }
761
+
762
+ // ------------------------------------------------------------ brand file
763
+
764
+ function luminance(hex) {
765
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(String(hex || ""));
766
+ if (!m) return null;
767
+ const [r, g, b] = [m[1], m[2], m[3]].map((part) => parseInt(part, 16) / 255);
768
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
769
+ }
770
+
771
+ const isHex = (value) => /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(String(value || ""));
772
+
773
+ /**
774
+ * `agent/views/brand.ts` from what `observe styles` sampled: the app's own
775
+ * accent, text, border, font and radius, on the panel's white surface. A dark
776
+ * app keeps its accent, font and radius; its light-on-dark text and borders
777
+ * would vanish on white, so those fall back to neutral greys and say so.
778
+ */
779
+ export function brandFile(styles, { url, date, agentPrimary } = {}) {
780
+ const notes = [];
781
+ const pick = (value, fallback, name) => {
782
+ const hex = value ? toHex(value) : null; // rgb / oklch / oklab -> hex; anything else stays as it was
783
+ if (isHex(hex)) return hex.slice(0, 7).toUpperCase();
784
+ if (value) notes.push(`${name}: sampled "${value}" is not a plain colour - using ${fallback}`);
785
+ return fallback;
786
+ };
787
+ const background = pick(styles.background, "#FFFFFF", "background");
788
+ const darkApp = (luminance(background) ?? 1) < 0.4;
789
+ if (darkApp) notes.push(`the app is dark (${background}); cards sit in the white panel, so text, muted and border are neutral greys`);
790
+ const text = darkApp ? "#111827" : pick(styles.text, "#111827", "text");
791
+ const muted = darkApp ? "#6B7280" : pick(styles.textMuted, "#6B7280", "textMuted");
792
+ const border = darkApp ? "#E5E7EB" : pick(styles.border, "#E5E7EB", "border");
793
+ const buttonBackground = styles.primaryButton?.background;
794
+ const accent = isHex(agentPrimary)
795
+ ? agentPrimary.slice(0, 7).toUpperCase()
796
+ : pick(buttonBackground || styles.link, "#3248F2", "accent");
797
+ const accentSource = isHex(agentPrimary) ? "the agent's own primary colour" : buttonBackground ? "the app's primary button" : styles.link ? "the app's links" : "Foldspace blue (nothing sampled)";
798
+ const accentText = pick(styles.primaryButton?.color, (luminance(accent) ?? 0) > 0.6 ? "#111827" : "#FFFFFF", "accentText");
799
+ const font = String(styles.fontFamily || "").replace(/["'\\]/g, "").trim() || "Roboto, sans-serif";
800
+ const radius = /^\d+(\.\d+)?px$/.test(String(styles.boxRadius || "")) ? styles.boxRadius : "8px";
801
+ const stamp = `${url || "the app"}${date ? ` on ${date}` : ""}`;
802
+ const lines = [
803
+ `// Brand reference for this product's cards. Sampled from ${stamp} with`,
804
+ "// `foldspace observe styles --write`. Cards live in the agent's panel, which is",
805
+ "// white, so the surface is the panel's; accent, text, border, font and radius",
806
+ "// are the app's own. Re-run the command to re-sample; do not edit by eye.",
807
+ "// Plain hex only: the panel saves cards to history as an image drawn by a",
808
+ "// library that cannot read oklch()/lab().",
809
+ `// accent: ${accentSource}.`,
810
+ ...notes.map((note) => `// NOTE ${note}.`),
811
+ "",
812
+ "export const brand = {",
813
+ ` surface: "#FFFFFF",`,
814
+ ` surface2: "#F5F6FA",`,
815
+ ` text: "${text}",`,
816
+ ` muted: "${muted}",`,
817
+ ` border: "${border}",`,
818
+ ` accent: "${accent}",`,
819
+ ` accentText: "${accentText}",`,
820
+ ` font: '${font}',`,
821
+ ` radius: "${radius}",`,
822
+ "} as const;",
823
+ "",
824
+ ];
825
+ return { content: lines.join("\n"), notes, values: { text, muted, border, accent, accentText, font, radius } };
826
+ }
@@ -14,7 +14,14 @@ import type { ApiFailure } from "./http";
14
14
  export type ViewHost = HTMLElement | { container: HTMLElement };
15
15
 
16
16
  function containerOf(host: ViewHost): HTMLElement {
17
- return host instanceof HTMLElement ? host : host.container;
17
+ // Not `instanceof HTMLElement`: the host Foldspace passes belongs to the
18
+ // agent panel's frame, and an element from another frame is not an instance
19
+ // of THIS frame's HTMLElement. The check was false for every real host, so
20
+ // `host.container` (undefined) was used and every failure card crashed
21
+ // instead of drawing (seen on a live build, 2026-09-24).
22
+ const wrapped = (host as { container?: HTMLElement }).container;
23
+ if (wrapped && typeof wrapped.appendChild === "function") return wrapped;
24
+ return host as HTMLElement;
18
25
  }
19
26
 
20
27
  function el(tag: string, className: string, text?: string): HTMLElement {
package/src/upgrade.mjs CHANGED
@@ -307,7 +307,12 @@ export function checkForUpdate(projectDir, options = {}) {
307
307
  } else if (skipped) {
308
308
  status = "check_skipped";
309
309
  } else {
310
- const cached = options.cache === false ? null : readCache(projectDir, now);
310
+ // `fresh` asks the registry whatever was remembered, and still writes the
311
+ // new answer down. `foldspace upgrade` is always fresh: someone asking to
312
+ // upgrade minutes after a release was told the previous version was the
313
+ // latest, for up to 12 hours (seen 2026-09-28).
314
+ const cached =
315
+ options.cache === false || options.fresh === true ? null : readCache(projectDir, now);
311
316
  const cachedDeprecation =
312
317
  cached?.deprecation?.version === running ? cached.deprecation : null;
313
318
  if (cached?.latest && cachedDeprecation) {
@@ -445,7 +450,7 @@ export async function runUpgrade(argv, options = {}) {
445
450
  );
446
451
  }
447
452
 
448
- const report = checkForUpdate(projectDir, options);
453
+ const report = checkForUpdate(projectDir, { ...options, fresh: options.fresh ?? true });
449
454
 
450
455
  if (parsed.checkOnly || (!parsed.yes && !isInteractive(options) && !parsed.refreshInstructions)) {
451
456
  return report;
@@ -101,6 +101,16 @@ across.
101
101
 
102
102
  Listed so the next person knows they exist **and knows they are unverified**.
103
103
 
104
+ ## Workarounds
105
+
106
+ Anything built here because Foldspace - the SDK, the harness, the panel - did
107
+ not do it, or did not seem to. One row each, and the same note as a comment in
108
+ the code. "Assumed" means you did not find support for it; it may exist.
109
+
110
+ | What it does | What it works around | Where in the code | Verified or assumed | Goes when |
111
+ |---|---|---|---|---|
112
+ | | | | | |
113
+
104
114
  ## Failures observed
105
115
 
106
116
  | Call | Result | What it means |