@foldspace_npm/harness 0.1.22 → 0.1.24
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 +113 -36
- package/bin/ask.mjs +345 -0
- package/bin/attach.mjs +29 -1
- package/bin/observe.mjs +42 -8
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -0
- package/recipes/bottom-bar/recipe.json +5 -1
- package/recipes/several-hosts/README.md +35 -0
- package/recipes/several-hosts/agent/hosts.ts +146 -0
- package/recipes/several-hosts/fixtures/nav-record.json +5 -0
- package/recipes/several-hosts/recipe.json +10 -0
- 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/cdp-client.mjs +6 -1
- package/src/cli-registry.mjs +30 -1
- package/src/init.mjs +8 -1
- package/src/observe-core.mjs +96 -1
- package/src/runtime/render.ts +8 -1
- package/templates/agent-starter/claude-settings.json +21 -0
- package/templates/agent-starter/docs/app-profile.md +10 -0
package/CLAUDE.md
CHANGED
|
@@ -43,7 +43,7 @@ current CLI contract — risk, prerequisites, effects, next step.
|
|
|
43
43
|
|
|
44
44
|
| Level | Reached when |
|
|
45
45
|
|---|---|
|
|
46
|
-
| **L0 · agent live** | injected, visible on the page
|
|
46
|
+
| **L0 · agent live** | injected, visible on the page |
|
|
47
47
|
| **L1 · read settings** | the user's own account, plan or settings in a widget, showing their real values |
|
|
48
48
|
| **L2 · look up entities** | a search from the user's own words, showing rows |
|
|
49
49
|
|
|
@@ -52,7 +52,9 @@ current CLI contract — risk, prerequisites, effects, next step.
|
|
|
52
52
|
window opened at <Product>'s login. Please log in there - I'll notice when
|
|
53
53
|
you're in."* Then `npx foldspace observe wait-login`: it returns the moment
|
|
54
54
|
they are in, so nobody has to type "I've logged in". Then
|
|
55
|
-
`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
|
|
56
58
|
`registration_ok` with the agent visible on their page: take
|
|
57
59
|
`npx foldspace observe screenshot`, send them the image, and tell them to
|
|
58
60
|
look at it and say hello to it. **From here on the agent stays on their
|
|
@@ -86,7 +88,27 @@ back) and in chat you say what to try. No bar for their turn: they may type
|
|
|
86
88
|
whatever, whenever; the pill only ever asks them to wait. `foldspace badge label` puts the small
|
|
87
89
|
corner tag back; `off` removes it, for a screenshot they will share.
|
|
88
90
|
|
|
89
|
-
|
|
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.
|
|
90
112
|
|
|
91
113
|
### Ask as a choice, and ask almost nothing
|
|
92
114
|
|
|
@@ -101,6 +123,31 @@ Then agree the experience in the customer's words, before creating anything:
|
|
|
101
123
|
*"Show my plan and credits needs one new action on your agent and one card to
|
|
102
124
|
show it in. Sound right?"*
|
|
103
125
|
|
|
126
|
+
## One product, several hosts
|
|
127
|
+
|
|
128
|
+
Many products live on more than one host - `www.`, `analyze.`, `admin.` under
|
|
129
|
+
one domain. `init` covers only the host it was given, so on the others the
|
|
130
|
+
agent does not appear and every command refuses the window.
|
|
131
|
+
|
|
132
|
+
- **Find out at the start.** Straight after the agent is live, `npx foldspace
|
|
133
|
+
observe menu` lists the hosts the page links to that belong to the same
|
|
134
|
+
product and that this project does not cover (`otherHosts`). `observe pages`
|
|
135
|
+
and the attach log say the same when the window lands on one.
|
|
136
|
+
- **Widening is the human's yes.** Name the hosts you found and ask once:
|
|
137
|
+
*"Your product also lives on analyze.… and communicate.…. Add them so the
|
|
138
|
+
agent works there too?"* Then add the one pattern (`"*.example.com"`) to
|
|
139
|
+
`hosts` in `foldspace.dev.json`, `npm run inject`, and attach again.
|
|
140
|
+
- **Then go there yourself.** `npx foldspace observe screen --goto
|
|
141
|
+
https://analyze.example.com/reports` opens a screen on any host the project
|
|
142
|
+
names. Do not ask the human to click a tile you can open.
|
|
143
|
+
- **An action reads only the host it runs on** - that is the browser. Build
|
|
144
|
+
each experience on the host its data lives on, and use
|
|
145
|
+
`recipes/several-hosts`: an action asked from the wrong host answers
|
|
146
|
+
`wrong_page` instead of failing.
|
|
147
|
+
- **The user and their conversations carry across hosts; the agent carrying
|
|
148
|
+
on after it navigated does not** (PLG-5962). `recipes/several-hosts` has the
|
|
149
|
+
workaround. It is a workaround: register it (**Rules**, 9).
|
|
150
|
+
|
|
104
151
|
## Find the call yourself
|
|
105
152
|
|
|
106
153
|
Never guess `API_BASE`, `AUTH_SOURCE`, an endpoint or a schema. But guessing
|
|
@@ -111,9 +158,13 @@ and asking are not the only options — **observing is, and it comes first**:
|
|
|
111
158
|
2. **You look - once they have chosen an experience - with `foldspace
|
|
112
159
|
observe`.** It reads the test window they signed in to, it is read-only by
|
|
113
160
|
construction, and it works **while the agent stays attached**: do not
|
|
114
|
-
`attach --stop` to look. Write no browser-driving script of your own
|
|
115
|
-
|
|
116
|
-
|
|
161
|
+
`attach --stop` to look. Write no browser-driving script of your own - not
|
|
162
|
+
to look at the app, and not to talk to the agent: a socket to Chrome's debug
|
|
163
|
+
port that evaluates `agent.message(...)` in a signed-in session is the shape
|
|
164
|
+
of an attack and gets refused. `foldspace observe` looks; `foldspace ask`
|
|
165
|
+
talks. Do not reach for a separate DevTools server either - one launched on
|
|
166
|
+
its own opens a different, signed-out Chrome and can never see the window
|
|
167
|
+
they logged in to.
|
|
117
168
|
|
|
118
169
|
```bash
|
|
119
170
|
npx foldspace observe menu # the product's own screens, by label and path
|
|
@@ -290,8 +341,8 @@ Do not report success without all six:
|
|
|
290
341
|
4. The browser reports `inspect_registration:registration_ok`, and for a named
|
|
291
342
|
action the captured registry includes it.
|
|
292
343
|
5. The action behaves against the real workflow: `[actions]
|
|
293
|
-
local-handler:execute` in the attach log after
|
|
294
|
-
visible agent. A widget also needs `local-handler:render` from a real chat
|
|
344
|
+
local-handler:execute` in the attach log after `foldspace ask` (or the
|
|
345
|
+
human) exercises the visible agent. A widget also needs `local-handler:render` from a real chat
|
|
295
346
|
turn — not a hand-built `render()` call. `runAction` refuses render actions
|
|
296
347
|
and driving `render()` yourself never runs `execute()`.
|
|
297
348
|
6. Browser evidence came from the live target, not hand-authored examples.
|
|
@@ -320,12 +371,21 @@ Do not report success without all six:
|
|
|
320
371
|
MCP (`search_docs`, `fetch_page`) before asserting how the platform behaves;
|
|
321
372
|
cite the page, or say plainly that the docs do not cover it and write down
|
|
322
373
|
what you learned.
|
|
323
|
-
4. **Test through the agent, then let them.** In a first session,
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
and
|
|
328
|
-
|
|
374
|
+
4. **Test through the agent, then let them.** In a first session, prove an
|
|
375
|
+
experience yourself before the customer sees it: once the action is
|
|
376
|
+
published and registered, `npx foldspace ask "<the question>" --retry 2`
|
|
377
|
+
types it into the agent's panel in the test window the way a person would,
|
|
378
|
+
and reports the reply, the card's text, whether `execute` and `render` ran
|
|
379
|
+
(from the attach log) and a screenshot. A reply with no action fired is
|
|
380
|
+
publish lag or a wrong key - `--retry` covers the lag. Look at the
|
|
381
|
+
screenshot. Only then the ready pill and the invitation to try it; never
|
|
382
|
+
hand the first test to the customer. **Ask only what reads.** A question
|
|
383
|
+
that makes the agent create, send, change or delete something is you
|
|
384
|
+
writing in their product: ask it only in an environment the human has
|
|
385
|
+
named as a test one, or let them type it. Those conversations appear in their
|
|
386
|
+
Foldspace dashboard, and that is wanted: seeing their own conversations
|
|
387
|
+
recorded is part of what they came to see. Never pass `--test-mode` in a
|
|
388
|
+
first session.
|
|
329
389
|
5. **Test mode is for an agent that already has real users.** Then `attach
|
|
330
390
|
--test-mode` keeps your conversations out of their data. It is not a safety
|
|
331
391
|
net: publishing still changes the live agent. Working on an agent in
|
|
@@ -341,6 +401,21 @@ Do not report success without all six:
|
|
|
341
401
|
8. **Subagents investigate; they do not build.** A read-only investigation
|
|
342
402
|
that returns a summary belongs in a subagent because it floods context.
|
|
343
403
|
Never delegate a handler or a gate — a summary of a gate is not a gate.
|
|
404
|
+
9. **A platform gap never stops the session - work round it, and register
|
|
405
|
+
it.** When something the experience needs looks missing or broken on
|
|
406
|
+
Foldspace's side - the SDK, the harness, the panel:
|
|
407
|
+
- **Check before you conclude.** `search_docs`, `npx foldspace help --json`
|
|
408
|
+
and the recipes first: it may be supported in a way you have not found.
|
|
409
|
+
Say "I did not find it", never "Foldspace does not support it".
|
|
410
|
+
- **Build the narrowest workaround that gives them a working experience.**
|
|
411
|
+
A prospect is never left stuck on a Foldspace limitation.
|
|
412
|
+
- **Register it, in two places.** A comment in the code where it lives -
|
|
413
|
+
what it works around, how you established that, and that it goes when the
|
|
414
|
+
fix ships - and a row under **Workarounds** in `docs/app-profile.md`:
|
|
415
|
+
what, why, where in the code, verified or assumed. An unregistered
|
|
416
|
+
workaround is copied as house style by whoever comes next, and hides the
|
|
417
|
+
gap from the people who can close it.
|
|
418
|
+
- **Tell the human in one sentence**, and carry on.
|
|
344
419
|
|
|
345
420
|
## Product defaults
|
|
346
421
|
|
|
@@ -356,29 +431,24 @@ Lint catches some of these in handler code; Agent Studio copy is on you.
|
|
|
356
431
|
Three layers: built-in defaults, then Agent Studio, then code - **and code
|
|
357
432
|
wins**. <https://docs.foldspace.ai/customize/configuration/>
|
|
358
433
|
|
|
359
|
-
- **
|
|
360
|
-
Foldspace tools
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
- **Page-specific starters and the bottom bar are
|
|
367
|
-
|
|
434
|
+
- **Conversation starters** are agent-wide and yours to set through the
|
|
435
|
+
Foldspace tools; they carry over to the customer's real site.
|
|
436
|
+
`set_conversation_starters` **replaces the whole set and clears whatever you
|
|
437
|
+
leave out**: call `list_conversation_starters` first and send back the full
|
|
438
|
+
list. It changes their Foldspace account, so it needs their yes. Offer it in
|
|
439
|
+
the second wave, replacing the sign-up starters that point at actions the
|
|
440
|
+
agent does not have.
|
|
441
|
+
- **Page-specific starters and the bottom bar are code** -
|
|
442
|
+
`setConversationStarters` (`null` restores Agent Studio's) and
|
|
368
443
|
`setConfiguration({ bottomBarSettings })` then `openBottomBar()`. Send one
|
|
369
|
-
whole settings group per call, as the docs do.
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
a customer who cannot make the change, decided with Foldspace - not something
|
|
375
|
-
to reach for, and never in a first session.
|
|
444
|
+
whole settings group per call, as the docs do. That code lives in this
|
|
445
|
+
project's start-up (`recipes/bottom-bar`, kind `page-setup`) and ships with
|
|
446
|
+
the actions bundle, so nothing in the customer's source changes; their own
|
|
447
|
+
mount code is the other place it can live, their engineers' call. Offer it in
|
|
448
|
+
the second wave.
|
|
376
449
|
- **An action's `execute` never calls `setConfiguration`**: a partial call from
|
|
377
450
|
one once collapsed the agent's panel to nothing.
|
|
378
451
|
|
|
379
|
-
If they ask for page-specific starters or the bottom bar, say what it takes in
|
|
380
|
-
one sentence and note it in `docs/app-profile.md` as a next step.
|
|
381
|
-
|
|
382
452
|
## Agentic UI components: what bites
|
|
383
453
|
|
|
384
454
|
An Agentic UI component is the card an action draws in the chat (`render`).
|
|
@@ -454,8 +524,10 @@ out: the API helper, the handler or card, and a recorded response per state. Cop
|
|
|
454
524
|
placeholder path and fields with what you observed. They match the harness
|
|
455
525
|
version you have installed, and every one is built and run on each release.
|
|
456
526
|
|
|
457
|
-
|
|
458
|
-
user has to choose. The user's records on one
|
|
527
|
+
After the first experience → `who-is-the-user`. L2 → `find-by-name`, and
|
|
528
|
+
`pick-from-a-list` when the user has to choose. The user's records on one
|
|
529
|
+
card → `records-list`. The product lives on several hosts → `several-hosts`.
|
|
530
|
+
Show one account on a card → `account-overview`. Upload a
|
|
459
531
|
CSV of contacts → `upload-contacts`. Save a note on a meeting →
|
|
460
532
|
`update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
|
|
461
533
|
attention, with reasons → `opportunities-at-risk`.
|
|
@@ -502,7 +574,12 @@ export { apiFetch } from "./api";
|
|
|
502
574
|
- **A refusal is an answer.** When a command or tool call is declined - by the
|
|
503
575
|
human or by the session's own safety checks - stop. Say in one plain sentence
|
|
504
576
|
what you wanted it for, or take a narrower read-only route. Never retry it,
|
|
505
|
-
and never try a different command hoping it gets through
|
|
577
|
+
and never try a different command hoping it gets through - not re-quoted, not
|
|
578
|
+
in pieces, not through a helper script or a subagent: a denial covers the
|
|
579
|
+
outcome, not the string. If a `foldspace` command itself was denied, name it,
|
|
580
|
+
give the one-line allow rule (`Bash(npx foldspace:*)` in
|
|
581
|
+
`.claude/settings.local.json`), and hand that step to the human - for a chat
|
|
582
|
+
turn, that is them typing the question into the panel.
|
|
506
583
|
- **Never create an account or a key** - not in Foldspace, not in the
|
|
507
584
|
customer's product, not anywhere. If one is missing, say which and where it
|
|
508
585
|
goes. **Never widen your own access**: if something is refused for a site or
|
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
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import { serializeDiagnostic } from "../src/diagnostics.mjs";
|
|
46
46
|
import { recordEvent } from "../src/session-events.mjs";
|
|
47
47
|
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
48
|
+
import { pageCoverage } from "../src/observe-core.mjs";
|
|
48
49
|
import {
|
|
49
50
|
ERROR_CODES,
|
|
50
51
|
createLifecycleResult,
|
|
@@ -249,7 +250,7 @@ const badgeSrc = `(() => {
|
|
|
249
250
|
const want = s.state + "|" + (s.text || "");
|
|
250
251
|
if (b.dataset.fsState === want) return;
|
|
251
252
|
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);";
|
|
253
|
+
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
254
|
if (s.state === "working") {
|
|
254
255
|
// Attention, not decoration: a pulsing marker, the ask in bold, and an
|
|
255
256
|
// animated line underneath so it reads as "in progress" at a glance.
|
|
@@ -272,6 +273,18 @@ const badgeSrc = `(() => {
|
|
|
272
273
|
draw();
|
|
273
274
|
document.addEventListener("DOMContentLoaded", draw);
|
|
274
275
|
setInterval(draw, 1000);
|
|
276
|
+
// The corner tag sits over the app's top-left corner - often a logo or the
|
|
277
|
+
// first menu item. Clicks already pass through it (pointer-events:none); it
|
|
278
|
+
// also fades out while the pointer is near, so what is under it can be seen.
|
|
279
|
+
// The working pill never fades: it is the one thing that must be read.
|
|
280
|
+
document.addEventListener("mousemove", (e) => {
|
|
281
|
+
const b = document.getElementById("foldspace-dev-badge");
|
|
282
|
+
if (!b) return;
|
|
283
|
+
if ((b.dataset.fsState || "").indexOf("working") === 0) { b.style.opacity = "1"; return; }
|
|
284
|
+
const r = b.getBoundingClientRect();
|
|
285
|
+
const near = e.clientX >= r.left - 12 && e.clientX <= r.right + 12 && e.clientY >= r.top - 12 && e.clientY <= r.bottom + 12;
|
|
286
|
+
b.style.opacity = near ? "0" : "1";
|
|
287
|
+
}, { passive: true });
|
|
275
288
|
})();`;
|
|
276
289
|
|
|
277
290
|
// Flag every local session as test traffic so it is excluded from analytics and
|
|
@@ -797,6 +810,18 @@ async function prepare(ws, sessionId, url) {
|
|
|
797
810
|
}
|
|
798
811
|
}
|
|
799
812
|
|
|
813
|
+
// A tab on a host of the same product that this project does not name: the
|
|
814
|
+
// agent is not injected there, silently. Say so once per host, with the fix.
|
|
815
|
+
const uncoveredHostsSeen = new Set();
|
|
816
|
+
function noteUncoveredHost(url) {
|
|
817
|
+
if (!/^https?:/.test(String(url || ""))) return;
|
|
818
|
+
const coverage = pageCoverage(url, hostPatterns);
|
|
819
|
+
if (coverage.covered || !coverage.pattern || uncoveredHostsSeen.has(coverage.host)) return;
|
|
820
|
+
uncoveredHostsSeen.add(coverage.host);
|
|
821
|
+
console.log(` [hosts] the window is on ${coverage.host}, which this project does not cover - the agent is not there.`);
|
|
822
|
+
console.log(` Next: add "${coverage.pattern}" to "hosts" in foldspace.dev.json (with the human's yes), then inject and attach again.`);
|
|
823
|
+
}
|
|
824
|
+
|
|
800
825
|
async function onEvent(ws, msg) {
|
|
801
826
|
const { method, params, sessionId } = msg;
|
|
802
827
|
|
|
@@ -807,6 +832,8 @@ async function onEvent(ws, msg) {
|
|
|
807
832
|
sessionUrls.set(params.sessionId, t.url);
|
|
808
833
|
if (hostMatches(t.url, hostPatterns)) {
|
|
809
834
|
await prepare(ws, params.sessionId, t.url);
|
|
835
|
+
} else {
|
|
836
|
+
noteUncoveredHost(t.url);
|
|
810
837
|
}
|
|
811
838
|
} else {
|
|
812
839
|
// Auto-attach pauses workers on start until the attaching client says go.
|
|
@@ -831,6 +858,7 @@ async function onEvent(ws, msg) {
|
|
|
831
858
|
if (hostMatches(t.url, hostPatterns)) {
|
|
832
859
|
await prepare(ws, sid, t.url);
|
|
833
860
|
} else {
|
|
861
|
+
noteUncoveredHost(t.url);
|
|
834
862
|
const state = prepared.get(sid);
|
|
835
863
|
if (state) {
|
|
836
864
|
await cleanupPreparedSession(ws, sid, state, { reload: false });
|