@foldspace_npm/harness 0.1.21 → 0.1.23
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 +77 -37
- package/README.md +49 -0
- package/bin/ask.mjs +345 -0
- package/bin/attach.mjs +13 -1
- package/bin/cli.mjs +21 -2
- package/package.json +1 -1
- package/recipes/who-is-the-user/README.md +4 -3
- package/src/ask-core.mjs +195 -0
- package/src/attach-daemon.mjs +27 -0
- package/src/cli-registry.mjs +35 -2
- package/src/init.mjs +8 -1
- package/src/upgrade.mjs +71 -4
- package/templates/agent-starter/claude-settings.json +21 -0
- package/templates/agent-starter/docs/app-profile.md +4 -2
package/CLAUDE.md
CHANGED
|
@@ -13,7 +13,11 @@ something they can **see on their own page** — not at "it builds".
|
|
|
13
13
|
`npx --yes @foldspace_npm/harness@latest upgrade --check`). If
|
|
14
14
|
`update.outdated` is true, say the pinned and latest versions and **ask
|
|
15
15
|
before upgrading**; then `foldspace upgrade --yes` and `npm run build`. Never
|
|
16
|
-
upgrade as a side effect of build, attach or implementing an action.
|
|
16
|
+
upgrade as a side effect of build, attach or implementing an action.
|
|
17
|
+
`build` and `attach` check too: behind prints one line and carries on; a
|
|
18
|
+
version Foldspace **withdrew** refuses to run and says why. Tell the user
|
|
19
|
+
the reason and ask to upgrade. Only if they decide not to, rerun with
|
|
20
|
+
`FOLDSPACE_SKIP_UPDATE_CHECK=1` - never set it on your own. If the
|
|
17
21
|
project `CLAUDE.md` does not contain
|
|
18
22
|
`@node_modules/@foldspace_npm/harness/CLAUDE.md`, ask before
|
|
19
23
|
`foldspace upgrade --refresh-instructions`.
|
|
@@ -39,7 +43,7 @@ current CLI contract — risk, prerequisites, effects, next step.
|
|
|
39
43
|
|
|
40
44
|
| Level | Reached when |
|
|
41
45
|
|---|---|
|
|
42
|
-
| **L0 · agent live** | injected, visible on the page
|
|
46
|
+
| **L0 · agent live** | injected, visible on the page |
|
|
43
47
|
| **L1 · read settings** | the user's own account, plan or settings in a widget, showing their real values |
|
|
44
48
|
| **L2 · look up entities** | a search from the user's own words, showing rows |
|
|
45
49
|
|
|
@@ -48,7 +52,9 @@ current CLI contract — risk, prerequisites, effects, next step.
|
|
|
48
52
|
window opened at <Product>'s login. Please log in there - I'll notice when
|
|
49
53
|
you're in."* Then `npx foldspace observe wait-login`: it returns the moment
|
|
50
54
|
they are in, so nobody has to type "I've logged in". Then
|
|
51
|
-
`npx foldspace attach --daemon
|
|
55
|
+
`npx foldspace attach --daemon` - and if it answers that the page has no
|
|
56
|
+
Foldspace code, `npx foldspace attach --bootstrap --daemon` (an app that
|
|
57
|
+
never had Foldspace). An empty registry is valid. L0 is
|
|
52
58
|
`registration_ok` with the agent visible on their page: take
|
|
53
59
|
`npx foldspace observe screenshot`, send them the image, and tell them to
|
|
54
60
|
look at it and say hello to it. **From here on the agent stays on their
|
|
@@ -82,7 +88,27 @@ back) and in chat you say what to try. No bar for their turn: they may type
|
|
|
82
88
|
whatever, whenever; the pill only ever asks them to wait. `foldspace badge label` puts the small
|
|
83
89
|
corner tag back; `off` removes it, for a screenshot they will share.
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
**After they have tried it, offer the second wave - one choice, recommended
|
|
92
|
+
first, not four questions.** Once you have handed over (`badge ready`) and the
|
|
93
|
+
human has asked the agent the question:
|
|
94
|
+
|
|
95
|
+
1. **Starters.** The sign-up starters point at actions the agent does not have
|
|
96
|
+
(clicking one answers "that action isn't available"): replace them with the
|
|
97
|
+
question this experience answers. `set_conversation_starters` replaces the
|
|
98
|
+
whole set - send the full list. Their account, so their yes.
|
|
99
|
+
2. **The bottom bar** - `recipes/bottom-bar`: the agent's resting place at the
|
|
100
|
+
foot of the page, with starters that change per screen. Ships in this
|
|
101
|
+
project's start-up code; nothing in their source changes.
|
|
102
|
+
3. **Tell Foldspace who the user is** - `recipes/who-is-the-user`: from then on
|
|
103
|
+
their conversations and analytics stop being anonymous, and the agent has
|
|
104
|
+
real-time context about who it is talking to. One line in chat, no question.
|
|
105
|
+
4. **A second experience** - and, once a card shows records, navigation to a
|
|
106
|
+
record's page.
|
|
107
|
+
|
|
108
|
+
Not before the first experience: nothing about it needs any of these, and it
|
|
109
|
+
is silent work before the human has seen anything.
|
|
110
|
+
|
|
111
|
+
Uploads, extraction and anything that writes are a later session.
|
|
86
112
|
|
|
87
113
|
### Ask as a choice, and ask almost nothing
|
|
88
114
|
|
|
@@ -107,9 +133,13 @@ and asking are not the only options — **observing is, and it comes first**:
|
|
|
107
133
|
2. **You look - once they have chosen an experience - with `foldspace
|
|
108
134
|
observe`.** It reads the test window they signed in to, it is read-only by
|
|
109
135
|
construction, and it works **while the agent stays attached**: do not
|
|
110
|
-
`attach --stop` to look. Write no browser-driving script of your own
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
`attach --stop` to look. Write no browser-driving script of your own - not
|
|
137
|
+
to look at the app, and not to talk to the agent: a socket to Chrome's debug
|
|
138
|
+
port that evaluates `agent.message(...)` in a signed-in session is the shape
|
|
139
|
+
of an attack and gets refused. `foldspace observe` looks; `foldspace ask`
|
|
140
|
+
talks. Do not reach for a separate DevTools server either - one launched on
|
|
141
|
+
its own opens a different, signed-out Chrome and can never see the window
|
|
142
|
+
they logged in to.
|
|
113
143
|
|
|
114
144
|
```bash
|
|
115
145
|
npx foldspace observe menu # the product's own screens, by label and path
|
|
@@ -286,8 +316,8 @@ Do not report success without all six:
|
|
|
286
316
|
4. The browser reports `inspect_registration:registration_ok`, and for a named
|
|
287
317
|
action the captured registry includes it.
|
|
288
318
|
5. The action behaves against the real workflow: `[actions]
|
|
289
|
-
local-handler:execute` in the attach log after
|
|
290
|
-
visible agent. A widget also needs `local-handler:render` from a real chat
|
|
319
|
+
local-handler:execute` in the attach log after `foldspace ask` (or the
|
|
320
|
+
human) exercises the visible agent. A widget also needs `local-handler:render` from a real chat
|
|
291
321
|
turn — not a hand-built `render()` call. `runAction` refuses render actions
|
|
292
322
|
and driving `render()` yourself never runs `execute()`.
|
|
293
323
|
6. Browser evidence came from the live target, not hand-authored examples.
|
|
@@ -316,12 +346,21 @@ Do not report success without all six:
|
|
|
316
346
|
MCP (`search_docs`, `fetch_page`) before asserting how the platform behaves;
|
|
317
347
|
cite the page, or say plainly that the docs do not cover it and write down
|
|
318
348
|
what you learned.
|
|
319
|
-
4. **Test through the agent, then let them.** In a first session,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
and
|
|
324
|
-
|
|
349
|
+
4. **Test through the agent, then let them.** In a first session, prove an
|
|
350
|
+
experience yourself before the customer sees it: once the action is
|
|
351
|
+
published and registered, `npx foldspace ask "<the question>" --retry 2`
|
|
352
|
+
types it into the agent's panel in the test window the way a person would,
|
|
353
|
+
and reports the reply, the card's text, whether `execute` and `render` ran
|
|
354
|
+
(from the attach log) and a screenshot. A reply with no action fired is
|
|
355
|
+
publish lag or a wrong key - `--retry` covers the lag. Look at the
|
|
356
|
+
screenshot. Only then the ready pill and the invitation to try it; never
|
|
357
|
+
hand the first test to the customer. **Ask only what reads.** A question
|
|
358
|
+
that makes the agent create, send, change or delete something is you
|
|
359
|
+
writing in their product: ask it only in an environment the human has
|
|
360
|
+
named as a test one, or let them type it. Those conversations appear in their
|
|
361
|
+
Foldspace dashboard, and that is wanted: seeing their own conversations
|
|
362
|
+
recorded is part of what they came to see. Never pass `--test-mode` in a
|
|
363
|
+
first session.
|
|
325
364
|
5. **Test mode is for an agent that already has real users.** Then `attach
|
|
326
365
|
--test-mode` keeps your conversations out of their data. It is not a safety
|
|
327
366
|
net: publishing still changes the live agent. Working on an agent in
|
|
@@ -352,29 +391,24 @@ Lint catches some of these in handler code; Agent Studio copy is on you.
|
|
|
352
391
|
Three layers: built-in defaults, then Agent Studio, then code - **and code
|
|
353
392
|
wins**. <https://docs.foldspace.ai/customize/configuration/>
|
|
354
393
|
|
|
355
|
-
- **
|
|
356
|
-
Foldspace tools
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
- **Page-specific starters and the bottom bar are
|
|
363
|
-
|
|
394
|
+
- **Conversation starters** are agent-wide and yours to set through the
|
|
395
|
+
Foldspace tools; they carry over to the customer's real site.
|
|
396
|
+
`set_conversation_starters` **replaces the whole set and clears whatever you
|
|
397
|
+
leave out**: call `list_conversation_starters` first and send back the full
|
|
398
|
+
list. It changes their Foldspace account, so it needs their yes. Offer it in
|
|
399
|
+
the second wave, replacing the sign-up starters that point at actions the
|
|
400
|
+
agent does not have.
|
|
401
|
+
- **Page-specific starters and the bottom bar are code** -
|
|
402
|
+
`setConversationStarters` (`null` restores Agent Studio's) and
|
|
364
403
|
`setConfiguration({ bottomBarSettings })` then `openBottomBar()`. Send one
|
|
365
|
-
whole settings group per call, as the docs do.
|
|
366
|
-
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
a customer who cannot make the change, decided with Foldspace - not something
|
|
371
|
-
to reach for, and never in a first session.
|
|
404
|
+
whole settings group per call, as the docs do. That code lives in this
|
|
405
|
+
project's start-up (`recipes/bottom-bar`, kind `page-setup`) and ships with
|
|
406
|
+
the actions bundle, so nothing in the customer's source changes; their own
|
|
407
|
+
mount code is the other place it can live, their engineers' call. Offer it in
|
|
408
|
+
the second wave.
|
|
372
409
|
- **An action's `execute` never calls `setConfiguration`**: a partial call from
|
|
373
410
|
one once collapsed the agent's panel to nothing.
|
|
374
411
|
|
|
375
|
-
If they ask for page-specific starters or the bottom bar, say what it takes in
|
|
376
|
-
one sentence and note it in `docs/app-profile.md` as a next step.
|
|
377
|
-
|
|
378
412
|
## Agentic UI components: what bites
|
|
379
413
|
|
|
380
414
|
An Agentic UI component is the card an action draws in the chat (`render`).
|
|
@@ -450,8 +484,9 @@ out: the API helper, the handler or card, and a recorded response per state. Cop
|
|
|
450
484
|
placeholder path and fields with what you observed. They match the harness
|
|
451
485
|
version you have installed, and every one is built and run on each release.
|
|
452
486
|
|
|
453
|
-
|
|
454
|
-
user has to choose. The user's records on one
|
|
487
|
+
After the first experience → `who-is-the-user`. L2 → `find-by-name`, and
|
|
488
|
+
`pick-from-a-list` when the user has to choose. The user's records on one
|
|
489
|
+
card → `records-list`. Show one account on a card → `account-overview`. Upload a
|
|
455
490
|
CSV of contacts → `upload-contacts`. Save a note on a meeting →
|
|
456
491
|
`update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
|
|
457
492
|
attention, with reasons → `opportunities-at-risk`.
|
|
@@ -498,7 +533,12 @@ export { apiFetch } from "./api";
|
|
|
498
533
|
- **A refusal is an answer.** When a command or tool call is declined - by the
|
|
499
534
|
human or by the session's own safety checks - stop. Say in one plain sentence
|
|
500
535
|
what you wanted it for, or take a narrower read-only route. Never retry it,
|
|
501
|
-
and never try a different command hoping it gets through
|
|
536
|
+
and never try a different command hoping it gets through - not re-quoted, not
|
|
537
|
+
in pieces, not through a helper script or a subagent: a denial covers the
|
|
538
|
+
outcome, not the string. If a `foldspace` command itself was denied, name it,
|
|
539
|
+
give the one-line allow rule (`Bash(npx foldspace:*)` in
|
|
540
|
+
`.claude/settings.local.json`), and hand that step to the human - for a chat
|
|
541
|
+
turn, that is them typing the question into the panel.
|
|
502
542
|
- **Never create an account or a key** - not in Foldspace, not in the
|
|
503
543
|
customer's product, not anywhere. If one is missing, say which and where it
|
|
504
544
|
goes. **Never widen your own access**: if something is refused for a site or
|
package/README.md
CHANGED
|
@@ -151,6 +151,55 @@ foldspace upgrade --yes # only after the user agrees
|
|
|
151
151
|
`--yes` rewrites the exact pin and runs `npm install --ignore-scripts`. Rebuild
|
|
152
152
|
afterward; that is still not a deploy.
|
|
153
153
|
|
|
154
|
+
`foldspace build` and `foldspace attach` run the same check before they start
|
|
155
|
+
(`attach --status` and `--stop` do not). A project that is behind gets one line
|
|
156
|
+
on stderr and carries on. A project on a **withdrawn** version (below) refuses
|
|
157
|
+
to run and prints the reason. To build on it anyway, once the user has declined
|
|
158
|
+
the upgrade:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
FOLDSPACE_SKIP_UPDATE_CHECK=1 npm run build
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
That turns the check off entirely; it does not fix the withdrawn version.
|
|
165
|
+
|
|
166
|
+
### Withdrawing a release (maintainers)
|
|
167
|
+
|
|
168
|
+
When a published version is broken badly enough that nobody should build on
|
|
169
|
+
it, withdraw it with `npm deprecate`. The registry is the list of withdrawn
|
|
170
|
+
versions, so projects already installed on it see the withdrawal without
|
|
171
|
+
installing anything new. Only owners of `@foldspace_npm/harness` on npm can
|
|
172
|
+
run it.
|
|
173
|
+
|
|
174
|
+
1. **Publish the fix first.** If the withdrawn version is also `latest`, the
|
|
175
|
+
message tells users to upgrade to a version that is itself withdrawn.
|
|
176
|
+
2. **Withdraw the broken version**, with a message that says what is wrong and
|
|
177
|
+
which version to move to. That message is exactly what users, and the coding
|
|
178
|
+
agent working for them, see when `build` or `attach` refuses to run:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npm deprecate @foldspace_npm/harness@0.1.14 "attach loads the wrong bundle; upgrade to 0.1.15"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A range works too: `npm deprecate "@foldspace_npm/harness@<0.1.15" "…"`.
|
|
185
|
+
3. **Check it took:**
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npm view @foldspace_npm/harness@0.1.14 deprecated
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
To undo a withdrawal, deprecate with an empty message:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm deprecate @foldspace_npm/harness@0.1.14 ""
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**It is not instant.** Each project caches the registry's answer for 12 hours
|
|
198
|
+
in `.foldspace-dev/harness-update.json`. A project that checked recently keeps
|
|
199
|
+
its "fine" answer until that expires, so a withdrawal reaches every project
|
|
200
|
+
within 12 hours. It only reaches projects on a harness version that has this
|
|
201
|
+
check.
|
|
202
|
+
|
|
154
203
|
### Lint handlers before they ship
|
|
155
204
|
|
|
156
205
|
`foldspace build` runs `foldspace lint` first. Errors skip bundling; warnings
|
package/bin/ask.mjs
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foldspace ask — put a question to the visible assistant through its panel,
|
|
3
|
+
// exactly as a person would, and report what came back: the reply, any card,
|
|
4
|
+
// what the handlers did, and a picture.
|
|
5
|
+
//
|
|
6
|
+
// It exists so the coding agent stops asking a non-technical customer to
|
|
7
|
+
// "reload, try these two sentences and tell me what you saw".
|
|
8
|
+
//
|
|
9
|
+
// node bin/ask.mjs "<question>" [--retry <n>] [--every <s>] [--timeout <s>] [--out <dir>]
|
|
10
|
+
//
|
|
11
|
+
// Nothing here reaches into the SDK, patches fetch or injects into the panel:
|
|
12
|
+
// it opens the panel, types into the composer, presses Enter and reads the
|
|
13
|
+
// screen. --retry re-asks when no action fired, which covers the platform
|
|
14
|
+
// taking up to a minute to pick up a newly published action; lagMs is that cost.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createSettleTracker,
|
|
21
|
+
numberFlag,
|
|
22
|
+
replyAfter,
|
|
23
|
+
shouldRetry,
|
|
24
|
+
squash,
|
|
25
|
+
summarizeHandlerLog,
|
|
26
|
+
} from "../src/ask-core.mjs";
|
|
27
|
+
import { ATTACH_DAEMON_LOG_FILE } from "../src/attach-daemon.mjs";
|
|
28
|
+
import { CdpPage, parseArgs, printJson, sleep } from "../src/cdp-client.mjs";
|
|
29
|
+
import { recordEvent } from "../src/session-events.mjs";
|
|
30
|
+
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
31
|
+
|
|
32
|
+
const PANEL_ID = "eucera-agent-iframe";
|
|
33
|
+
const COMPOSER_WAIT_S = 30;
|
|
34
|
+
const POLL_MS = 1500;
|
|
35
|
+
|
|
36
|
+
const { flags, rest } = parseArgs(process.argv.slice(2), ["retry", "every", "timeout", "out"]);
|
|
37
|
+
const question = squash(rest.join(" "));
|
|
38
|
+
const retries = Math.floor(numberFlag(flags.retry, 0));
|
|
39
|
+
const everyS = numberFlag(flags.every, 20);
|
|
40
|
+
const timeoutS = numberFlag(flags.timeout, 90);
|
|
41
|
+
const projectDir = resolveProjectDir();
|
|
42
|
+
const outDir = flags.out
|
|
43
|
+
? path.resolve(projectDir, String(flags.out))
|
|
44
|
+
: path.join(projectDir, ".foldspace-dev", "ask");
|
|
45
|
+
const logFile = path.join(projectDir, ".foldspace-dev", ATTACH_DAEMON_LOG_FILE);
|
|
46
|
+
|
|
47
|
+
let page = null;
|
|
48
|
+
|
|
49
|
+
function finish(result, code) {
|
|
50
|
+
recordEvent(projectDir, "ask", { ok: result.ok, hasCard: Boolean(result.hasCard) });
|
|
51
|
+
printJson(result);
|
|
52
|
+
page?.close();
|
|
53
|
+
process.exit(code);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function logSize() {
|
|
57
|
+
try {
|
|
58
|
+
return fs.statSync(logFile).size;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Only what attach appended after the ask began; a missing log reads as "".
|
|
65
|
+
function logSince(offset) {
|
|
66
|
+
if (offset === null) return "";
|
|
67
|
+
try {
|
|
68
|
+
const buffer = fs.readFileSync(logFile);
|
|
69
|
+
return buffer.subarray(Math.min(offset, buffer.length)).toString("utf8");
|
|
70
|
+
} catch {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Shared by every page snippet: the panel document, or null while it loads.
|
|
76
|
+
const PANEL_DOC = `
|
|
77
|
+
const frame = document.getElementById(${JSON.stringify(PANEL_ID)});
|
|
78
|
+
let doc = null;
|
|
79
|
+
try { doc = frame && frame.contentDocument; } catch {}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
async function panelState() {
|
|
83
|
+
return page.evaluate(`${PANEL_DOC}
|
|
84
|
+
if (!doc) return { frame: !!frame, composer: false };
|
|
85
|
+
const box = doc.querySelector("textarea");
|
|
86
|
+
const rect = frame.getBoundingClientRect();
|
|
87
|
+
return { frame: true, composer: !!box, visible: rect.width > 50 && rect.height > 50 };`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// open() is the SDK's public "show the panel" call, the same thing the
|
|
91
|
+
// launcher button does. The handle comes from the bootstrap when attach made
|
|
92
|
+
// one, else from the page's own SDK.
|
|
93
|
+
async function openPanel(agentApiName) {
|
|
94
|
+
return page.evaluate(`
|
|
95
|
+
const wanted = ${JSON.stringify(agentApiName || "")};
|
|
96
|
+
const namespace = typeof window.__FOLD_SPACE__ === "string" && window.__FOLD_SPACE__
|
|
97
|
+
? window.__FOLD_SPACE__ : "foldspace";
|
|
98
|
+
const sdk = window[namespace];
|
|
99
|
+
let agent = window.__FOLDSPACE_AGENT__ || null;
|
|
100
|
+
if (!agent && typeof sdk?.agentIds === "function") {
|
|
101
|
+
const ids = sdk.agentIds().filter((id) => typeof id === "string" && id.indexOf("-") > 0);
|
|
102
|
+
const id = ids.find((item) => item.slice(item.indexOf("-") + 1) === wanted) || ids[0];
|
|
103
|
+
if (id) {
|
|
104
|
+
const cut = id.indexOf("-");
|
|
105
|
+
agent = sdk.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (typeof agent?.open !== "function") return { sdk: !!sdk, opened: false };
|
|
109
|
+
try { agent.open(); return { sdk: true, opened: true }; }
|
|
110
|
+
catch (error) { return { sdk: true, opened: false, error: String(error?.message || error) }; }`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readPanel(cardsBefore) {
|
|
114
|
+
return page.evaluate(`${PANEL_DOC}
|
|
115
|
+
if (!doc || !doc.body) return { panel: "", cards: [], cardFrames: 0, newCardFrames: 0 };
|
|
116
|
+
const squash = (text) => (text || "").replace(/\\s+/g, " ").trim();
|
|
117
|
+
const frames = Array.from(doc.querySelectorAll("iframe"));
|
|
118
|
+
const cards = [];
|
|
119
|
+
let painted = 0;
|
|
120
|
+
// Cards from earlier turns are still in the panel; only the new ones count.
|
|
121
|
+
for (const card of frames.slice(${Number(cardsBefore) || 0})) {
|
|
122
|
+
const rect = card.getBoundingClientRect();
|
|
123
|
+
if (rect.width > 20 && rect.height > 20) painted += 1;
|
|
124
|
+
try {
|
|
125
|
+
const body = card.contentDocument && card.contentDocument.body;
|
|
126
|
+
if (body) cards.push(squash(body.innerText || body.textContent));
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
panel: squash(doc.body.innerText || doc.body.textContent),
|
|
131
|
+
cards: cards.filter(Boolean),
|
|
132
|
+
cardFrames: frames.length,
|
|
133
|
+
newCardFrames: painted,
|
|
134
|
+
};`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Real key events through the browser's input pipeline, like a person at the
|
|
138
|
+
// keyboard. Frameworks that ignore synthetic DOM events still hear these.
|
|
139
|
+
async function typeLikeAPerson(text) {
|
|
140
|
+
const focused = await page.evaluate(`${PANEL_DOC}
|
|
141
|
+
const box = doc && doc.querySelector("textarea");
|
|
142
|
+
if (!box) return false;
|
|
143
|
+
window.focus(); frame.contentWindow.focus(); box.focus();
|
|
144
|
+
return doc.activeElement === box;`);
|
|
145
|
+
if (!focused) return false;
|
|
146
|
+
await page.send("Input.insertText", { text });
|
|
147
|
+
const typed = await page.evaluate(`${PANEL_DOC}
|
|
148
|
+
const box = doc && doc.querySelector("textarea");
|
|
149
|
+
return box ? box.value : null;`);
|
|
150
|
+
if (squash(typed) !== text) return false;
|
|
151
|
+
const enter = {
|
|
152
|
+
key: "Enter",
|
|
153
|
+
code: "Enter",
|
|
154
|
+
windowsVirtualKeyCode: 13,
|
|
155
|
+
nativeVirtualKeyCode: 13,
|
|
156
|
+
};
|
|
157
|
+
await page.send("Input.dispatchKeyEvent", { type: "rawKeyDown", ...enter });
|
|
158
|
+
await page.send("Input.dispatchKeyEvent", { type: "char", ...enter, text: "\r" });
|
|
159
|
+
await page.send("Input.dispatchKeyEvent", { type: "keyUp", ...enter });
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A background or unfocused tab can swallow CDP input. Same gesture, delivered
|
|
164
|
+
// as DOM events on the composer; still only typing, still nothing injected.
|
|
165
|
+
async function typeWithDomEvents(text) {
|
|
166
|
+
return page.evaluate(`${PANEL_DOC}
|
|
167
|
+
const box = doc && doc.querySelector("textarea");
|
|
168
|
+
if (!box) return false;
|
|
169
|
+
const view = frame.contentWindow;
|
|
170
|
+
const setValue = Object.getOwnPropertyDescriptor(view.HTMLTextAreaElement.prototype, "value").set;
|
|
171
|
+
box.focus();
|
|
172
|
+
setValue.call(box, ${JSON.stringify(text)});
|
|
173
|
+
box.dispatchEvent(new view.Event("input", { bubbles: true }));
|
|
174
|
+
for (const type of ["keydown", "keypress", "keyup"]) {
|
|
175
|
+
box.dispatchEvent(new view.KeyboardEvent(type, {
|
|
176
|
+
key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
return true;`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function composerValue() {
|
|
183
|
+
return page
|
|
184
|
+
.evaluate(`${PANEL_DOC}
|
|
185
|
+
const box = doc && doc.querySelector("textarea");
|
|
186
|
+
return box ? box.value : "";`)
|
|
187
|
+
.catch(() => "");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function askOnce() {
|
|
191
|
+
const before = await readPanel(0);
|
|
192
|
+
const cardsBefore = before.cardFrames;
|
|
193
|
+
const logOffset = logSize();
|
|
194
|
+
|
|
195
|
+
let typedBy = "keyboard";
|
|
196
|
+
let sent = await typeLikeAPerson(question).catch(() => false);
|
|
197
|
+
await sleep(700);
|
|
198
|
+
// Still sitting in the composer means Enter never landed.
|
|
199
|
+
if (!sent || squash(await composerValue()) === question) {
|
|
200
|
+
typedBy = "dom-events";
|
|
201
|
+
sent = await typeWithDomEvents(question).catch(() => false);
|
|
202
|
+
}
|
|
203
|
+
if (!sent) return { sent: false };
|
|
204
|
+
|
|
205
|
+
const tracker = createSettleTracker();
|
|
206
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
207
|
+
let seen = { panel: "", cards: [], newCardFrames: 0 };
|
|
208
|
+
let reply = "";
|
|
209
|
+
let settled = false;
|
|
210
|
+
while (Date.now() < deadline && !settled) {
|
|
211
|
+
await sleep(POLL_MS);
|
|
212
|
+
seen = await readPanel(cardsBefore).catch(() => seen);
|
|
213
|
+
reply = replyAfter(seen.panel, question);
|
|
214
|
+
settled = tracker.observe({ reply, cards: seen.cards }).settled;
|
|
215
|
+
}
|
|
216
|
+
// The render line trails the card by a beat; let attach flush it.
|
|
217
|
+
await sleep(400);
|
|
218
|
+
return {
|
|
219
|
+
sent: true,
|
|
220
|
+
typedBy,
|
|
221
|
+
settled,
|
|
222
|
+
reply: reply.slice(0, 2000),
|
|
223
|
+
cards: seen.cards.map((card) => card.slice(0, 2000)),
|
|
224
|
+
hasCard: seen.cards.length > 0 || seen.newCardFrames > 0,
|
|
225
|
+
handler: summarizeHandlerLog(logSince(logOffset)),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function takeScreenshots() {
|
|
230
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
231
|
+
const fullPath = path.join(outDir, `${stamp}-full.png`);
|
|
232
|
+
const panelPath = path.join(outDir, `${stamp}-panel.png`);
|
|
233
|
+
let screenshotFull = null;
|
|
234
|
+
let screenshot = null;
|
|
235
|
+
try {
|
|
236
|
+
screenshotFull = await page.screenshot(fullPath);
|
|
237
|
+
} catch {
|
|
238
|
+
// A picture is evidence, not the answer; never fail the ask over it.
|
|
239
|
+
}
|
|
240
|
+
// Clip is in page coordinates, so a scrolled page needs its offset added.
|
|
241
|
+
const clip = await page
|
|
242
|
+
.evaluate(`${PANEL_DOC}
|
|
243
|
+
if (!frame) return null;
|
|
244
|
+
const rect = frame.getBoundingClientRect();
|
|
245
|
+
const x = Math.max(0, rect.left), y = Math.max(0, rect.top);
|
|
246
|
+
const width = Math.min(window.innerWidth, rect.right) - x;
|
|
247
|
+
const height = Math.min(window.innerHeight, rect.bottom) - y;
|
|
248
|
+
return { x: x + window.scrollX, y: y + window.scrollY, width, height };`)
|
|
249
|
+
.catch(() => null);
|
|
250
|
+
if (clip && clip.width > 50 && clip.height > 50) {
|
|
251
|
+
try {
|
|
252
|
+
screenshot = await page.screenshot(panelPath, clip);
|
|
253
|
+
} catch {
|
|
254
|
+
// fall through to the full-window picture
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { screenshot: screenshot || screenshotFull, screenshotFull };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function main() {
|
|
261
|
+
if (!question) {
|
|
262
|
+
finish(
|
|
263
|
+
{
|
|
264
|
+
ok: false,
|
|
265
|
+
reason:
|
|
266
|
+
'No question. Usage: foldspace ask "<question>" [--retry <n>] [--every <s>] [--timeout <s>] [--out <dir>]',
|
|
267
|
+
},
|
|
268
|
+
1,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const startedAt = Date.now();
|
|
273
|
+
page = await CdpPage.open({ projectDir });
|
|
274
|
+
const url = page.info.url;
|
|
275
|
+
|
|
276
|
+
// The composer does not exist until the panel has opened, and one open() is
|
|
277
|
+
// not always enough, so it is retried inside the poll.
|
|
278
|
+
let state = await panelState();
|
|
279
|
+
let opened = { sdk: false, opened: false };
|
|
280
|
+
for (let tick = 0; tick < COMPOSER_WAIT_S && !(state.composer && state.visible); tick++) {
|
|
281
|
+
opened = await openPanel(page.info.target?.agentApiName).catch(() => opened);
|
|
282
|
+
await sleep(1000);
|
|
283
|
+
state = await panelState().catch(() => state);
|
|
284
|
+
if (tick === 5 && !state.frame && !opened.sdk) break;
|
|
285
|
+
}
|
|
286
|
+
if (!state.composer) {
|
|
287
|
+
finish(
|
|
288
|
+
{
|
|
289
|
+
ok: false,
|
|
290
|
+
question,
|
|
291
|
+
reason: state.frame
|
|
292
|
+
? "The assistant panel is on the page but never showed a composer."
|
|
293
|
+
: "No assistant panel on this page. Run `foldspace attach --daemon` (add --bootstrap when the page has no Foldspace SDK), then ask again.",
|
|
294
|
+
url,
|
|
295
|
+
},
|
|
296
|
+
1,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
await sleep(600);
|
|
300
|
+
|
|
301
|
+
let attempts = 0;
|
|
302
|
+
let turn;
|
|
303
|
+
do {
|
|
304
|
+
if (attempts > 0) await sleep(everyS * 1000);
|
|
305
|
+
turn = await askOnce();
|
|
306
|
+
attempts += 1;
|
|
307
|
+
} while (turn.sent && shouldRetry({ ...turn, attempts, retries }));
|
|
308
|
+
|
|
309
|
+
if (!turn.sent) {
|
|
310
|
+
finish({ ok: false, question, reason: "Could not type into the composer.", attempts, url }, 1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await sleep(500);
|
|
314
|
+
const shots = await takeScreenshots();
|
|
315
|
+
const ok = turn.reply.length > 0;
|
|
316
|
+
finish(
|
|
317
|
+
{
|
|
318
|
+
ok,
|
|
319
|
+
question,
|
|
320
|
+
reply: turn.reply,
|
|
321
|
+
cards: turn.cards,
|
|
322
|
+
hasCard: turn.hasCard,
|
|
323
|
+
handler: {
|
|
324
|
+
execute: turn.handler.execute,
|
|
325
|
+
render: turn.handler.render,
|
|
326
|
+
lines: turn.handler.lines,
|
|
327
|
+
},
|
|
328
|
+
...(ok ? {} : { reason: `No reply within ${timeoutS}s.` }),
|
|
329
|
+
...(turn.settled ? {} : { settled: false }),
|
|
330
|
+
screenshot: shots.screenshot,
|
|
331
|
+
screenshotFull: shots.screenshotFull,
|
|
332
|
+
typedBy: turn.typedBy,
|
|
333
|
+
attempts,
|
|
334
|
+
// Only a re-ask has lag to report: the time the platform took to catch up.
|
|
335
|
+
lagMs: attempts > 1 ? Date.now() - startedAt : 0,
|
|
336
|
+
ms: Date.now() - startedAt,
|
|
337
|
+
url,
|
|
338
|
+
},
|
|
339
|
+
ok ? 0 : 1,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
main().catch((error) => {
|
|
344
|
+
finish({ ok: false, question, error: String(error?.message || error) }, 1);
|
|
345
|
+
});
|
package/bin/attach.mjs
CHANGED
|
@@ -249,7 +249,7 @@ const badgeSrc = `(() => {
|
|
|
249
249
|
const want = s.state + "|" + (s.text || "");
|
|
250
250
|
if (b.dataset.fsState === want) return;
|
|
251
251
|
b.dataset.fsState = want;
|
|
252
|
-
const base = "position:fixed;top:0;left:0;z-index:2147483647;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;pointer-events:none;box-shadow:0 1px 6px rgba(0,0,0,.35);";
|
|
252
|
+
const base = "position:fixed;top:0;left:0;z-index:2147483647;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;pointer-events:none;transition:opacity .15s;box-shadow:0 1px 6px rgba(0,0,0,.35);";
|
|
253
253
|
if (s.state === "working") {
|
|
254
254
|
// Attention, not decoration: a pulsing marker, the ask in bold, and an
|
|
255
255
|
// animated line underneath so it reads as "in progress" at a glance.
|
|
@@ -272,6 +272,18 @@ const badgeSrc = `(() => {
|
|
|
272
272
|
draw();
|
|
273
273
|
document.addEventListener("DOMContentLoaded", draw);
|
|
274
274
|
setInterval(draw, 1000);
|
|
275
|
+
// The corner tag sits over the app's top-left corner - often a logo or the
|
|
276
|
+
// first menu item. Clicks already pass through it (pointer-events:none); it
|
|
277
|
+
// also fades out while the pointer is near, so what is under it can be seen.
|
|
278
|
+
// The working pill never fades: it is the one thing that must be read.
|
|
279
|
+
document.addEventListener("mousemove", (e) => {
|
|
280
|
+
const b = document.getElementById("foldspace-dev-badge");
|
|
281
|
+
if (!b) return;
|
|
282
|
+
if ((b.dataset.fsState || "").indexOf("working") === 0) { b.style.opacity = "1"; return; }
|
|
283
|
+
const r = b.getBoundingClientRect();
|
|
284
|
+
const near = e.clientX >= r.left - 12 && e.clientX <= r.right + 12 && e.clientY >= r.top - 12 && e.clientY <= r.bottom + 12;
|
|
285
|
+
b.style.opacity = near ? "0" : "1";
|
|
286
|
+
}, { passive: true });
|
|
275
287
|
})();`;
|
|
276
288
|
|
|
277
289
|
// Flag every local session as test traffic so it is excluded from analytics and
|
package/bin/cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { runInit } from "../src/init.mjs";
|
|
8
8
|
import {
|
|
9
9
|
checkForUpdate,
|
|
10
|
+
gateForCommand,
|
|
10
11
|
nagIfOutdated,
|
|
11
12
|
resolveProjectDir,
|
|
12
13
|
runUpgrade,
|
|
@@ -54,6 +55,18 @@ function runScript(scriptName, args) {
|
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// build and attach are where a stale or withdrawn harness does its damage, so
|
|
59
|
+
// they check before running. Returns false when the command must not run.
|
|
60
|
+
function passesVersionGate() {
|
|
61
|
+
const gate = gateForCommand(checkForUpdate(resolveProjectDir())) || {};
|
|
62
|
+
if (gate.message) console.error(`foldspace: ${gate.message}`);
|
|
63
|
+
if (gate.block) {
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
function printHelp(topic, json) {
|
|
58
71
|
const document = helpDocument(registry, topic);
|
|
59
72
|
if (!document) {
|
|
@@ -184,8 +197,14 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
184
197
|
try {
|
|
185
198
|
const definition = commandByName(command);
|
|
186
199
|
const normalized = normalizeCommandArgs(definition, args);
|
|
187
|
-
|
|
188
|
-
|
|
200
|
+
const control =
|
|
201
|
+
command === "attach" ? classifyAttachControl(normalized) : null;
|
|
202
|
+
const gated =
|
|
203
|
+
command === "build" ||
|
|
204
|
+
(command === "attach" && (!control || control === "daemon"));
|
|
205
|
+
if (gated && !passesVersionGate()) {
|
|
206
|
+
// gate already reported why
|
|
207
|
+
} else if (command === "attach") {
|
|
189
208
|
if (control) {
|
|
190
209
|
Promise.resolve()
|
|
191
210
|
.then(() =>
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Who is the user — L0
|
|
2
2
|
|
|
3
|
-
L0 is
|
|
4
|
-
|
|
5
|
-
analytics
|
|
3
|
+
L0 is the agent appearing. This recipe comes right after the human has tried
|
|
4
|
+
the first experience: Foldspace learns who it is talking to, so conversations,
|
|
5
|
+
analytics rows and segments stop being anonymous, and the agent has real-time
|
|
6
|
+
context about the user.
|
|
6
7
|
|
|
7
8
|
**Proven by 4 production builds**, which found the identity in three different
|
|
8
9
|
places — so look before you assume:
|
package/src/ask-core.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Pure helpers behind `foldspace ask`: deciding when the assistant has finished
|
|
2
|
+
// replying, reading what attach logged about the handlers, and deciding whether
|
|
3
|
+
// to ask again. No I/O here, so every judgement call is unit-tested.
|
|
4
|
+
|
|
5
|
+
/** Collapse whitespace the way the panel text is collapsed before comparing. */
|
|
6
|
+
export function squash(text) {
|
|
7
|
+
return String(text ?? "")
|
|
8
|
+
.replace(/\s+/g, " ")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The reply is "whatever follows the question in the panel". A short prefix
|
|
13
|
+
// survives truncation and the panel re-wrapping a long question bubble.
|
|
14
|
+
export function anchorFor(question) {
|
|
15
|
+
return squash(question).slice(0, 24);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Text after the LAST occurrence of the question: earlier turns are ignored. */
|
|
19
|
+
export function replyAfter(panelText, question) {
|
|
20
|
+
const panel = squash(panelText);
|
|
21
|
+
const anchor = anchorFor(question);
|
|
22
|
+
if (!anchor) return "";
|
|
23
|
+
const at = panel.lastIndexOf(anchor);
|
|
24
|
+
if (at < 0) return "";
|
|
25
|
+
const rest = panel.slice(at);
|
|
26
|
+
// Drop the whole echoed question when it is there, else just the anchor.
|
|
27
|
+
const full = squash(question);
|
|
28
|
+
const tail = rest.startsWith(full) ? rest.slice(full.length) : rest.slice(anchor.length);
|
|
29
|
+
return stripPanelChrome(tail);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The panel's footer sits after the last message in document order, so it
|
|
33
|
+
// would otherwise be read as the end of every reply.
|
|
34
|
+
export function stripPanelChrome(text) {
|
|
35
|
+
return squash(String(text ?? "").replace(/[\u200b-\u200d\ufeff]/g, ""))
|
|
36
|
+
.replace(/\s*Powered by Foldspace(?:\.ai)?\s*$/i, "")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PENDING_PATTERN =
|
|
41
|
+
/thinking|working on it|one moment|generating|ready to go|\.\.\.\s*$|…\s*$/i;
|
|
42
|
+
|
|
43
|
+
/** A reply whose tail is a progress phrase is still being written. */
|
|
44
|
+
export function looksPending(reply) {
|
|
45
|
+
return PENDING_PATTERN.test(squash(reply).slice(-60));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Feed one observation per poll. Settled means a real reply that stopped
|
|
50
|
+
* changing: the same text and the same cards on `stableNeeded` consecutive
|
|
51
|
+
* polls after the first sighting, and not a progress phrase. A streaming reply
|
|
52
|
+
* and a card that paints late both reset the count.
|
|
53
|
+
*/
|
|
54
|
+
export function createSettleTracker({ stableNeeded = 2, minChars = 2 } = {}) {
|
|
55
|
+
let last = null;
|
|
56
|
+
let stable = 0;
|
|
57
|
+
return {
|
|
58
|
+
observe({ reply = "", cards = [] } = {}) {
|
|
59
|
+
const snapshot = `${reply}|${cards.join("|")}`;
|
|
60
|
+
const answered = (reply.length >= minChars || cards.some(Boolean)) && !looksPending(reply);
|
|
61
|
+
if (answered && snapshot === last) stable += 1;
|
|
62
|
+
else stable = 0;
|
|
63
|
+
last = snapshot;
|
|
64
|
+
return { settled: stable >= stableNeeded, stable };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ACTION_LINE = /\[actions\]\s+([\w-]+):(execute|render)(?:\s+(\S+))?\s+(started|succeeded|failed)\b/;
|
|
70
|
+
|
|
71
|
+
/** One `[actions] local-handler:execute my_action succeeded 12ms` line. */
|
|
72
|
+
export function parseActionLine(line) {
|
|
73
|
+
const match = ACTION_LINE.exec(String(line ?? ""));
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
return {
|
|
76
|
+
source: match[1],
|
|
77
|
+
phase: match[2],
|
|
78
|
+
actionName: match[3] || null,
|
|
79
|
+
status: match[4],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function phaseOutcome(events, phase) {
|
|
84
|
+
const mine = events.filter((event) => event.phase === phase);
|
|
85
|
+
// A failure anywhere in the turn is the headline, even if a later call passed.
|
|
86
|
+
if (mine.some((event) => event.status === "failed")) return "failed";
|
|
87
|
+
if (mine.some((event) => event.status === "succeeded")) return "succeeded";
|
|
88
|
+
// "started" with no end yet: the handler fired but has not reported.
|
|
89
|
+
return mine.length ? "started" : "absent";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Summarise the attach log text written since the ask began.
|
|
94
|
+
* `logText` of null/"" (no daemon log) reads as all "absent".
|
|
95
|
+
*/
|
|
96
|
+
export function summarizeHandlerLog(logText) {
|
|
97
|
+
// The daemon log indents these and may prefix them; keep from the tag onward.
|
|
98
|
+
const lines = String(logText ?? "")
|
|
99
|
+
.split("\n")
|
|
100
|
+
.filter((line) => parseActionLine(line))
|
|
101
|
+
.map((line) => line.slice(line.indexOf("[actions]")).trim());
|
|
102
|
+
const events = lines.map(parseActionLine);
|
|
103
|
+
const actions = [...new Set(events.map((event) => event.actionName).filter(Boolean))];
|
|
104
|
+
return {
|
|
105
|
+
execute: phaseOutcome(events, "execute"),
|
|
106
|
+
render: phaseOutcome(events, "render"),
|
|
107
|
+
actions,
|
|
108
|
+
lines: lines.slice(-20),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function actionFired(handler, hasCard) {
|
|
113
|
+
return Boolean(hasCard) || handler?.execute !== "absent" || handler?.render !== "absent";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Re-ask only for publish lag: the assistant answered from knowledge because
|
|
118
|
+
* the platform has not picked the new action up yet. A handler that ran and
|
|
119
|
+
* FAILED is a bug to read, not something another ask will fix.
|
|
120
|
+
*/
|
|
121
|
+
export function shouldRetry({ handler, hasCard, attempts, retries }) {
|
|
122
|
+
if (!(retries > 0) || attempts > retries) return false;
|
|
123
|
+
return !actionFired(handler, hasCard);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Positive number from a flag, else the default; never NaN into a sleep. */
|
|
127
|
+
export function numberFlag(value, fallback) {
|
|
128
|
+
const parsed = Number(value);
|
|
129
|
+
return Number.isFinite(parsed) && parsed >= 0 && value !== true && value !== undefined
|
|
130
|
+
? parsed
|
|
131
|
+
: fallback;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- `foldspace run`: describe what a handler returned without printing it ----
|
|
135
|
+
|
|
136
|
+
function typeOf(value) {
|
|
137
|
+
if (value === null) return "null";
|
|
138
|
+
if (Array.isArray(value)) return "array";
|
|
139
|
+
return typeof value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Breadth-first so `items` wins over `meta.pages[0].items`; the first array of
|
|
143
|
+
// records is what a card will draw.
|
|
144
|
+
function findRows(value, maxDepth = 3) {
|
|
145
|
+
const queue = [{ node: value, path: "$", depth: 0 }];
|
|
146
|
+
while (queue.length) {
|
|
147
|
+
const { node, path, depth } = queue.shift();
|
|
148
|
+
if (Array.isArray(node)) return { path, count: node.length, first: node[0] };
|
|
149
|
+
if (!node || typeof node !== "object" || depth >= maxDepth) continue;
|
|
150
|
+
for (const [key, child] of Object.entries(node)) {
|
|
151
|
+
queue.push({ node: child, path: path === "$" ? key : `${path}.${key}`, depth: depth + 1 });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const short = (value) => (typeof value === "string" ? value.slice(0, 200) : null);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The SHAPE of a handler's return value: enough to tell "it answered with 12
|
|
161
|
+
* rows of {id,name}" from "it returned a clean failure", and no customer data.
|
|
162
|
+
*/
|
|
163
|
+
export function describeReturn(value) {
|
|
164
|
+
const type = typeOf(value);
|
|
165
|
+
const isObject = type === "object";
|
|
166
|
+
const rows = findRows(value);
|
|
167
|
+
const firstRow = rows?.first;
|
|
168
|
+
return {
|
|
169
|
+
type,
|
|
170
|
+
keys: isObject ? Object.keys(value).slice(0, 50) : [],
|
|
171
|
+
okFlag: isObject && typeof value.ok === "boolean" ? value.ok : null,
|
|
172
|
+
reason: isObject ? short(value.reason) : null,
|
|
173
|
+
error: isObject ? short(value.error) : null,
|
|
174
|
+
rows: rows ? { path: rows.path, count: rows.count } : null,
|
|
175
|
+
fieldNames:
|
|
176
|
+
firstRow && typeof firstRow === "object" && !Array.isArray(firstRow)
|
|
177
|
+
? Object.keys(firstRow).slice(0, 50)
|
|
178
|
+
: [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* runAction only executes data handlers. For an action with `render` the SDK
|
|
184
|
+
* rejects with `Action "x" is a render action and cannot be executed silently`
|
|
185
|
+
* (it needs a conversation to draw into); recognise that so the caller is
|
|
186
|
+
* pointed at `foldspace ask` instead of a stack trace.
|
|
187
|
+
*/
|
|
188
|
+
export function refusedAsNotSilent(message) {
|
|
189
|
+
return typeof message === "string" && /render action|executed silently/i.test(message);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `Action "x" not found`: the key is not in the bundle the page loaded. */
|
|
193
|
+
export function refusedAsUnknown(message) {
|
|
194
|
+
return typeof message === "string" && /\bnot found\b/i.test(message);
|
|
195
|
+
}
|
package/src/attach-daemon.mjs
CHANGED
|
@@ -8,6 +8,8 @@ export const ATTACH_DAEMON_LOG_FILE = "attach-daemon.log";
|
|
|
8
8
|
const CONTROL_FLAGS = ["--daemon", "--status", "--stop"];
|
|
9
9
|
const LIFECYCLE_PATTERN =
|
|
10
10
|
/\[lifecycle\] inspect_registration:(registration_ok|registration_mismatch)/g;
|
|
11
|
+
// The page was refused before registration: no SDK on it, or someone else's.
|
|
12
|
+
const REJECTED_PATTERN = /\[lifecycle\] prepare_page:rejected (SDK_NOT_FOUND|REGISTRATION_MISMATCH)/g;
|
|
11
13
|
|
|
12
14
|
export function projectRoot(env = process.env, cwd = process.cwd()) {
|
|
13
15
|
return env.FOLDSPACE_PROJECT_DIR || cwd;
|
|
@@ -59,6 +61,26 @@ export function parseRegistrationLifecycle(logText) {
|
|
|
59
61
|
return last;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/** The last page rejection in the log, or null. */
|
|
65
|
+
export function parseRejection(logText) {
|
|
66
|
+
let last = null;
|
|
67
|
+
for (const match of String(logText ?? "").matchAll(REJECTED_PATTERN)) {
|
|
68
|
+
last = { code: match[1] };
|
|
69
|
+
}
|
|
70
|
+
return last;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What to run next, in one line, when attach refused the page. */
|
|
74
|
+
export function rejectionAdvice(code) {
|
|
75
|
+
if (code === "SDK_NOT_FOUND") {
|
|
76
|
+
return "The page has no Foldspace code. Run: npx foldspace attach --bootstrap --daemon";
|
|
77
|
+
}
|
|
78
|
+
if (code === "REGISTRATION_MISMATCH") {
|
|
79
|
+
return "The page carries a different Foldspace agent. Run: npx foldspace attach --replace --daemon";
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
export function readDaemonState(root) {
|
|
63
85
|
const { stateFile } = daemonPaths(root);
|
|
64
86
|
try {
|
|
@@ -107,6 +129,11 @@ export async function waitForRegistration({
|
|
|
107
129
|
}
|
|
108
130
|
const registration = parseRegistrationLifecycle(text);
|
|
109
131
|
if (registration) return registration;
|
|
132
|
+
const rejection = parseRejection(text);
|
|
133
|
+
if (rejection) {
|
|
134
|
+
const advice = rejectionAdvice(rejection.code);
|
|
135
|
+
throw new Error(advice ? `${advice} (${rejection.code}; log: ${logFile})` : `Attach refused the page: ${rejection.code}. Check ${logFile}`);
|
|
136
|
+
}
|
|
110
137
|
if (!isAlive(pid)) {
|
|
111
138
|
throw new Error(
|
|
112
139
|
`Attach daemon exited before inspect_registration. Check ${logFile}`,
|
package/src/cli-registry.mjs
CHANGED
|
@@ -112,12 +112,13 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
112
112
|
usage: "foldspace build [--watch]",
|
|
113
113
|
risk: "local-write",
|
|
114
114
|
environment: "node",
|
|
115
|
-
environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
|
|
115
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR", "FOLDSPACE_SKIP_UPDATE_CHECK"],
|
|
116
116
|
capabilities: ["artifact.build"],
|
|
117
117
|
positionals: [],
|
|
118
118
|
options: [flag("--watch", "Rebuild when action source changes")],
|
|
119
119
|
prerequisites: ["agent/actions/index.ts"],
|
|
120
120
|
effects: [
|
|
121
|
+
"Checks the harness version first: behind prints one line, a withdrawn version refuses to run (FOLDSPACE_SKIP_UPDATE_CHECK=1 skips the check)",
|
|
121
122
|
"Runs foldspace lint first; errors skip bundling",
|
|
122
123
|
"Writes dist/index.js when lint has no errors",
|
|
123
124
|
],
|
|
@@ -201,6 +202,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
201
202
|
"FOLDSPACE_PROJECT_DIR",
|
|
202
203
|
"CDP_PORT",
|
|
203
204
|
"AGENT_API_NAME",
|
|
205
|
+
"FOLDSPACE_SKIP_UPDATE_CHECK",
|
|
204
206
|
],
|
|
205
207
|
capabilities: [
|
|
206
208
|
"browser.cdp",
|
|
@@ -260,6 +262,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
260
262
|
"sdkUrl in foldspace.dev.json when using --bootstrap",
|
|
261
263
|
],
|
|
262
264
|
effects: [
|
|
265
|
+
"Checks the harness version first, except --status and --stop: behind prints one line, a withdrawn version refuses to run (FOLDSPACE_SKIP_UPDATE_CHECK=1 skips the check)",
|
|
263
266
|
"May reload and instrument matching target pages",
|
|
264
267
|
"Refuses a Chrome that is not the profile inject launched",
|
|
265
268
|
"Test mode is off unless --test-mode is passed: conversations appear in the dashboard",
|
|
@@ -317,6 +320,35 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
317
320
|
],
|
|
318
321
|
next: ["Record what you established in docs/app-profile.md", "Write the handler"],
|
|
319
322
|
}),
|
|
323
|
+
Object.freeze({
|
|
324
|
+
name: "ask",
|
|
325
|
+
entry: "ask.mjs",
|
|
326
|
+
group: "verify",
|
|
327
|
+
summary: "Ask the visible agent a question through its panel; get the reply, card text, what the handlers did, and a screenshot",
|
|
328
|
+
usage: "foldspace ask \"<question>\" [--retry <n>] [--every <s>] [--timeout <s>]",
|
|
329
|
+
risk: "browser-session",
|
|
330
|
+
environment: "local-chrome",
|
|
331
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT"],
|
|
332
|
+
capabilities: ["browser.cdp", "page.evaluate"],
|
|
333
|
+
positionals: [
|
|
334
|
+
Object.freeze({ name: "question", required: true, description: "What an end user would type" }),
|
|
335
|
+
],
|
|
336
|
+
options: [
|
|
337
|
+
value("--retry", "n", "Ask again when no action fired (a newly published action can take a minute to be picked up)", { default: "0" }),
|
|
338
|
+
value("--every", "seconds", "Pause between retries", { default: "20" }),
|
|
339
|
+
value("--timeout", "seconds", "Wait this long for a reply", { default: "90" }),
|
|
340
|
+
],
|
|
341
|
+
prerequisites: ["foldspace attach is running, so the handlers on the page are this project's build"],
|
|
342
|
+
effects: [
|
|
343
|
+
"Types into the panel the way a person does; touches nothing inside the SDK",
|
|
344
|
+
"Creates a real conversation: it appears in the customer's Foldspace dashboard (test traffic only under attach --test-mode)",
|
|
345
|
+
"Saves panel and full-page screenshots under .foldspace-dev/ask/",
|
|
346
|
+
],
|
|
347
|
+
next: [
|
|
348
|
+
"Read reply, cards and handler.execute / handler.render; look at the screenshot",
|
|
349
|
+
"Only then: foldspace badge ready, and invite the customer to try it",
|
|
350
|
+
],
|
|
351
|
+
}),
|
|
320
352
|
Object.freeze({
|
|
321
353
|
name: "badge",
|
|
322
354
|
entry: "badge.mjs",
|
|
@@ -444,7 +476,7 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
|
|
|
444
476
|
},
|
|
445
477
|
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
446
478
|
workflow: ["init", "build", "inject", "attach"],
|
|
447
|
-
verifyWorkflow: ["observe"],
|
|
479
|
+
verifyWorkflow: ["observe", "ask"],
|
|
448
480
|
publishWorkflow: showInternal ? ["deploy"] : [],
|
|
449
481
|
capabilities: CAPABILITY_CATALOGUE.map(([id, description]) => ({
|
|
450
482
|
id,
|
|
@@ -464,6 +496,7 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
|
|
|
464
496
|
"attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
|
|
465
497
|
"Coding agents should run attach --daemon; foreground attach is for humans watching the terminal.",
|
|
466
498
|
"If update.outdated is true, ask the user before foldspace upgrade --yes.",
|
|
499
|
+
"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.",
|
|
467
500
|
"observe, ask and run are read-only second clients on the test window and work while attach runs.",
|
|
468
501
|
"There is no customer-run publication step: putting actions in front of end users is done with Foldspace's team.",
|
|
469
502
|
],
|
package/src/init.mjs
CHANGED
|
@@ -353,7 +353,14 @@ function copyTemplate(templateRoot, targetRoot, values) {
|
|
|
353
353
|
const sourcePath = path.join(templateRoot, relativePath);
|
|
354
354
|
// npm excludes .gitignore files from package tarballs, so the bundled
|
|
355
355
|
// template uses a publish-safe name and restores it during generation.
|
|
356
|
-
|
|
356
|
+
// The permission allowlist lives in .claude/, kept flat here for the
|
|
357
|
+
// same reason.
|
|
358
|
+
const outputPath =
|
|
359
|
+
relativePath === "gitignore"
|
|
360
|
+
? ".gitignore"
|
|
361
|
+
: relativePath === "claude-settings.json"
|
|
362
|
+
? path.join(".claude", "settings.json")
|
|
363
|
+
: relativePath;
|
|
357
364
|
const targetPath = path.join(targetRoot, outputPath);
|
|
358
365
|
assertInside(targetRoot, targetPath);
|
|
359
366
|
|
package/src/upgrade.mjs
CHANGED
|
@@ -227,10 +227,10 @@ function readCache(projectDir, now) {
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
function writeCache(projectDir,
|
|
230
|
+
function writeCache(projectDir, entry, now) {
|
|
231
231
|
const dir = path.dirname(cachePath(projectDir));
|
|
232
232
|
fs.mkdirSync(dir, { recursive: true });
|
|
233
|
-
writeJson(cachePath(projectDir), { checkedAt: now,
|
|
233
|
+
writeJson(cachePath(projectDir), { checkedAt: now, ...entry });
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
export function fetchLatestFromNpm(options = {}) {
|
|
@@ -244,7 +244,27 @@ export function fetchLatestFromNpm(options = {}) {
|
|
|
244
244
|
return normalizeVersion(result.stdout);
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* A version we have withdrawn is marked with `npm deprecate`, so the registry
|
|
249
|
+
* is the list of known-broken versions and an old install can still read it.
|
|
250
|
+
* Returns the deprecation message, "" when the version is not deprecated, or
|
|
251
|
+
* null when the registry could not be asked.
|
|
252
|
+
*/
|
|
253
|
+
export function fetchDeprecationFromNpm(version, options = {}) {
|
|
254
|
+
const spawn = options.spawnSyncFn || spawnSync;
|
|
255
|
+
const result = spawn("npm", ["view", `${HARNESS_PACKAGE}@${version}`, "deprecated"], {
|
|
256
|
+
encoding: "utf8",
|
|
257
|
+
timeout: options.timeoutMs ?? NPM_VIEW_TIMEOUT_MS,
|
|
258
|
+
env: options.env || process.env,
|
|
259
|
+
});
|
|
260
|
+
if (result.error || result.status !== 0) return null;
|
|
261
|
+
return String(result.stdout || "").trim();
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
function nextAction(report) {
|
|
265
|
+
if (report.broken) {
|
|
266
|
+
return `This harness version was withdrawn: ${report.deprecated}. ${UPGRADE_NEXT}`;
|
|
267
|
+
}
|
|
248
268
|
if (report.status === "outdated") {
|
|
249
269
|
return report.instructionsImport === false
|
|
250
270
|
? `${UPGRADE_NEXT}. If CLAUDE.md is still a snapshot, also pass --refresh-instructions`
|
|
@@ -275,7 +295,11 @@ export function checkForUpdate(projectDir, options = {}) {
|
|
|
275
295
|
(options.skipCheck !== false && env.FOLDSPACE_SKIP_UPDATE_CHECK === "1");
|
|
276
296
|
const consumer = !harnessSource && Boolean(pinned || installed);
|
|
277
297
|
|
|
298
|
+
// The version that actually runs is the installed one; the pin is a fallback
|
|
299
|
+
// for a project that has not been installed yet.
|
|
300
|
+
const running = installed || pinned;
|
|
278
301
|
let latest = null;
|
|
302
|
+
let deprecated = null;
|
|
279
303
|
let status = "current";
|
|
280
304
|
|
|
281
305
|
if (!consumer) {
|
|
@@ -284,16 +308,30 @@ export function checkForUpdate(projectDir, options = {}) {
|
|
|
284
308
|
status = "check_skipped";
|
|
285
309
|
} else {
|
|
286
310
|
const cached = options.cache === false ? null : readCache(projectDir, now);
|
|
287
|
-
|
|
311
|
+
const cachedDeprecation =
|
|
312
|
+
cached?.deprecation?.version === running ? cached.deprecation : null;
|
|
313
|
+
if (cached?.latest && cachedDeprecation) {
|
|
288
314
|
latest = cached.latest;
|
|
315
|
+
deprecated = cachedDeprecation.message || null;
|
|
289
316
|
} else {
|
|
290
317
|
const fetched =
|
|
291
318
|
typeof options.fetchLatest === "function"
|
|
292
319
|
? options.fetchLatest()
|
|
293
320
|
: fetchLatestFromNpm(options);
|
|
294
321
|
latest = normalizeVersion(fetched);
|
|
322
|
+
const message =
|
|
323
|
+
typeof options.fetchDeprecation === "function"
|
|
324
|
+
? options.fetchDeprecation(running)
|
|
325
|
+
: fetchDeprecationFromNpm(running, options);
|
|
326
|
+
deprecated = message || null;
|
|
295
327
|
if (latest && options.cache !== false) {
|
|
296
|
-
|
|
328
|
+
const entry = { latest };
|
|
329
|
+
// Only cache an answer the registry actually gave, so an offline
|
|
330
|
+
// run is asked again next time instead of remembered as "fine".
|
|
331
|
+
if (typeof message === "string") {
|
|
332
|
+
entry.deprecation = { version: running, message };
|
|
333
|
+
}
|
|
334
|
+
writeCache(projectDir, entry, now);
|
|
297
335
|
}
|
|
298
336
|
}
|
|
299
337
|
if (!latest) {
|
|
@@ -311,6 +349,8 @@ export function checkForUpdate(projectDir, options = {}) {
|
|
|
311
349
|
installed,
|
|
312
350
|
latest,
|
|
313
351
|
outdated: status === "outdated",
|
|
352
|
+
deprecated,
|
|
353
|
+
broken: Boolean(deprecated),
|
|
314
354
|
status,
|
|
315
355
|
instructionsImport,
|
|
316
356
|
next: null,
|
|
@@ -319,6 +359,33 @@ export function checkForUpdate(projectDir, options = {}) {
|
|
|
319
359
|
return report;
|
|
320
360
|
}
|
|
321
361
|
|
|
362
|
+
/**
|
|
363
|
+
* What `build` and `attach` do with an update report before they run:
|
|
364
|
+
* `block: true` stops the command, `message` (if any) is printed to stderr.
|
|
365
|
+
* Never upgrades - that stays the user's decision (CLAUDE.md, step 1).
|
|
366
|
+
* The escape hatch is FOLDSPACE_SKIP_UPDATE_CHECK=1: the report comes back
|
|
367
|
+
* check_skipped, so nothing here blocks.
|
|
368
|
+
*/
|
|
369
|
+
export function gateForCommand(report) {
|
|
370
|
+
// A check that could not run never stops a build.
|
|
371
|
+
if (!report) return { block: false, message: null };
|
|
372
|
+
if (report.broken) {
|
|
373
|
+
return {
|
|
374
|
+
block: true,
|
|
375
|
+
message:
|
|
376
|
+
`${HARNESS_PACKAGE} ${report.installed || report.pinned} was withdrawn: ${report.deprecated}. ` +
|
|
377
|
+
`${UPGRADE_NEXT}. If the user declines, rerun with FOLDSPACE_SKIP_UPDATE_CHECK=1 to build on this version anyway.`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (report.outdated) {
|
|
381
|
+
return {
|
|
382
|
+
block: false,
|
|
383
|
+
message: `${HARNESS_PACKAGE} ${report.installed || report.pinned} is behind ${report.latest}. ${UPGRADE_NEXT}.`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return { block: false, message: null };
|
|
387
|
+
}
|
|
388
|
+
|
|
322
389
|
export function writeExactPin(projectDir, version) {
|
|
323
390
|
const manifestPath = path.join(projectDir, "package.json");
|
|
324
391
|
const manifest = readPackageManifest(projectDir);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Written by foldspace init. The commands a build runs, allowed up front so the coding agent is not stopped one denial at a time mid-build. Named one by one on purpose: init, upgrade and deploy are not here and still ask. Everything else still asks.",
|
|
3
|
+
"permissions": {
|
|
4
|
+
"allow": [
|
|
5
|
+
"Bash(npx foldspace help:*)",
|
|
6
|
+
"Bash(npx foldspace build:*)",
|
|
7
|
+
"Bash(npx foldspace lint:*)",
|
|
8
|
+
"Bash(npx foldspace inject:*)",
|
|
9
|
+
"Bash(npx foldspace attach:*)",
|
|
10
|
+
"Bash(npx foldspace observe:*)",
|
|
11
|
+
"Bash(npx foldspace ask:*)",
|
|
12
|
+
"Bash(npx foldspace badge:*)",
|
|
13
|
+
"Bash(npm install:*)",
|
|
14
|
+
"Bash(npm run build:*)",
|
|
15
|
+
"Bash(npm run typecheck:*)",
|
|
16
|
+
"Bash(npm run lint:*)",
|
|
17
|
+
"Bash(npm run inject:*)",
|
|
18
|
+
"Bash(npm run attach:*)"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -14,8 +14,10 @@ How to re-run any probe below:
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npm run inject
|
|
17
|
-
# Sign in
|
|
18
|
-
#
|
|
17
|
+
# Sign in, then read the running app with foldspace observe. It reads the
|
|
18
|
+
# window you signed in to, and works while foldspace attach runs.
|
|
19
|
+
npx foldspace observe screen --click "<a label from observe menu>" --match "<words for the data>"
|
|
20
|
+
npx foldspace observe read "<the GET path screen returned>"
|
|
19
21
|
```
|
|
20
22
|
|
|
21
23
|
Start URL: `{{START_URL}}`
|