@foldspace_npm/harness 0.1.25 → 0.1.27

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
@@ -21,12 +21,15 @@ something they can **see on their own page** — not at "it builds".
21
21
  project `CLAUDE.md` does not contain
22
22
  `@node_modules/@foldspace_npm/harness/CLAUDE.md`, ask before
23
23
  `foldspace upgrade --refresh-instructions`.
24
- 2. **Is Foldspace connected? Check — never assume.** Call `list_agents`:
24
+ 2. **Is Foldspace connected? Check — never assume.** `region` in
25
+ `foldspace.dev.json` (absent means `us`) picks the server: `foldspace`, or
26
+ `foldspace-eu` for `eu`. Use that server's tools all session. Call its
27
+ `list_agents`:
25
28
 
26
29
  | Result | Do |
27
30
  |---|---|
28
- | The tool does not exist | The Foldspace MCP is not installed in this editor. Give the one install step for *this* editor — Claude Code: `claude plugin install foldspace-agent-builder@foldspace-plugins`; anything else: the MCP block on <https://foldspace.ai/agents.md> — then check again |
29
- | It exists but fails on auth | Installed, not signed in. Only the human can approve sign-in. Tell them exactly where — Claude Code: `/mcp` → **foldspace** → finish in the browser — then check again |
31
+ | The tool does not exist | The Foldspace MCP is not installed in this editor. Give the one install step for *this* region and editor — **EU**, any editor: the MCP block on <https://foldspace.ai/eu/agents.md>, never the plugin. **US** — Claude Code: `claude plugin install foldspace-agent-builder@foldspace-plugins`; anything else: the MCP block on <https://foldspace.ai/agents.md>. Then check again |
32
+ | It exists but fails on auth | Installed, not signed in. Only the human can approve sign-in. Tell them exactly where — Claude Code: `/mcp` → **foldspace-eu** (**EU**) or **foldspace** (**US**) → finish in the browser — then check again |
30
33
  | It returns agents | One agent: state its name, Agent Key and product in a line and carry on; the human objects if it is wrong. Several: ask which, as a choice |
31
34
 
32
35
  3. **Orient** — `get_agent_settings`, `list_actions`, `list_task_agents`,
@@ -35,6 +38,39 @@ something they can **see on their own page** — not at "it builds".
35
38
  seeded at sign-up are often a specification someone already wrote.
36
39
  4. **Read `docs/app-profile.md`.** It is what is already known about this app,
37
40
  with how each fact was established.
41
+ 5. **Does the agent already have code saved in Foldspace?** If your Foldspace
42
+ tools include `repo_read`, call it with command `status` now - before the
43
+ first build, because the first build is what the test window shows them.
44
+ - **A code project.** Call `repo_read` with command `instructions` and
45
+ follow what it returns. The project's `agent/` is the copy Foldspace
46
+ builds and publishes, and it may hold work someone else saved. Before you
47
+ build, replace this folder's `agent/` with it (`repo_read` commands `tree`
48
+ and `file`): delete what is there rather than copying over the top, or
49
+ their page shows the template instead of their agent. An action file here
50
+ that the project lacks is work a session never saved: keep it aside and
51
+ put it back after.
52
+ - **`NO_REPO`.** Nothing is saved yet. Follow the next step `status` gives:
53
+ it creates the code project in about a minute and changes nothing anyone
54
+ sees - say you are setting up their project, no need to ask. Get the agent
55
+ live in this folder meanwhile (L0 needs no code). Once `status` shows the
56
+ project, replace this folder's `agent/` with its `agent/` as above, before
57
+ you write the first action.
58
+ - **A refusal that names `foldspace-ai-access`.** This agent has saved
59
+ code that Foldspace has not opened to these tools; that is final. Tell
60
+ the human that Foldspace manages their agent's saved code, so this build
61
+ starts fresh.
62
+ - **A refusal that starts "Your Foldspace account cannot".** It is about
63
+ the signed-in account, so you cannot tell whether code is saved. Pass its
64
+ words on as they are - they are written for the human.
65
+ - **Anything else** - `status` says a project claims this agent but cannot
66
+ be used, or another error. Create none. Tell the human in your own words
67
+ that their agent's saved code cannot be used right now; never pass on the
68
+ text that came back - it can name things they cannot open.
69
+
70
+ After any of these three, build in this folder, never stop the session
71
+ over it, and tell the human once that the code stays on this computer.
72
+ - **No `repo_read` tool.** This folder is the only copy, so there is no code
73
+ project.
38
74
 
39
75
  Before an unfamiliar harness operation, `npx foldspace help --json` is the
40
76
  current CLI contract — risk, prerequisites, effects, next step.
@@ -47,11 +83,12 @@ current CLI contract — risk, prerequisites, effects, next step.
47
83
  | **L1 · read settings** | the user's own account, plan or settings in a widget, showing their real values |
48
84
  | **L2 · look up entities** | a search from the user's own words, showing rows |
49
85
 
50
- 1. **Get the agent live first (L0) - with no actions.** Straight after
51
- orienting: `npm run build`, `npm run inject`, and one ask - *"A Chrome
52
- window opened at <Product>'s login. Please log in there - I'll notice when
53
- you're in."* Then `npx foldspace observe wait-login`: it returns the moment
54
- they are in, so nobody has to type "I've logged in". Then
86
+ 1. **Get the agent live first (L0) - with no actions.** Straight after the
87
+ steps above (with a code project, once its `agent/` is in place - step 5):
88
+ `npm run build`, `npm run inject`, and one ask - *"A Chrome window opened at
89
+ <Product>'s login. Please log in there - I'll notice when you're in."* Then
90
+ `npx foldspace observe wait-login`: it returns the moment they are in, so
91
+ nobody has to type "I've logged in". Then
55
92
  `npx foldspace attach --daemon` - and if it answers that the page has no
56
93
  Foldspace code, `npx foldspace attach --bootstrap --daemon` (an app that
57
94
  never had Foldspace). An empty registry is valid. L0 is
@@ -308,7 +345,8 @@ SDK without the configured agent.
308
345
  read `npx foldspace help attach --json` and map `missingActionNames`,
309
346
  `unexpectedActionNames`, `diagnosticError`. A running attach keeps the actions
310
347
  it loaded: after a rebuild, `attach --stop` and attach again to load the new
311
- ones. That is the only reason to stop it.
348
+ ones. That, and checking published code on dev (**Done, and what you say at
349
+ the end**), are the only reasons to stop it.
312
350
 
313
351
  Prove a named action through the visible agent: after the human talks to the
314
352
  copilot, read the daemon log for `[actions]` SDK callback and local
@@ -321,7 +359,44 @@ A first session is done when the customer has seen one experience answer from
321
359
  their own data, in the test window, and been invited to try it. Not when the
322
360
  gates pass, and not when there is nothing left to improve.
323
361
 
324
- If they ask *"how do I put this in front of my users?"*, the honest answer is:
362
+ **With a code project** (step 5 found or created one), putting it in front of
363
+ their users is yours to do. Once they have tried it in the test window, before
364
+ you offer the second wave:
365
+
366
+ 1. Say *"I'm saving your changes and publishing them to dev"* - no need to
367
+ ask - then save with `repo_write` and publish with the `deploy` tool, the
368
+ way `repo_read` command `instructions` says. One exception: a dev preview
369
+ that comes back with no warnings means this agent loads dev for its users -
370
+ then ask first, as for production. In chat it is "saving your changes",
371
+ "publishing to dev" and "publishing for your users" - never GitHub,
372
+ branches, commits or tool names.
373
+ 2. Check dev yourself. Say in chat that you are checking it in that window
374
+ and to leave it alone until you say it is ready: with attach stopped the
375
+ page draws no pill. Then `npx foldspace attach --stop` (while attach runs,
376
+ the page serves your local build), `npx foldspace published dev`, and
377
+ `npx foldspace ask "<the question>" --retry 2`. Without attach, `ask`
378
+ cannot see `execute` and `render`: the reply, the card text and the
379
+ screenshot are the proof - look at the screenshot. If it fails, go no
380
+ further: attach again, fix it here, then save, publish to dev and check
381
+ again. If it works, invite them to try it in that window (a reload takes
382
+ it off dev), and run `npx foldspace attach --daemon` before you build
383
+ anything else. If `published` says this window cannot show dev, say so in
384
+ one sentence, attach again, and stop at step 1.
385
+ 3. Once the dev check passed, ask before publishing for their users:
386
+ *"Shall I publish it for your users?"* On a yes, publish to production. On a not yet, it stays on dev:
387
+ say so in one line and carry on.
388
+
389
+ Steps 2 and 3 need a site that loads this agent by itself. If you attached
390
+ with `--bootstrap` or `--replace`, it does not yet: do step 1, keep attach
391
+ running, and give the answer below for the rest - their site loading the agent
392
+ is the step the Foldspace team does with them.
393
+
394
+ A save refused because someone saved after you read is not final: the
395
+ instructions say how to redo it. Any other refusal from the Foldspace tools
396
+ here is final: then the answer below.
397
+
398
+ **Without a code project**, the answer to *"how do I put this in front of my
399
+ users?"* is:
325
400
 
326
401
  > What you've seen runs in a test window on your machine. Putting it in front
327
402
  > of your users is a separate step, and the Foldspace team does it with you.
@@ -329,8 +404,9 @@ If they ask *"how do I put this in front of my users?"*, the honest answer is:
329
404
  > <support@foldspace.ai>. Everything built today carries over: the actions are
330
405
  > already in your Foldspace account, and the code is in this folder.
331
406
 
332
- Never run `foldspace deploy`, never install cloud tooling, never ask them for
333
- cloud credentials: `deploy` is Foldspace-internal and no customer can run it.
407
+ Never run `npx foldspace deploy` - that command is for Foldspace staff only,
408
+ and it is not the `deploy` tool you publish a code project with. Never install
409
+ cloud tooling, and never ask them for cloud credentials.
334
410
 
335
411
  ## Verification gates
336
412
 
@@ -392,7 +468,9 @@ Do not report success without all six:
392
468
  --test-mode` keeps your conversations out of their data. It is not a safety
393
469
  net: publishing still changes the live agent. Working on an agent in
394
470
  production - test mode, pinned action versions, what may be published - is
395
- a separate playbook, not this one.
471
+ a separate playbook, not this one. Publishing its code with the `deploy`
472
+ tool is the exception: it takes the same dev-then-production path as a new
473
+ agent (**Done, and what you say at the end**).
396
474
  6. **The human is not necessarily a developer.** Never hand over a terminal
397
475
  line, a stack trace, or a choice between flags. Name experiences by outcome
398
476
  — *"show my plan and credits"*, not `get_account_status`. Their two jobs:
@@ -568,13 +646,20 @@ building is not the session at run time: **expect `signed_out`** and let
568
646
  `renderFailure` show it. `not_found` and zero rows are both `renderEmpty` with
569
647
  the query named — never an error, never a blank card.
570
648
 
571
- If the observed auth is not Bearer + `localStorage`/`cookie`, stop re-exporting
572
- that one function and keep the rest:
573
-
574
- ```ts
575
- export { getAgent, rankBy, renderEmpty } from "@foldspace_npm/harness/runtime";
576
- export { apiFetch } from "./api";
577
- ```
649
+ If the observed auth is not Bearer + `localStorage`/`cookie`, copy
650
+ `node_modules/@foldspace_npm/harness/recipes/swap-the-login-method/agent/utils.ts`
651
+ over `agent/utils.ts` - or, when the browser's own cookies carry the sign-in and
652
+ there is no token, that recipe's `variants/utils.cookies.ts` - and set what its
653
+ README's **Adapt it** table lists to what you observed.
654
+
655
+ **If `agent/` came from a code project** (step 5), nothing in it may import a
656
+ package: Foldspace builds the published bundle with no `node_modules`, so a
657
+ package import passes every gate in this manual and then fails on publish.
658
+ Import the helpers the way the project's own `agent/utils.ts` does - from
659
+ `./runtime` - in a recipe file you copy too. A project with an `agent/utils/`
660
+ folder has its own helpers instead: change the sign-in there, and never add an
661
+ `agent/utils.ts` beside it, which would win the import. Without a code project,
662
+ the package import is right.
578
663
 
579
664
  ## Safety
580
665
 
@@ -594,5 +679,8 @@ export { apiFetch } from "./api";
594
679
  - Do not commit secrets, cookies, HAR files, browser storage, or
595
680
  `.foldspace-dev/`. Nothing transient goes in `docs/`.
596
681
  - Local handler changes are not cloud publication.
597
- - Creating or publishing Foldspace resources requires explicit approval.
682
+ - Creating or publishing Foldspace resources needs the human's yes - except
683
+ the agent's code: creating its code project, saving to it and publishing it
684
+ to dev, unless the dev preview comes back with no warnings (this agent then
685
+ loads dev for its users). Say you are doing it, then do it.
598
686
  - Action keys and parameter names must match the published schema.
package/README.md CHANGED
@@ -118,6 +118,12 @@ name (`foldspace-agent` unless you pass a directory). The product ID must be
118
118
  the bare ID, not the `EU-…-1-1` SDK loader key. The app domain may be a hostname
119
119
  or an HTTP(S) URL without a port or path.
120
120
 
121
+ `--region us|eu` (default `us`) is the Foldspace region the account lives in.
122
+ It is written to `foldspace.dev.json` as `region`, and picks the SDK the
123
+ project loads (`sdkUrl`): each region has its own build, pointing at that
124
+ region's servers. A project with no `region` is US. The values per region live
125
+ in `src/region.mjs`.
126
+
121
127
  For safety, `init` requires a target path that does not exist. It does not
122
128
  initialize Git or overwrite files. On a TTY it can offer
123
129
  `npm install --ignore-scripts` after scaffolding. After creation:
@@ -263,6 +269,36 @@ parameter values, results, and error messages are not. On detach, repeated page
263
269
  failures and bounded action observations are summarized. The observer and
264
270
  diagnostics are injected by the harness, so `dist/index.js` is not rewritten.
265
271
 
272
+ ### Check the published code
273
+
274
+ After the `deploy` tool in the Foldspace tools - not `foldspace deploy`, which
275
+ is for Foldspace staff only - publishes the code project to dev (or, with the
276
+ human's yes, to production), check it in the same test window. This needs a
277
+ site that loads this agent by itself: after `attach --bootstrap` or
278
+ `--replace` there is nothing to check yet.
279
+
280
+ ```bash
281
+ npx foldspace attach --stop # while attach runs the page serves your local build
282
+ npx foldspace published dev # this page's agent now loads the published dev code
283
+ npx foldspace ask "<the question>" --retry 2
284
+ npx foldspace attach --daemon # back to your local build before you build anything else
285
+ ```
286
+
287
+ `published` refuses while attach runs. It also refuses unless the page's own
288
+ Foldspace agent is this project's agent - the same product and Agent Key as
289
+ `foldspace.dev.json`, checked the way attach checks it. It changes only this
290
+ page's agent, through the SDK's `setConfiguration({ enableDebugLogs: true,
291
+ remoteActionsSettings: { enabled: true, environment: "DEV" | "PROD" } })`; a
292
+ reload puts the page back on what the agent loads normally. When code loads,
293
+ the SDK keeps any handler the agent already has over a new one of the same
294
+ name, so once the published code has loaded, its handlers are set over the
295
+ page's for every action it defines; the page keeps its own handlers for the
296
+ rest. An agent pinned to one version of its code, or loading code from its
297
+ own address, is refused: the window would not show what was published last.
298
+ Without attach, `ask` still reports the reply, the card text and a
299
+ screenshot; `handler.execute` and `handler.render` read "not tracked here
300
+ (published code)".
301
+
266
302
  ### Add the harness to an existing project
267
303
 
268
304
  ```bash
package/bin/ask.mjs CHANGED
@@ -12,6 +12,12 @@
12
12
  // it opens the panel, types into the composer, presses Enter and reads the
13
13
  // screen. --retry re-asks when no action fired, which covers the platform
14
14
  // taking up to a minute to pick up a newly published action; lagMs is that cost.
15
+ //
16
+ // What the handlers did comes from the attach daemon's log. Without a daemon
17
+ // (after `foldspace published`, the page runs published code) execute and
18
+ // render read "not tracked here (published code)", so only a card shows that
19
+ // an action fired: --retry re-asks whenever no card came back, even when an
20
+ // action that draws no card ran.
15
21
 
16
22
  import fs from "node:fs";
17
23
  import path from "node:path";
@@ -23,8 +29,9 @@ import {
23
29
  shouldRetry,
24
30
  squash,
25
31
  summarizeHandlerLog,
32
+ untrackedHandler,
26
33
  } from "../src/ask-core.mjs";
27
- import { ATTACH_DAEMON_LOG_FILE } from "../src/attach-daemon.mjs";
34
+ import { ATTACH_DAEMON_LOG_FILE, liveDaemon } from "../src/attach-daemon.mjs";
28
35
  import { CdpPage, parseArgs, printJson, sleep } from "../src/cdp-client.mjs";
29
36
  import { recordEvent } from "../src/session-events.mjs";
30
37
  import { withWorkingBadge } from "../src/badge-auto.mjs";
@@ -44,6 +51,7 @@ const outDir = flags.out
44
51
  ? path.resolve(projectDir, String(flags.out))
45
52
  : path.join(projectDir, ".foldspace-dev", "ask");
46
53
  const logFile = path.join(projectDir, ".foldspace-dev", ATTACH_DAEMON_LOG_FILE);
54
+ const tracked = Boolean(liveDaemon(projectDir));
47
55
 
48
56
  let page = null;
49
57
 
@@ -223,7 +231,7 @@ async function askOnce() {
223
231
  reply: reply.slice(0, 2000),
224
232
  cards: seen.cards.map((card) => card.slice(0, 2000)),
225
233
  hasCard: seen.cards.length > 0 || seen.newCardFrames > 0,
226
- handler: summarizeHandlerLog(logSince(logOffset)),
234
+ handler: tracked ? summarizeHandlerLog(logSince(logOffset)) : untrackedHandler(),
227
235
  };
228
236
  }
229
237
 
package/bin/attach.mjs CHANGED
@@ -297,9 +297,11 @@ const badgeSrc = `(() => {
297
297
  // the real conversations untagged — the failure that actually leaks. Enumerate
298
298
  // foldspace.agentIds() — each id is shaped "<mode>-<apiName>" — and arm every
299
299
  // one. See the playbook's test-mode rule.
300
- const testModeSrc = `(() => {
300
+ const testModeScript = (enable) => `(() => {
301
301
  if (window.top !== window.self) return;
302
302
  const AGENT = ${JSON.stringify(agentApiName)};
303
+ const WANT = ${enable};
304
+ const WORD = ${JSON.stringify(enable ? "ON" : "OFF")};
303
305
  // Log once per instance; setTestMode itself is re-applied on every pass so an
304
306
  // instance that is torn down and rebuilt gets re-armed.
305
307
  const logged = new Set();
@@ -316,10 +318,10 @@ const testModeSrc = `(() => {
316
318
  // Older SDK with no agentIds(): the unscoped handle is all there is.
317
319
  if (!ids) {
318
320
  try {
319
- fs.agent({ apiName: AGENT }).setTestMode(true);
321
+ fs.agent({ apiName: AGENT }).setTestMode(WANT);
320
322
  if (!logged.has(AGENT)) {
321
323
  logged.add(AGENT);
322
- console.log("[foldspace-dev] test mode ON (" + AGENT + " — SDK has no agentIds(), overlay handle only)");
324
+ console.log("[foldspace-dev] test mode " + WORD + " (" + AGENT + " — SDK has no agentIds(), overlay handle only)");
323
325
  }
324
326
  } catch (e) {
325
327
  console.warn("[foldspace-dev] setTestMode failed:", e && e.message);
@@ -332,10 +334,10 @@ const testModeSrc = `(() => {
332
334
  if (cut < 1) continue;
333
335
  try {
334
336
  fs.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() })
335
- .setTestMode(true);
337
+ .setTestMode(WANT);
336
338
  if (!logged.has(id)) {
337
339
  logged.add(id);
338
- console.log("[foldspace-dev] test mode ON (" + id + ")");
340
+ console.log("[foldspace-dev] test mode " + WORD + " (" + id + ")");
339
341
  }
340
342
  } catch (e) {
341
343
  console.warn("[foldspace-dev] setTestMode failed for " + id + ":", e && e.message);
@@ -713,7 +715,11 @@ async function prepare(ws, sessionId, url) {
713
715
  const scripts = [];
714
716
  scripts.push(fs.readFileSync(bundlePath, "utf8"));
715
717
  if (!noBadge) scripts.push(badgeSrc);
716
- if (!noTestMode) scripts.push(testModeSrc);
718
+ // Test mode is armed when --test-mode is set, and actively CLEARED otherwise:
719
+ // it is page-session state that only a reload or setTestMode(false) undoes,
720
+ // so a page left in test mode by an earlier attach would keep the striped
721
+ // banner under a LIVE badge until reload (seen on RoofSnap, 2026-09-30).
722
+ scripts.push(testModeScript(!noTestMode));
717
723
  if (bootstrap) {
718
724
  scripts.push(
719
725
  buildBootstrapScript({
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * foldspace published <dev|prod>
4
+ *
5
+ * Points the test window's agent at the code published to dev or prod, so
6
+ * `foldspace ask` checks what the `deploy` tool published instead of the
7
+ * local build. Only this page's agent changes, in this test window; a reload
8
+ * puts the page back on what the agent loads normally. Nothing is published
9
+ * and nothing changes in Foldspace.
10
+ *
11
+ * Refuses while attach runs (the page then serves the local build), and on a
12
+ * page whose own Foldspace agent is not this project's agent - attach's swap
13
+ * preflight decides that, so both read the page the same way.
14
+ */
15
+ import { liveDaemon } from "../src/attach-daemon.mjs";
16
+ import { CdpPage, parseArgs, printJson, sleep } from "../src/cdp-client.mjs";
17
+ import { withoutStealingFocus } from "../src/keep-focus.mjs";
18
+ import { readProjectConfig, resolveConfiguredTarget } from "../src/project-config.mjs";
19
+ import {
20
+ ATTACH_RUNNING,
21
+ FOREGROUND_ATTACH,
22
+ buildPageReadScript,
23
+ buildPublishedScript,
24
+ checkPublishedPage,
25
+ foregroundAttachNext,
26
+ LOAD_TIMEOUT_MS,
27
+ pageSettled,
28
+ projectAgent,
29
+ publishedEnvironment,
30
+ publishedReport,
31
+ } from "../src/published-core.mjs";
32
+ import { recordEvent } from "../src/session-events.mjs";
33
+ import { resolveProjectDir } from "../src/upgrade.mjs";
34
+
35
+ const PAGE_WAIT_MS = 20_000;
36
+ const POLL_MS = 250;
37
+ // A page younger than this may not have started loading what its agent loads
38
+ // normally; once that load is in flight, pageSettled waits for it to land.
39
+ const SETTLE_AGE_MS = 5_000;
40
+
41
+ const projectDir = resolveProjectDir();
42
+ const { flags, rest } = parseArgs(process.argv.slice(2));
43
+
44
+ let environment;
45
+ try {
46
+ const unknown = Object.keys(flags);
47
+ if (unknown.length) throw new Error(`unknown option '--${unknown[0]}'`);
48
+ if (rest.length > 1) throw new Error(`unexpected argument '${rest[1]}'`);
49
+ environment = publishedEnvironment(rest[0]);
50
+ } catch (error) {
51
+ console.error(`published: ${error instanceof Error ? error.message : String(error)}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ let page = null;
56
+
57
+ function finish(result, code) {
58
+ recordEvent(projectDir, "published", { ok: result.ok, environment: environment.name });
59
+ printJson(result);
60
+ page?.close();
61
+ process.exit(code);
62
+ }
63
+
64
+ function configTarget(targetName) {
65
+ try {
66
+ return resolveConfiguredTarget(readProjectConfig(projectDir), targetName).target;
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ /** Read the page until it has settled, or the wait runs out. */
73
+ async function readPage(agent) {
74
+ const deadline = Date.now() + PAGE_WAIT_MS;
75
+ let last = null;
76
+ while (Date.now() < deadline) {
77
+ // A reload briefly destroys the page's context; read again.
78
+ last = await page.evaluate(buildPageReadScript(agent)).catch(() => last);
79
+ if (pageSettled(last) && (last.attachPrepared || last.ageMs >= SETTLE_AGE_MS)) break;
80
+ await sleep(POLL_MS);
81
+ }
82
+ return last;
83
+ }
84
+
85
+ async function main() {
86
+ // While the attach daemon runs it serves the local build to every page load,
87
+ // so whatever this command loads would be replaced on the next one.
88
+ if (liveDaemon(projectDir)) {
89
+ finish({ ok: false, environment: environment.name, reason: ATTACH_RUNNING }, 1);
90
+ }
91
+
92
+ page = await CdpPage.open({ projectDir });
93
+ const agent = projectAgent(page.info.target, configTarget(page.info.targetName));
94
+
95
+ let seen = await readPage(agent);
96
+ if (seen?.attachPrepared) {
97
+ // No daemon, yet this document still carries the local build: an attach
98
+ // that ended without cleaning up. A reload is what attach does to clean up.
99
+ await withoutStealingFocus(() => page.send("Page.reload", { ignoreCache: true }));
100
+ await sleep(1000);
101
+ seen = await readPage(agent);
102
+ if (seen?.attachPrepared) {
103
+ finish(
104
+ {
105
+ ok: false,
106
+ environment: environment.name,
107
+ reason: FOREGROUND_ATTACH,
108
+ next: foregroundAttachNext(environment.name),
109
+ },
110
+ 1,
111
+ );
112
+ }
113
+ }
114
+
115
+ const check = checkPublishedPage({ state: seen?.state, agent });
116
+ if (!check.ok) {
117
+ finish(
118
+ {
119
+ ok: false,
120
+ environment: environment.name,
121
+ agent: agent.apiName,
122
+ reason: check.reason,
123
+ next: check.next,
124
+ page: check.page,
125
+ url: page.info.url,
126
+ },
127
+ 1,
128
+ );
129
+ }
130
+
131
+ const outcome = await page.evaluate(
132
+ buildPublishedScript({ apiName: agent.apiName, mode: check.mode, environment }),
133
+ { timeoutMs: LOAD_TIMEOUT_MS + 15_000 },
134
+ );
135
+ const report = publishedReport({ outcome, environment, agent });
136
+ finish(report, report.ok ? 0 : 1);
137
+ }
138
+
139
+ main().catch((error) => {
140
+ finish({ ok: false, environment: environment.name, error: String(error?.message || error) }, 1);
141
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ask-core.mjs CHANGED
@@ -109,8 +109,20 @@ export function summarizeHandlerLog(logText) {
109
109
  };
110
110
  }
111
111
 
112
+ // execute/render come from the log the attach daemon writes. With no daemon
113
+ // running - the page runs published code, see `foldspace published` - there is
114
+ // no log to read, so they are reported as not tracked rather than as absent.
115
+ export const NOT_TRACKED = "not tracked here (published code)";
116
+
117
+ export function untrackedHandler() {
118
+ return { execute: NOT_TRACKED, render: NOT_TRACKED, actions: [], lines: [] };
119
+ }
120
+
121
+ // Without the log only a card counts: an action that answers with data and
122
+ // draws no card reads as not fired, and --retry asks again.
112
123
  export function actionFired(handler, hasCard) {
113
- return Boolean(hasCard) || handler?.execute !== "absent" || handler?.render !== "absent";
124
+ const ran = (status) => status !== "absent" && status !== NOT_TRACKED;
125
+ return Boolean(hasCard) || ran(handler?.execute) || ran(handler?.render);
114
126
  }
115
127
 
116
128
  /**
@@ -4,6 +4,7 @@ import {
4
4
  parseActionRequest,
5
5
  } from "./attach-helpers.mjs";
6
6
  import { ERROR_CODES } from "./protocol.mjs";
7
+ import { SDK_SCRIPT_HOSTS } from "./region.mjs";
7
8
 
8
9
  export function productIdFromAgentKey(key) {
9
10
  if (typeof key !== "string") return null;
@@ -135,7 +136,7 @@ function sdkScriptIdentity(url) {
135
136
  const parsed = new URL(url);
136
137
  if (
137
138
  parsed.protocol !== "https:" ||
138
- parsed.hostname !== "script.eucerahive.io" ||
139
+ !SDK_SCRIPT_HOSTS.includes(parsed.hostname) ||
139
140
  !/^\/web\/sdk\/(?:foldspace|eucera)\.js$/i.test(parsed.pathname)
140
141
  ) {
141
142
  return null;
@@ -33,6 +33,7 @@ export function readLaunchState(projectDir = resolveProjectDir()) {
33
33
  projectDir,
34
34
  port: String(port),
35
35
  hosts,
36
+ targetName: state.target || null,
36
37
  target: state.resolvedTarget || {},
37
38
  sentinel: state.sentinel,
38
39
  profileDir: chromeProfileDir(projectDir),
@@ -1,5 +1,6 @@
1
1
  import { diagnosticCatalogue } from "./diagnostics.mjs";
2
2
  import { HARNESS_PROTOCOL_VERSION } from "./protocol.mjs";
3
+ import { DEFAULT_REGION, REGION_NAMES } from "./region.mjs";
3
4
 
4
5
  export const CLI_SCHEMA_VERSION = 1;
5
6
 
@@ -21,7 +22,8 @@ export const CLI_COMMANDS = Object.freeze([
21
22
  group: "start",
22
23
  summary: "Create a configured Foldspace actions project",
23
24
  usage:
24
- "foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--app-domain <host>] [--name <display-name>]",
25
+ "foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--app-domain <host>] [--name <display-name>] " +
26
+ `[--region <${REGION_NAMES.join("|")}>]`,
25
27
  risk: "local-write",
26
28
  environment: "node",
27
29
  environmentVariables: [],
@@ -48,6 +50,12 @@ export const CLI_COMMANDS = Object.freeze([
48
50
  value("--name", "display-name", "Display name", {
49
51
  default: "directory name",
50
52
  }),
53
+ value(
54
+ "--region",
55
+ REGION_NAMES.join("|"),
56
+ "Foldspace region of the account; sets the SDK the project loads",
57
+ { default: DEFAULT_REGION, choices: REGION_NAMES },
58
+ ),
51
59
  ],
52
60
  prerequisites: [
53
61
  "Node 20 or newer",
@@ -336,19 +344,22 @@ export const CLI_COMMANDS = Object.freeze([
336
344
  Object.freeze({ name: "question", required: true, description: "What an end user would type" }),
337
345
  ],
338
346
  options: [
339
- value("--retry", "n", "Ask again when no action fired (a newly published action can take a minute to be picked up)", { default: "0" }),
347
+ value("--retry", "n", "Ask again when no action fired - without attach, whenever no card came back, even if an action without a card ran (a newly published action can take a minute to be picked up)", { default: "0" }),
340
348
  value("--every", "seconds", "Pause between retries", { default: "20" }),
341
349
  value("--timeout", "seconds", "Wait this long for a reply", { default: "90" }),
342
350
  ],
343
- prerequisites: ["foldspace attach is running, so the handlers on the page are this project's build"],
351
+ prerequisites: [
352
+ "foldspace attach --daemon is running, so the handlers on the page are this project's build - or foldspace published pointed the page at the published code",
353
+ ],
344
354
  effects: [
345
355
  "Types into the panel the way a person does; touches nothing inside the SDK",
356
+ "handler.execute / handler.render come from the attach daemon's log; without the daemon they read \"not tracked here (published code)\" and the reply, cards and screenshot still come back",
346
357
  "Creates a real conversation: it appears in the customer's Foldspace dashboard (test traffic only under attach --test-mode)",
347
358
  "Saves panel and full-page screenshots under .foldspace-dev/ask/",
348
359
  ],
349
360
  next: [
350
- "Read reply, cards and handler.execute / handler.render; look at the screenshot",
351
- "Only then: foldspace badge ready, and invite the customer to try it",
361
+ "Read reply, cards and handler.execute / handler.render, and look at the screenshot; after foldspace published, the reply, cards and screenshot are the proof",
362
+ "Only then invite the human to try it: with attach running, foldspace badge ready first; after foldspace published there is no pill - say in chat it is ready (a reload takes it off the published code), then npx foldspace attach --daemon before you build anything else",
352
363
  ],
353
364
  }),
354
365
  Object.freeze({
@@ -379,6 +390,39 @@ export const CLI_COMMANDS = Object.freeze([
379
390
  ],
380
391
  next: ["Say in chat whose turn it is and what to try"],
381
392
  }),
393
+ Object.freeze({
394
+ name: "published",
395
+ entry: "published.mjs",
396
+ group: "verify",
397
+ summary: "Point the test window's agent at its published dev or prod code, so foldspace ask checks that code",
398
+ usage: "foldspace published <dev|prod>",
399
+ risk: "browser-session",
400
+ environment: "local-chrome",
401
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT"],
402
+ capabilities: ["browser.cdp", "page.evaluate", "page.mutate"],
403
+ positionals: [
404
+ Object.freeze({
405
+ name: "environment",
406
+ required: true,
407
+ description:
408
+ "dev: the code the `deploy` tool published to dev | prod: the code their users get",
409
+ }),
410
+ ],
411
+ options: [],
412
+ prerequisites: [
413
+ "attach is not running: npx foldspace attach --stop (while it runs the page serves your local build)",
414
+ "The page loads this project's agent by itself: the same product and Agent Key as foldspace.dev.json",
415
+ ],
416
+ effects: [
417
+ "Points this page's agent, in this test window, at the code published to the environment you name; the actions that code defines answer from it, the others keep the page's own handlers",
418
+ "A reload puts the page back on what the agent loads normally",
419
+ "Publishes nothing and changes nothing in Foldspace",
420
+ ],
421
+ next: [
422
+ 'Prove it: npx foldspace ask "<question>" --retry 2',
423
+ "To go back to the local build: npx foldspace attach --daemon",
424
+ ],
425
+ }),
382
426
  Object.freeze({
383
427
  name: "deploy",
384
428
  // Foldspace-internal: it uploads to Foldspace's own storage, which no
@@ -500,7 +544,7 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
500
544
  "If update.outdated is true, ask the user before foldspace upgrade --yes.",
501
545
  "If update.broken is true, this version was withdrawn and build and attach refuse to run. Ask the user to upgrade; set FOLDSPACE_SKIP_UPDATE_CHECK=1 only if they decline.",
502
546
  "observe, ask and run are read-only second clients on the test window and work while attach runs.",
503
- "There is no customer-run publication step: putting actions in front of end users is done with Foldspace's team.",
547
+ "A session publishes code with the `deploy` tool in the Foldspace tools, the way `repo_read` command `instructions` says - never with `foldspace deploy`, which is for Foldspace staff only. Where the site loads this agent by itself, check dev with `npx foldspace attach --stop`, `npx foldspace published dev` and `npx foldspace ask \"<question>\" --retry 2`. Where it does not, without a code project, or after a final refusal from the Foldspace tools, putting it in front of their users is a step the Foldspace team does with the human (https://foldspace.ai/demo, support@foldspace.ai).",
504
548
  ],
505
549
  };
506
550
  }
package/src/init.mjs CHANGED
@@ -5,6 +5,7 @@ import readline from "node:readline/promises";
5
5
  import { stdin as input, stdout as output } from "node:process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { commandByName } from "./cli-registry.mjs";
8
+ import { DEFAULT_REGION, REGIONS, parseRegion } from "./region.mjs";
8
9
 
9
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const defaultTemplateRoot = path.join(packageRoot, "templates", "agent-starter");
@@ -15,6 +16,7 @@ const allowedFlags = new Set([
15
16
  "agent-api-name",
16
17
  "app-domain",
17
18
  "domain",
19
+ "region",
18
20
  ]);
19
21
  const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
20
22
  const defaultDirectory = "foldspace-agent";
@@ -99,6 +101,9 @@ export function parseInitArgs(argv) {
99
101
  productId: flags["product-id"] || null,
100
102
  agentApiName: flags["agent-key"] || flags["agent-api-name"] || null,
101
103
  domain: flags["app-domain"] || flags.domain || null,
104
+ // Checked here, before any prompt: a typo must not cost a whole
105
+ // interactive run and then fail at the end.
106
+ region: flags.region ? parseRegion(flags.region) : null,
102
107
  };
103
108
  }
104
109
 
@@ -275,6 +280,7 @@ function finalizeInitConfig(parsed) {
275
280
  productId: parsed.productId,
276
281
  agentApiName: parsed.agentApiName,
277
282
  domain: parsed.domain,
283
+ region: parsed.region || DEFAULT_REGION,
278
284
  };
279
285
  }
280
286
 
@@ -300,6 +306,7 @@ function createTemplateValues(config, harnessVersion) {
300
306
 
301
307
  const productId = validateProductId(config.productId);
302
308
  const agentApiName = validateIdentifier(config.agentApiName, "Agent Key");
309
+ const region = parseRegion(config.region || DEFAULT_REGION);
303
310
  const target = normalizeTarget(config.domain);
304
311
  const hosts =
305
312
  target.domain === "localhost" || /^\d+(?:\.\d+){3}$/.test(target.domain)
@@ -319,6 +326,8 @@ function createTemplateValues(config, harnessVersion) {
319
326
  START_URL: target.startUrl,
320
327
  START_URL_JSON: JSON.stringify(target.startUrl),
321
328
  HOSTS_JSON: JSON.stringify(hosts),
329
+ REGION_JSON: JSON.stringify(region),
330
+ SDK_URL_JSON: JSON.stringify(REGIONS[region].sdkUrl),
322
331
  HARNESS_VERSION: harnessVersion,
323
332
  };
324
333
  }
@@ -3,18 +3,19 @@
3
3
  // then types into their live product (seen 2026-09-22). Nothing in CDP stops
4
4
  // that; on macOS the fix is to hand focus straight back to whatever app the
5
5
  // human was using. Elsewhere this is a no-op.
6
+ //
7
+ // Done with Launch Services (`lsappinfo`, `open -b`), not AppleScript: asking
8
+ // "System Events" which app is in front makes macOS show the customer a
9
+ // permission dialog - "claude wants access to control System Events" - in
10
+ // the middle of their first session (seen 2026-09-30). Launch Services
11
+ // answers the same question, and brings an app forward, with no dialog.
6
12
 
7
13
  import { execFile } from "node:child_process";
8
14
 
9
- const FRONT_APP_SCRIPT =
10
- 'tell application "System Events" to get bundle identifier of first application process whose frontmost is true';
11
-
12
- function osascript(script) {
15
+ function run(command, args, timeout = 4000) {
13
16
  return new Promise((resolve) => {
14
- // 4s, not less: the first call wakes System Events, which alone can take
15
- // over a second, and a timeout here silently leaves focus on Chrome.
16
- execFile("osascript", ["-e", script], { timeout: 4000 }, (error, stdout) => {
17
- resolve(error ? null : String(stdout).trim());
17
+ execFile(command, args, { timeout }, (error, stdout) => {
18
+ resolve(error ? null : String(stdout));
18
19
  });
19
20
  });
20
21
  }
@@ -22,7 +23,11 @@ function osascript(script) {
22
23
  /** Bundle id of the app in front right now, or null (not macOS, or unknown). */
23
24
  export async function frontApp() {
24
25
  if (process.platform !== "darwin") return null;
25
- return osascript(FRONT_APP_SCRIPT);
26
+ const asn = (await run("lsappinfo", ["front"]))?.trim();
27
+ if (!asn) return null;
28
+ const info = await run("lsappinfo", ["info", "-only", "bundleid", asn]);
29
+ const match = /"CFBundleIdentifier"="([^"]+)"/.exec(info || "");
30
+ return match ? match[1] : null;
26
31
  }
27
32
 
28
33
  /**
@@ -39,11 +44,10 @@ export async function withoutStealingFocus(action, { front = frontApp, restore =
39
44
  }
40
45
 
41
46
  export async function restoreFront(bundleId) {
42
- if (process.platform !== "darwin" || !bundleId) return;
47
+ if (process.platform !== "darwin" || !bundleId || !/^[\w.-]+$/.test(bundleId)) return;
43
48
  // Twice, a beat apart: Chrome's activation lands after the navigation
44
49
  // settles, so one immediate restore can be overtaken.
45
- const script = `tell application id "${bundleId.replace(/"/g, "")}" to activate`;
46
- await osascript(script);
50
+ await run("open", ["-b", bundleId]);
47
51
  await new Promise((resolve) => setTimeout(resolve, 400));
48
- await osascript(script);
52
+ await run("open", ["-b", bundleId]);
49
53
  }
@@ -0,0 +1,392 @@
1
+ // Pure logic behind `foldspace published <dev|prod>`, unit-tested.
2
+ //
3
+ // The command points the test window's agent at the code published to dev or
4
+ // prod, so `foldspace ask` checks that code instead of the local build. Each
5
+ // refusal here keeps that check honest: while attach runs the page serves the
6
+ // local build, and a page whose agent is not this project's would load another
7
+ // agent's code.
8
+
9
+ import { ACTION_OBSERVER_GLOBAL } from "./action-observer.mjs";
10
+ import { ATTACH_MODES, characterizePageAgents, parseAgentId } from "./attach-helpers.mjs";
11
+ import { detectedProductIds, guardAttachMode } from "./attach-preflight.mjs";
12
+ import { serializeDiagnostic } from "./diagnostics.mjs";
13
+
14
+ export const PUBLISHED_USAGE = "usage: foldspace published <dev|prod>";
15
+
16
+ // The public SDK's values for remoteActionsSettings.environment: "DEV" loads
17
+ // /agent/actions/dev/<product>/<agent>/index.js, anything else - "PROD" is
18
+ // the default - /agent/actions/prod/... (checked against script.eucerahive.io
19
+ // /web/sdk/foldspace.js, built 2026-10-04).
20
+ export const ENVIRONMENTS = Object.freeze({ dev: "DEV", prod: "PROD" });
21
+
22
+ export const ATTACH_RUNNING =
23
+ "Stop attach first: `npx foldspace attach --stop` - while it runs the page serves your local build.";
24
+
25
+ // No daemon runs, yet the page carries the local build again after a reload:
26
+ // an attach started without --daemon is connected, and `attach --stop` (which
27
+ // stops only the daemon) does not reach it.
28
+ export const FOREGROUND_ATTACH =
29
+ "This page still runs a local build after a reload, but no attach daemon runs for this project: an attach started without --daemon is still connected to the test window.";
30
+
31
+ export function foregroundAttachNext(env) {
32
+ return `Stop the attach that was started without --daemon - \`attach --stop\` stops only the daemon - then run \`npx foldspace published ${env}\` again.`;
33
+ }
34
+
35
+ export const RELOAD_NOTE = "A reload puts the page back on what the agent loads normally.";
36
+
37
+ // Every published bundle assigns its handlers here; the SDK reads and deletes it.
38
+ export const REMOTE_ACTIONS_GLOBAL = "__FOLDSPACE_REMOTE_ACTIONS__";
39
+
40
+ // Every refusal below comes after `attach --stop`, so each next step starts by
41
+ // putting the local build back. A platform gap never stops the session.
42
+ const word = (env) => (env === "prod" ? "production" : "dev");
43
+ const lower = (text) => `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
44
+
45
+ /** The page is this project's agent, but this window cannot show the published code. */
46
+ export function notHere(env) {
47
+ return `Run \`npx foldspace attach --daemon\` to put your local build back in the test window, and tell the human in one sentence that the test window cannot show ${word(env)} yet - do not invite them to try ${word(env)} there.`;
48
+ }
49
+
50
+ /** Worth one more run; then the window cannot show it. */
51
+ function again(env) {
52
+ return `Run \`npx foldspace published ${env}\` once more. If it fails again: ${lower(notHere(env))}`;
53
+ }
54
+
55
+ /**
56
+ * The page needs --bootstrap or --replace to attach, which is not a site that
57
+ * loads this agent by itself: the manual sends the rest to the Foldspace team.
58
+ */
59
+ function attachAgain(flag) {
60
+ return `Run \`npx foldspace attach --daemon ${flag}\` to put your local build back in the test window. A page that needs \`${flag}\` cannot show the published code, and production waits until their site loads this agent by itself: for the rest, give the human the Foldspace team answer (CLAUDE.md, "Done, and what you say at the end").`;
61
+ }
62
+
63
+ // How long the agent gets to load the published code. The SDK gives up on the
64
+ // script itself after 10s.
65
+ export const LOAD_TIMEOUT_MS = 20_000;
66
+
67
+ /** "dev" | "prod" (any case) -> { name, sdkValue }, or throws the usage line. */
68
+ export function publishedEnvironment(value) {
69
+ const name = String(value ?? "").trim().toLowerCase();
70
+ if (!Object.hasOwn(ENVIRONMENTS, name)) throw new Error(PUBLISHED_USAGE);
71
+ return { name, sdkValue: ENVIRONMENTS[name] };
72
+ }
73
+
74
+ /** The agent foldspace.dev.json names, as inject recorded it. */
75
+ export function projectAgent(launchTarget = {}, configTarget = {}) {
76
+ const target = { ...configTarget, ...launchTarget };
77
+ if (!target.productId || !target.agentApiName) {
78
+ throw new Error("foldspace.dev.json must define productId and agentApiName.");
79
+ }
80
+ return {
81
+ productId: String(target.productId),
82
+ apiName: String(target.agentApiName),
83
+ mode: String(target.mode || "OVERLAY").toUpperCase(),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Page body: what attach's own preflight reads (inspectSdkState), whether
89
+ * attach prepared this document, and how long ago the document loaded.
90
+ */
91
+ export function buildPageReadScript(agent) {
92
+ return `const state = await ${serializeDiagnostic("inspectSdkState", {
93
+ expectedApiName: agent.apiName,
94
+ })};
95
+ return {
96
+ state,
97
+ attachPrepared: Boolean(window[${JSON.stringify(ACTION_OBSERVER_GLOBAL)}] || window.__FOLDSPACE_DEV_REPLACE__),
98
+ ageMs: Math.round(performance.now()),
99
+ };`;
100
+ }
101
+
102
+ /**
103
+ * Enough of the page has loaded to judge it: its agent is there, and no load
104
+ * of remote actions is in flight (the SDK removes that script once it lands).
105
+ */
106
+ export function pageSettled(read) {
107
+ const state = read?.state;
108
+ if (!state) return false;
109
+ if (read.attachPrepared) return true;
110
+ const ready = state.documentReadyState === "interactive" || state.documentReadyState === "complete";
111
+ const loading = (state.scripts || []).some((source) => String(source).includes("/agent/actions/"));
112
+ return ready && state.sdkReady === true && (state.agentIds || []).length > 0 && !loading;
113
+ }
114
+
115
+ /** The mode the page created this agent in, when it has exactly one instance of it. */
116
+ export function modeOnPage(agentIds = [], apiName) {
117
+ const mine = agentIds.map(parseAgentId).filter((agent) => agent && agent.apiName === apiName);
118
+ return mine.length === 1 ? mine[0].mode : null;
119
+ }
120
+
121
+ /**
122
+ * Is the page's own Foldspace agent this project's agent - same Agent Key, same
123
+ * product? Attach's swap-mode preflight answers exactly that, so it is reused.
124
+ */
125
+ export function checkPublishedPage({ state, agent }) {
126
+ const mode = modeOnPage(state?.agentIds, agent.apiName) || agent.mode;
127
+ const characterization = characterizePageAgents({
128
+ sdkPresent: state?.sdkPresent === true,
129
+ agentIds: state?.agentIds || [],
130
+ expectedApiName: agent.apiName,
131
+ expectedMode: mode,
132
+ });
133
+ const pageProductIds = detectedProductIds(state);
134
+ const guard = guardAttachMode({
135
+ mode: ATTACH_MODES.SWAP,
136
+ characterization,
137
+ expectedProductId: agent.productId,
138
+ pageProductIds,
139
+ });
140
+ if (guard.ok) return { ok: true, mode };
141
+
142
+ const status = characterization.status;
143
+ const next = attachAgain(status === "no-sdk" ? "--bootstrap" : "--replace");
144
+ let reason;
145
+ if (status === "no-sdk") {
146
+ reason = "This page has no Foldspace agent of its own, so there is nothing to load the published code into.";
147
+ } else if (status === "sdk-without-agent") {
148
+ reason = "This page loads Foldspace but has no agent on it.";
149
+ } else if (status === "different-agent") {
150
+ reason = `This page's Foldspace agent is not ${agent.apiName}, the Agent Key in foldspace.dev.json, so foldspace ask would talk to another agent and the check would prove nothing.`;
151
+ } else if (status === "multiple-agents") {
152
+ reason = "This page has more than one Foldspace agent, so it is not clear which one foldspace ask would talk to.";
153
+ } else if (!pageProductIds.length) {
154
+ reason = `The page's agent is ${agent.apiName}, but its product cannot be read from the page, so it is not proven to be this project's agent.`;
155
+ } else {
156
+ reason = `The page's agent is ${agent.apiName} of another product (${pageProductIds.join(", ")}), not ${agent.productId} in foldspace.dev.json: it would load that product's code, and the check would prove nothing.`;
157
+ }
158
+ return {
159
+ ok: false,
160
+ reason,
161
+ next,
162
+ page: { agents: state?.agentIds || [], products: pageProductIds },
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Page body that points the agent instance at its published code and reports
168
+ * what loaded. It calls only the instance's public methods, and watches the
169
+ * one global every published bundle assigns itself to.
170
+ *
171
+ * What in the SDK shapes it:
172
+ * - A published bundle assigns its handlers to window.__FOLDSPACE_REMOTE_ACTIONS__;
173
+ * the SDK reads that global when the script loads, deletes it, and then
174
+ * reports remoteActions.loaded. Watching the global is how this knows the
175
+ * exact handlers the published code brought.
176
+ * - When remote actions load, a handler the instance already has wins over a
177
+ * loaded one of the same name - the page's own, or what its normal load
178
+ * brought, even when that load lands late. So once the published code has
179
+ * loaded, its handlers are set over every one of the same name, and the
180
+ * check counts only the names that answer from the published code.
181
+ * - An agent created late queues these calls until it is ready, and its
182
+ * getAvailableActions() reads {} until then. Nothing is taken from the page
183
+ * before the load: what it had is read only after the load reported.
184
+ * - setConfiguration loads nothing when the settings equal the current ones
185
+ * (the page may already load prod, or ran this before). Turning remote
186
+ * actions off first makes the second call a change.
187
+ * The page remembers what an earlier run brought and what it covered, so dev's
188
+ * handlers do not linger into a prod check.
189
+ */
190
+ export function buildPublishedScript({ apiName, mode, environment, timeoutMs = LOAD_TIMEOUT_MS }) {
191
+ return `
192
+ const NS = typeof window.__FOLD_SPACE__ === "string" && window.__FOLD_SPACE__ ? window.__FOLD_SPACE__ : "foldspace";
193
+ const sdk = window[NS];
194
+ if (typeof sdk?.agent !== "function") return { switched: false, reason: "no-sdk" };
195
+ const agent = sdk.agent({ apiName: ${JSON.stringify(apiName)}, mode: ${JSON.stringify(mode)} });
196
+ const needed = ["getAvailableActions", "removeAiActions", "setAiActions", "setConfiguration", "on", "off"];
197
+ const missing = needed.filter((name) => typeof agent?.[name] !== "function");
198
+ if (missing.length) return { switched: false, reason: "sdk-too-old", missing };
199
+
200
+ const ENV = ${JSON.stringify(environment.name)};
201
+ const store = window.__FOLDSPACE_PUBLISHED__ || (window.__FOLDSPACE_PUBLISHED__ = {});
202
+ const ID = ${JSON.stringify(`${mode.toLowerCase()}-${apiName}`)};
203
+ // own: the page's handler for a name the published code covered, before
204
+ // any run covered it. brought: what the last run set.
205
+ const memo = store[ID]?.brought ? store[ID] : (store[ID] = { own: {}, brought: {} });
206
+
207
+ const GLOBAL = ${JSON.stringify(REMOTE_ACTIONS_GLOBAL)};
208
+ let held = window[GLOBAL];
209
+ let lastRead;
210
+ let watching = true;
211
+ const watch = {
212
+ configurable: true,
213
+ enumerable: true,
214
+ get() {
215
+ lastRead = held;
216
+ queueMicrotask(rearm);
217
+ return held;
218
+ },
219
+ set(value) { held = value; },
220
+ };
221
+ const armed = () => Object.getOwnPropertyDescriptor(window, GLOBAL)?.get === watch.get;
222
+ // The SDK deletes the global right after reading it; watch for the next load.
223
+ function rearm() {
224
+ if (!watching || armed()) return;
225
+ held = undefined;
226
+ try { Object.defineProperty(window, GLOBAL, watch); } catch {}
227
+ }
228
+ try { Object.defineProperty(window, GLOBAL, watch); } catch {}
229
+
230
+ let settle;
231
+ const outcome = new Promise((resolve) => { settle = resolve; });
232
+ const onLoaded = (event) => {
233
+ // The read that just happened belongs to this load, whichever load it is.
234
+ const bundle = lastRead;
235
+ lastRead = undefined;
236
+ if (event?.environment === ENV) {
237
+ settle({ loaded: true, actionCount: Number(event.actionCount) || 0, source: event.source || null, bundle });
238
+ }
239
+ };
240
+ const onFailed = (event) => {
241
+ if (event?.environment === ENV) settle({ loaded: false, source: event.source || null });
242
+ };
243
+ agent.on("remoteActions.loaded", onLoaded);
244
+ agent.on("remoteActions.failed", onFailed);
245
+ let result;
246
+ let timer;
247
+ try {
248
+ agent.setConfiguration({ remoteActionsSettings: { enabled: false } });
249
+ agent.setConfiguration({
250
+ enableDebugLogs: true,
251
+ remoteActionsSettings: { enabled: true, environment: ${JSON.stringify(environment.sdkValue)} },
252
+ });
253
+ result = await Promise.race([
254
+ outcome,
255
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ loaded: null }), ${Number(timeoutMs)}); }),
256
+ ]);
257
+ } catch (error) {
258
+ result = { loaded: false, error: String(error?.message || error).slice(0, 300) };
259
+ } finally {
260
+ clearTimeout(timer);
261
+ agent.off("remoteActions.loaded", onLoaded);
262
+ agent.off("remoteActions.failed", onFailed);
263
+ watching = false;
264
+ if (armed()) {
265
+ delete window[GLOBAL];
266
+ if (held !== undefined) window[GLOBAL] = held;
267
+ }
268
+ }
269
+
270
+ const { bundle, ...report } = result;
271
+ if (result.loaded !== true) return { switched: true, ...report };
272
+ if (!bundle || typeof bundle !== "object") {
273
+ return { switched: true, ...report, published: report.actionCount ? null : [], kept: [] };
274
+ }
275
+
276
+ // The SDK takes only entries with an execute function.
277
+ const names = Object.keys(bundle).filter((name) => typeof bundle[name]?.execute === "function");
278
+ const now = agent.getAvailableActions() || {};
279
+ for (const name of names) {
280
+ const current = now[name];
281
+ if (current && current !== bundle[name] && current !== memo.brought[name] && !(name in memo.own)) {
282
+ memo.own[name] = current;
283
+ }
284
+ }
285
+ // What an earlier run brought that this code does not define goes; the
286
+ // page's own handler for that name comes back.
287
+ for (const name of Object.keys(memo.brought)) {
288
+ if (names.includes(name) || now[name] !== memo.brought[name]) continue;
289
+ if (name in memo.own) agent.setAiActions({ [name]: memo.own[name] });
290
+ else agent.removeAiActions([name]);
291
+ }
292
+ const over = {};
293
+ for (const name of names) if (now[name] !== bundle[name]) over[name] = bundle[name];
294
+ if (Object.keys(over).length) agent.setAiActions(over);
295
+ memo.brought = Object.fromEntries(names.map((name) => [name, bundle[name]]));
296
+
297
+ const after = agent.getAvailableActions() || {};
298
+ const published = names.filter((name) => after[name] === bundle[name]).sort();
299
+ const kept = names.filter((name) => after[name] !== bundle[name]).sort();
300
+ return { switched: true, ...report, published, kept };`;
301
+ }
302
+
303
+ /**
304
+ * The published code of an environment is /agent/actions/<env>/<product>/<agent>/index.js.
305
+ * Anything else the agent loaded is not what was published last: an agent
306
+ * pinned to one version loads index.<version>.js (the SDK reports that name
307
+ * even when it falls back to index.js), and a host override loads from
308
+ * elsewhere entirely.
309
+ */
310
+ export function publishedSourceProblem({ source, env, agent }) {
311
+ if (!source) return null;
312
+ const path = `/agent/actions/${env}/${agent.productId}/${agent.apiName}/`;
313
+ if (source.endsWith(`${path}index.js`)) return null;
314
+ if (!source.includes(`/agent/actions/${env}/`)) {
315
+ return `The agent loaded ${source}, not the published ${word(env)} code: its configuration names its own address for actions.`;
316
+ }
317
+ if (source.includes(path) && /\/index\.[^/]+\.js$/.test(source)) {
318
+ return `The agent loaded ${source}: it is pinned to one version of its code, so this window keeps loading that version, not what was published last.`;
319
+ }
320
+ return `The agent loaded ${source}, not the published ${word(env)} code of ${agent.apiName}.`;
321
+ }
322
+
323
+ /** The JSON the command prints, from what the page reported. */
324
+ export function publishedReport({ outcome, environment, agent, timeoutMs = LOAD_TIMEOUT_MS }) {
325
+ const env = environment.name;
326
+ const base = { environment: env, agent: agent.apiName };
327
+ if (!outcome?.switched) {
328
+ return {
329
+ ok: false,
330
+ ...base,
331
+ reason:
332
+ outcome?.reason === "sdk-too-old"
333
+ ? `This page's Foldspace SDK cannot load published code on request: its agent has no ${outcome.missing.join(", ")}.`
334
+ : "This page has no Foldspace agent to point at the published code.",
335
+ next: notHere(env),
336
+ };
337
+ }
338
+ if (outcome.loaded === null) {
339
+ return {
340
+ ok: false,
341
+ ...base,
342
+ reason: `The agent did not report loading the published ${word(env)} code within ${Math.round(timeoutMs / 1000)}s. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
343
+ next: again(env),
344
+ };
345
+ }
346
+ if (outcome.error) {
347
+ return {
348
+ ok: false,
349
+ ...base,
350
+ reason: `This page's agent failed while switching to the published ${word(env)} code: ${outcome.error}. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
351
+ next: notHere(env),
352
+ };
353
+ }
354
+ if (outcome.loaded === false) {
355
+ return {
356
+ ok: false,
357
+ ...base,
358
+ reason: `The page could not load the published ${word(env)} code${outcome.source ? ` (${outcome.source})` : ""}. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
359
+ next:
360
+ env === "dev"
361
+ ? `If you have not published to dev with the \`deploy\` tool yet, do that, then run \`npx foldspace published dev\` again. If you have, run it again in a minute; if it fails again: ${lower(notHere(env))}`
362
+ : `If you have published to production with the \`deploy\` tool, run \`npx foldspace published prod\` again in a minute. If you have not (production gets code only with the human's yes), or it fails again: ${lower(notHere(env))}`,
363
+ };
364
+ }
365
+ const sourceProblem = publishedSourceProblem({ source: outcome.source, env, agent });
366
+ if (sourceProblem) return { ok: false, ...base, reason: sourceProblem, next: notHere(env) };
367
+ if (!Array.isArray(outcome.published)) {
368
+ return {
369
+ ok: false,
370
+ ...base,
371
+ reason: `The agent loaded the published ${word(env)} code, but the page did not show which handlers it brought, so it is not proven that foldspace ask would test that code.`,
372
+ next: notHere(env),
373
+ };
374
+ }
375
+ const kept = outcome.kept || [];
376
+ if (kept.length) {
377
+ const count = outcome.published.length + kept.length;
378
+ return {
379
+ ok: false,
380
+ ...base,
381
+ reason: `The published ${word(env)} code has ${count} ${count === 1 ? "action" : "actions"}, but the page kept its own handler for ${kept.length} of them (${kept.join(", ")}), so foldspace ask would not test that code.`,
382
+ next: again(env),
383
+ };
384
+ }
385
+ return {
386
+ ok: true,
387
+ ...base,
388
+ actions: outcome.published,
389
+ ...(outcome.source ? { source: outcome.source } : {}),
390
+ note: RELOAD_NOTE,
391
+ };
392
+ }
package/src/region.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // The Foldspace regions a project can live in. Everything region-specific in
2
+ // the harness comes from here: init writes `region` and its `sdkUrl` into
3
+ // foldspace.dev.json, attach recognizes the SDK on every region's script host,
4
+ // and the startup step in CLAUDE.md names each region's server and playbook.
5
+ //
6
+ // `sdkUrl`: each region has its own SDK build. Same code, but its config
7
+ // points at that region's agent, recording and tracking hosts - the US SDK on
8
+ // an EU page talks to a US backend that does not have the EU agents.
9
+ //
10
+ // `server`: the Product MCP's name in the editor, as the region's playbook
11
+ // installs it. Distinct per region, so both can sit side by side.
12
+ export const REGIONS = Object.freeze({
13
+ us: Object.freeze({
14
+ label: "US",
15
+ sdkUrl: "https://script.eucerahive.io/web/sdk/foldspace.js",
16
+ server: "foldspace",
17
+ playbook: "https://foldspace.ai/agents.md",
18
+ }),
19
+ eu: Object.freeze({
20
+ label: "EU",
21
+ sdkUrl: "https://script.eu.eucerahive.io/web/sdk/foldspace.js",
22
+ server: "foldspace-eu",
23
+ playbook: "https://foldspace.ai/eu/agents.md",
24
+ }),
25
+ });
26
+
27
+ // Projects created before regions existed have no `region`: they are US.
28
+ export const DEFAULT_REGION = "us";
29
+
30
+ export const REGION_NAMES = Object.freeze(Object.keys(REGIONS));
31
+
32
+ export function isRegion(value) {
33
+ return typeof value === "string" && Object.hasOwn(REGIONS, value);
34
+ }
35
+
36
+ export function parseRegion(value) {
37
+ const region = typeof value === "string" ? value.trim().toLowerCase() : value;
38
+ if (!isRegion(region)) {
39
+ throw new Error(
40
+ `Unknown region: ${value}. Use one of: ${REGION_NAMES.join(", ")}.`,
41
+ );
42
+ }
43
+ return region;
44
+ }
45
+
46
+ // The region a foldspace.dev.json belongs to.
47
+ export function regionOf(config) {
48
+ return REGIONS[parseRegion(config?.region ?? DEFAULT_REGION)];
49
+ }
50
+
51
+ // Hostnames the Foldspace SDK is served from, in any region.
52
+ export const SDK_SCRIPT_HOSTS = Object.freeze(
53
+ REGION_NAMES.map((name) => new URL(REGIONS[name].sdkUrl).hostname),
54
+ );
@@ -10,6 +10,7 @@
10
10
  "Bash(npx foldspace observe:*)",
11
11
  "Bash(npx foldspace ask:*)",
12
12
  "Bash(npx foldspace badge:*)",
13
+ "Bash(npx foldspace published:*)",
13
14
  "Bash(npm install:*)",
14
15
  "Bash(npm run build:*)",
15
16
  "Bash(npm run typecheck:*)",
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "$comment": "Generated by foldspace init. Use the bare product ID, not the EU-… SDK key.",
3
3
  "defaultTarget": "app",
4
- "sdkUrl": "https://script.eucerahive.io/web/sdk/foldspace.js",
4
+ "region": {{REGION_JSON}},
5
+ "sdkUrl": {{SDK_URL_JSON}},
5
6
  "targets": {
6
7
  "app": {
7
8
  "name": "App",