@foldspace_npm/harness 0.1.26 → 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 +103 -18
- package/README.md +30 -0
- package/bin/ask.mjs +10 -2
- package/bin/attach.mjs +12 -6
- package/bin/published.mjs +141 -0
- package/package.json +1 -1
- package/src/ask-core.mjs +13 -1
- package/src/cdp-client.mjs +1 -0
- package/src/cli-registry.mjs +41 -5
- package/src/keep-focus.mjs +17 -13
- package/src/published-core.mjs +392 -0
- package/templates/agent-starter/claude-settings.json +1 -0
package/CLAUDE.md
CHANGED
|
@@ -38,6 +38,39 @@ something they can **see on their own page** — not at "it builds".
|
|
|
38
38
|
seeded at sign-up are often a specification someone already wrote.
|
|
39
39
|
4. **Read `docs/app-profile.md`.** It is what is already known about this app,
|
|
40
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.
|
|
41
74
|
|
|
42
75
|
Before an unfamiliar harness operation, `npx foldspace help --json` is the
|
|
43
76
|
current CLI contract — risk, prerequisites, effects, next step.
|
|
@@ -50,11 +83,12 @@ current CLI contract — risk, prerequisites, effects, next step.
|
|
|
50
83
|
| **L1 · read settings** | the user's own account, plan or settings in a widget, showing their real values |
|
|
51
84
|
| **L2 · look up entities** | a search from the user's own words, showing rows |
|
|
52
85
|
|
|
53
|
-
1. **Get the agent live first (L0) - with no actions.** Straight after
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
58
92
|
`npx foldspace attach --daemon` - and if it answers that the page has no
|
|
59
93
|
Foldspace code, `npx foldspace attach --bootstrap --daemon` (an app that
|
|
60
94
|
never had Foldspace). An empty registry is valid. L0 is
|
|
@@ -311,7 +345,8 @@ SDK without the configured agent.
|
|
|
311
345
|
read `npx foldspace help attach --json` and map `missingActionNames`,
|
|
312
346
|
`unexpectedActionNames`, `diagnosticError`. A running attach keeps the actions
|
|
313
347
|
it loaded: after a rebuild, `attach --stop` and attach again to load the new
|
|
314
|
-
ones. That
|
|
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.
|
|
315
350
|
|
|
316
351
|
Prove a named action through the visible agent: after the human talks to the
|
|
317
352
|
copilot, read the daemon log for `[actions]` SDK callback and local
|
|
@@ -324,7 +359,44 @@ A first session is done when the customer has seen one experience answer from
|
|
|
324
359
|
their own data, in the test window, and been invited to try it. Not when the
|
|
325
360
|
gates pass, and not when there is nothing left to improve.
|
|
326
361
|
|
|
327
|
-
|
|
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:
|
|
328
400
|
|
|
329
401
|
> What you've seen runs in a test window on your machine. Putting it in front
|
|
330
402
|
> of your users is a separate step, and the Foldspace team does it with you.
|
|
@@ -332,8 +404,9 @@ If they ask *"how do I put this in front of my users?"*, the honest answer is:
|
|
|
332
404
|
> <support@foldspace.ai>. Everything built today carries over: the actions are
|
|
333
405
|
> already in your Foldspace account, and the code is in this folder.
|
|
334
406
|
|
|
335
|
-
Never run `foldspace deploy
|
|
336
|
-
|
|
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.
|
|
337
410
|
|
|
338
411
|
## Verification gates
|
|
339
412
|
|
|
@@ -395,7 +468,9 @@ Do not report success without all six:
|
|
|
395
468
|
--test-mode` keeps your conversations out of their data. It is not a safety
|
|
396
469
|
net: publishing still changes the live agent. Working on an agent in
|
|
397
470
|
production - test mode, pinned action versions, what may be published - is
|
|
398
|
-
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**).
|
|
399
474
|
6. **The human is not necessarily a developer.** Never hand over a terminal
|
|
400
475
|
line, a stack trace, or a choice between flags. Name experiences by outcome
|
|
401
476
|
— *"show my plan and credits"*, not `get_account_status`. Their two jobs:
|
|
@@ -571,13 +646,20 @@ building is not the session at run time: **expect `signed_out`** and let
|
|
|
571
646
|
`renderFailure` show it. `not_found` and zero rows are both `renderEmpty` with
|
|
572
647
|
the query named — never an error, never a blank card.
|
|
573
648
|
|
|
574
|
-
If the observed auth is not Bearer + `localStorage`/`cookie`,
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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.
|
|
581
663
|
|
|
582
664
|
## Safety
|
|
583
665
|
|
|
@@ -597,5 +679,8 @@ export { apiFetch } from "./api";
|
|
|
597
679
|
- Do not commit secrets, cookies, HAR files, browser storage, or
|
|
598
680
|
`.foldspace-dev/`. Nothing transient goes in `docs/`.
|
|
599
681
|
- Local handler changes are not cloud publication.
|
|
600
|
-
- Creating or publishing Foldspace resources
|
|
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.
|
|
601
686
|
- Action keys and parameter names must match the published schema.
|
package/README.md
CHANGED
|
@@ -269,6 +269,36 @@ parameter values, results, and error messages are not. On detach, repeated page
|
|
|
269
269
|
failures and bounded action observations are summarized. The observer and
|
|
270
270
|
diagnostics are injected by the harness, so `dist/index.js` is not rewritten.
|
|
271
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
|
+
|
|
272
302
|
### Add the harness to an existing project
|
|
273
303
|
|
|
274
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
|
|
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(
|
|
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
|
|
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(
|
|
337
|
+
.setTestMode(WANT);
|
|
336
338
|
if (!logged.has(id)) {
|
|
337
339
|
logged.add(id);
|
|
338
|
-
console.log("[foldspace-dev] test mode
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
/**
|
package/src/cdp-client.mjs
CHANGED
|
@@ -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),
|
package/src/cli-registry.mjs
CHANGED
|
@@ -344,19 +344,22 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
344
344
|
Object.freeze({ name: "question", required: true, description: "What an end user would type" }),
|
|
345
345
|
],
|
|
346
346
|
options: [
|
|
347
|
-
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" }),
|
|
348
348
|
value("--every", "seconds", "Pause between retries", { default: "20" }),
|
|
349
349
|
value("--timeout", "seconds", "Wait this long for a reply", { default: "90" }),
|
|
350
350
|
],
|
|
351
|
-
prerequisites: [
|
|
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
|
+
],
|
|
352
354
|
effects: [
|
|
353
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",
|
|
354
357
|
"Creates a real conversation: it appears in the customer's Foldspace dashboard (test traffic only under attach --test-mode)",
|
|
355
358
|
"Saves panel and full-page screenshots under .foldspace-dev/ask/",
|
|
356
359
|
],
|
|
357
360
|
next: [
|
|
358
|
-
"Read reply, cards and handler.execute / handler.render
|
|
359
|
-
"Only then: foldspace badge ready
|
|
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",
|
|
360
363
|
],
|
|
361
364
|
}),
|
|
362
365
|
Object.freeze({
|
|
@@ -387,6 +390,39 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
387
390
|
],
|
|
388
391
|
next: ["Say in chat whose turn it is and what to try"],
|
|
389
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
|
+
}),
|
|
390
426
|
Object.freeze({
|
|
391
427
|
name: "deploy",
|
|
392
428
|
// Foldspace-internal: it uploads to Foldspace's own storage, which no
|
|
@@ -508,7 +544,7 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
|
|
|
508
544
|
"If update.outdated is true, ask the user before foldspace upgrade --yes.",
|
|
509
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.",
|
|
510
546
|
"observe, ask and run are read-only second clients on the test window and work while attach runs.",
|
|
511
|
-
"
|
|
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).",
|
|
512
548
|
],
|
|
513
549
|
};
|
|
514
550
|
}
|
package/src/keep-focus.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
await osascript(script);
|
|
50
|
+
await run("open", ["-b", bundleId]);
|
|
47
51
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
48
|
-
await
|
|
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
|
+
}
|