@foldspace_npm/harness 0.1.23 → 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 +42 -1
- package/bin/attach.mjs +16 -0
- 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/src/cdp-client.mjs +6 -1
- package/src/observe-core.mjs +96 -1
- package/src/runtime/render.ts +8 -1
- package/templates/agent-starter/docs/app-profile.md +10 -0
package/CLAUDE.md
CHANGED
|
@@ -123,6 +123,31 @@ Then agree the experience in the customer's words, before creating anything:
|
|
|
123
123
|
*"Show my plan and credits needs one new action on your agent and one card to
|
|
124
124
|
show it in. Sound right?"*
|
|
125
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
|
+
|
|
126
151
|
## Find the call yourself
|
|
127
152
|
|
|
128
153
|
Never guess `API_BASE`, `AUTH_SOURCE`, an endpoint or a schema. But guessing
|
|
@@ -376,6 +401,21 @@ Do not report success without all six:
|
|
|
376
401
|
8. **Subagents investigate; they do not build.** A read-only investigation
|
|
377
402
|
that returns a summary belongs in a subagent because it floods context.
|
|
378
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.
|
|
379
419
|
|
|
380
420
|
## Product defaults
|
|
381
421
|
|
|
@@ -486,7 +526,8 @@ version you have installed, and every one is built and run on each release.
|
|
|
486
526
|
|
|
487
527
|
After the first experience → `who-is-the-user`. L2 → `find-by-name`, and
|
|
488
528
|
`pick-from-a-list` when the user has to choose. The user's records on one
|
|
489
|
-
card → `records-list`.
|
|
529
|
+
card → `records-list`. The product lives on several hosts → `several-hosts`.
|
|
530
|
+
Show one account on a card → `account-overview`. Upload a
|
|
490
531
|
CSV of contacts → `upload-contacts`. Save a note on a meeting →
|
|
491
532
|
`update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
|
|
492
533
|
attention, with reasons → `opportunities-at-risk`.
|
package/bin/attach.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import { serializeDiagnostic } from "../src/diagnostics.mjs";
|
|
46
46
|
import { recordEvent } from "../src/session-events.mjs";
|
|
47
47
|
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
48
|
+
import { pageCoverage } from "../src/observe-core.mjs";
|
|
48
49
|
import {
|
|
49
50
|
ERROR_CODES,
|
|
50
51
|
createLifecycleResult,
|
|
@@ -809,6 +810,18 @@ async function prepare(ws, sessionId, url) {
|
|
|
809
810
|
}
|
|
810
811
|
}
|
|
811
812
|
|
|
813
|
+
// A tab on a host of the same product that this project does not name: the
|
|
814
|
+
// agent is not injected there, silently. Say so once per host, with the fix.
|
|
815
|
+
const uncoveredHostsSeen = new Set();
|
|
816
|
+
function noteUncoveredHost(url) {
|
|
817
|
+
if (!/^https?:/.test(String(url || ""))) return;
|
|
818
|
+
const coverage = pageCoverage(url, hostPatterns);
|
|
819
|
+
if (coverage.covered || !coverage.pattern || uncoveredHostsSeen.has(coverage.host)) return;
|
|
820
|
+
uncoveredHostsSeen.add(coverage.host);
|
|
821
|
+
console.log(` [hosts] the window is on ${coverage.host}, which this project does not cover - the agent is not there.`);
|
|
822
|
+
console.log(` Next: add "${coverage.pattern}" to "hosts" in foldspace.dev.json (with the human's yes), then inject and attach again.`);
|
|
823
|
+
}
|
|
824
|
+
|
|
812
825
|
async function onEvent(ws, msg) {
|
|
813
826
|
const { method, params, sessionId } = msg;
|
|
814
827
|
|
|
@@ -819,6 +832,8 @@ async function onEvent(ws, msg) {
|
|
|
819
832
|
sessionUrls.set(params.sessionId, t.url);
|
|
820
833
|
if (hostMatches(t.url, hostPatterns)) {
|
|
821
834
|
await prepare(ws, params.sessionId, t.url);
|
|
835
|
+
} else {
|
|
836
|
+
noteUncoveredHost(t.url);
|
|
822
837
|
}
|
|
823
838
|
} else {
|
|
824
839
|
// Auto-attach pauses workers on start until the attaching client says go.
|
|
@@ -843,6 +858,7 @@ async function onEvent(ws, msg) {
|
|
|
843
858
|
if (hostMatches(t.url, hostPatterns)) {
|
|
844
859
|
await prepare(ws, sid, t.url);
|
|
845
860
|
} else {
|
|
861
|
+
noteUncoveredHost(t.url);
|
|
846
862
|
const state = prepared.get(sid);
|
|
847
863
|
if (state) {
|
|
848
864
|
await cleanupPreparedSession(ws, sid, state, { reload: false });
|
package/bin/observe.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* foldspace observe pages
|
|
8
8
|
* foldspace observe menu
|
|
9
|
-
* foldspace observe screen [--click "<label>"] [--goto /path] [--match "deal stage"] [--wait 4000]
|
|
9
|
+
* foldspace observe screen [--click "<label>"] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
|
|
10
10
|
* foldspace observe read <path-or-url>
|
|
11
11
|
* foldspace observe auth [--wait 5000]
|
|
12
12
|
* foldspace observe styles
|
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
rankCandidates,
|
|
42
42
|
readHint,
|
|
43
43
|
refuseWriteFlags,
|
|
44
|
-
|
|
44
|
+
gotoDestination,
|
|
45
|
+
pageCoverage,
|
|
46
|
+
uncoveredSiblings,
|
|
45
47
|
summarizeJson,
|
|
46
48
|
toHex,
|
|
47
49
|
} from "../src/observe-core.mjs";
|
|
@@ -294,10 +296,33 @@ async function runPages() {
|
|
|
294
296
|
`The inject Chrome is not answering on :${state.port}. It was closed; run \`npm run inject\` again.`,
|
|
295
297
|
);
|
|
296
298
|
}
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
const real = targets.filter(
|
|
300
|
+
(target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
|
|
301
|
+
);
|
|
302
|
+
const pages = real.map((target) => {
|
|
303
|
+
const coverage = /^https?:/.test(String(target.url)) ? pageCoverage(target.url, state.hosts) : { covered: true };
|
|
304
|
+
return {
|
|
305
|
+
url: printableUrl(target.url),
|
|
306
|
+
title: target.title,
|
|
307
|
+
...(coverage.covered ? {} : { covered: false }),
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
// A tab on a host of the same product that this project does not name: every
|
|
311
|
+
// command would refuse it, and the agent would not appear there.
|
|
312
|
+
const off = real
|
|
313
|
+
.map((target) => pageCoverage(target.url, state.hosts))
|
|
314
|
+
.find((coverage) => !coverage.covered && coverage.pattern);
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
pages,
|
|
318
|
+
...(off
|
|
319
|
+
? {
|
|
320
|
+
hostsHint:
|
|
321
|
+
`The window is on ${off.host}, which this project does not cover (hosts: ${state.hosts.join(", ")}). ` +
|
|
322
|
+
`If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
|
|
323
|
+
}
|
|
324
|
+
: {}),
|
|
325
|
+
};
|
|
301
326
|
}
|
|
302
327
|
|
|
303
328
|
async function runMenu(page) {
|
|
@@ -311,11 +336,20 @@ async function runMenu(page) {
|
|
|
311
336
|
items.push(publicItem(entry));
|
|
312
337
|
}
|
|
313
338
|
const url = await page.evaluate("return location.href;");
|
|
339
|
+
// Links that leave this host but stay in the product (analyze., admin. ...).
|
|
340
|
+
// Looked for across the whole page: product tiles are often not navigation.
|
|
341
|
+
const leaving = await page
|
|
342
|
+
.evaluate(
|
|
343
|
+
`return [...document.querySelectorAll("a[href]")].map((a) => a.href).filter((href) => /^https?:/.test(href)).slice(0, 400);`,
|
|
344
|
+
)
|
|
345
|
+
.catch(() => []);
|
|
346
|
+
const siblings = uncoveredSiblings(Array.isArray(leaving) ? leaving : [], url, page.info.hosts);
|
|
314
347
|
return {
|
|
315
348
|
ok: true,
|
|
316
349
|
url: printableUrl(url),
|
|
317
350
|
count: items.length,
|
|
318
351
|
items,
|
|
352
|
+
...(siblings ? { otherHosts: siblings.hosts, hostsHint: siblings.hint } : {}),
|
|
319
353
|
...(items.length ? {} : { note: "No visible navigation found. The app may still be loading, or showing a login page." }),
|
|
320
354
|
};
|
|
321
355
|
}
|
|
@@ -324,7 +358,7 @@ async function runScreen(page, flags) {
|
|
|
324
358
|
const waitMs = numberFlag(flags.wait, 4000, "wait");
|
|
325
359
|
const words = parseMatchWords(flags.match);
|
|
326
360
|
const before = await page.evaluate("return location.href;");
|
|
327
|
-
const destination = flags.goto !== undefined ?
|
|
361
|
+
const destination = flags.goto !== undefined ? gotoDestination(flags.goto, before, page.info.hosts) : null;
|
|
328
362
|
|
|
329
363
|
await page.send("Network.enable");
|
|
330
364
|
const network = recordNetwork(page);
|
|
@@ -332,7 +366,7 @@ async function runScreen(page, flags) {
|
|
|
332
366
|
|
|
333
367
|
if (destination) {
|
|
334
368
|
await withoutStealingFocus(() => page.send("Page.navigate", { url: destination.url }));
|
|
335
|
-
reached = { how: "goto", path: destination.path };
|
|
369
|
+
reached = { how: "goto", path: destination.path, ...(destination.host ? { host: destination.host } : {}) };
|
|
336
370
|
if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
|
|
337
371
|
}
|
|
338
372
|
if (flags.click !== undefined) {
|
package/package.json
CHANGED
package/recipes/INDEX.md
CHANGED
|
@@ -13,6 +13,7 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
13
13
|
| [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An action with a card that waits for the user | 1 production build |
|
|
14
14
|
| [`update-meeting-notes`](update-meeting-notes/) | **L4** | A note is saved on a meeting and the new note id comes back | A write. **No card** | 1 production build |
|
|
15
15
|
| [`prepare-for-a-meeting`](prepare-for-a-meeting/) | **L2** | Meeting facts are gathered in code, then a task agent writes the summary | A data action plus a task agent. **No card** | 1 production build |
|
|
16
|
+
| [`several-hosts`](several-hosts/) | any | The product lives on several hosts (`www.`, `analyze.`, `admin.` ...): an action asked from the wrong host says which page it needs, and the conversation carries on after the agent moves the user there. **The resume is a registered workaround** (PLG-5962) | Page set-up, plus a guard each action calls | 1 production build |
|
|
16
17
|
| [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
17
18
|
| [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
18
19
|
|
|
@@ -5,5 +5,9 @@
|
|
|
5
5
|
"kind": "page-setup",
|
|
6
6
|
"entry": "agent/bottomBar.ts",
|
|
7
7
|
"outcome": "The agent's resting entry point is a bar at the bottom of the page whose starters change with the page the user is on",
|
|
8
|
-
"provenBy": 2
|
|
8
|
+
"provenBy": 2,
|
|
9
|
+
"setup": {
|
|
10
|
+
"import": "installBottomBar",
|
|
11
|
+
"from": "../bottomBar"
|
|
12
|
+
}
|
|
9
13
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# One product, several hosts
|
|
2
|
+
|
|
3
|
+
**The product lives on more than one host** — `www.`, `analyze.`, `communicate.`, `admin.` under one domain — **and the agent should work on all of them.**
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build**, which met each part of this the hard way, one at a time.
|
|
6
|
+
|
|
7
|
+
## Find out at the start
|
|
8
|
+
|
|
9
|
+
`npx foldspace observe menu` names the hosts a page links to that belong to the same product and that this project does not cover (`otherHosts`, `hostsHint`). `init` covers only the host you gave it. Tell the human which hosts you found and ask for a yes before adding the pattern (`"*.example.com"`) to `hosts` in `foldspace.dev.json`; then `npm run inject` and attach again. After that you open screens on the other hosts yourself: `npx foldspace observe screen --goto https://analyze.example.com/reports`.
|
|
10
|
+
|
|
11
|
+
## What this recipe gives
|
|
12
|
+
|
|
13
|
+
| Piece | What it does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `wrongHost(tool)` | First line of an action. On the right host: `null`. On another: `{ status: "wrong_page", required_page, current_page }`, and no request is made |
|
|
16
|
+
| `installResumeAcrossHosts()` | **A registered workaround.** After the agent moves the user to the right host, the conversation carries on there by itself |
|
|
17
|
+
|
|
18
|
+
In Agent Studio, each action's instructions say: *on `wrong_page`, navigate to that tool's route, then call the action again.* Give each tool a navigation route with a full address.
|
|
19
|
+
|
|
20
|
+
## Adapt it
|
|
21
|
+
|
|
22
|
+
| File | Change |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `agent/hosts.ts` → `TOOLS` | The product's tools and how to recognise each one's host (and path, if two tools share a host) |
|
|
25
|
+
| `agent/actions/index.ts` | `installResumeAcrossHosts()` once, next to the registry |
|
|
26
|
+
| each action | `const wrong = wrongHost("reports"); if (wrong) return { success: true, ...wrong };` before any request |
|
|
27
|
+
| `docs/app-profile.md` → Workarounds | One row for the resume workaround: what, why, `agent/hosts.ts`, verified or assumed, "goes when PLG-5962 ships" |
|
|
28
|
+
|
|
29
|
+
## What that build learned the hard way
|
|
30
|
+
|
|
31
|
+
- **An action can only read the host it runs on.** Asked from Analyze, a Communicate action failed its first read in 150 ms. That is the browser, not the app.
|
|
32
|
+
- **Who the user is, and their conversations, do carry across hosts** — Foldspace keeps the identity on the shared domain. What does not carry is the agent *continuing its turn* after it navigated: the note it leaves itself is kept per host, and the page that is leaving reports the navigation as finished.
|
|
33
|
+
- **Reopening the conversation is not enough.** The first attempt restored the note on the new host; the right chat opened and nothing happened. It took a message to start the next turn — it shows as a user bubble, "Continue on this page."
|
|
34
|
+
- **Ask the browser for the shared domain.** Counting dots gets `.co.uk` wrong.
|
|
35
|
+
- **Remove it when the SDK carries on by itself.** This is Foldspace's to fix, not the customer's code to keep.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// One product, several hosts (www. / analyze. / communicate. / admin. ...).
|
|
2
|
+
//
|
|
3
|
+
// Two facts decide everything here:
|
|
4
|
+
// 1. An action runs inside the page, and a page may only read its own host.
|
|
5
|
+
// Asked from the wrong host, the request cannot succeed - so say which
|
|
6
|
+
// page is needed instead of failing (`wrongHost`).
|
|
7
|
+
// 2. WORKAROUND - registered in docs/app-profile.md, goes when PLG-5962 ships.
|
|
8
|
+
// When the agent moves the user to another host, the SDK does not carry
|
|
9
|
+
// on with the turn: its resume note is kept per host, and the page that
|
|
10
|
+
// is leaving reports the navigation as done. The user and the
|
|
11
|
+
// conversation do carry over; only the "carry on" is lost. So the
|
|
12
|
+
// conversation id is handed across in a 60-second cookie on the domain
|
|
13
|
+
// the hosts share, and the new page reopens it and starts the next turn
|
|
14
|
+
// (`installResumeAcrossHosts`). Established on a live build, 2026-09-25.
|
|
15
|
+
|
|
16
|
+
import { getAgent } from "./utils";
|
|
17
|
+
|
|
18
|
+
/** ADAPT: the tools of this product and the host each one lives on. */
|
|
19
|
+
export const TOOLS: Record<string, { label: string; onThisHost: (hostname: string, path: string) => boolean }> = {
|
|
20
|
+
reports: { label: "Reports", onThisHost: (hostname) => hostname.startsWith("analyze.") },
|
|
21
|
+
messages: { label: "Messages", onThisHost: (hostname) => hostname.startsWith("communicate.") },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type WrongPage = { status: "wrong_page"; required_page: string; current_page: string };
|
|
25
|
+
|
|
26
|
+
let resumeExpected = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* First line of every action that reads or writes one tool: null on the right
|
|
30
|
+
* host, otherwise the state to hand back - no request is made. The action's
|
|
31
|
+
* Studio instructions say: on `wrong_page`, navigate to that tool's route,
|
|
32
|
+
* then call the action again.
|
|
33
|
+
*/
|
|
34
|
+
export function wrongHost(tool: keyof typeof TOOLS): WrongPage | null {
|
|
35
|
+
const wanted = TOOLS[tool];
|
|
36
|
+
if (!wanted || wanted.onThisHost(window.location.hostname, window.location.pathname)) return null;
|
|
37
|
+
resumeExpected = true;
|
|
38
|
+
return { status: "wrong_page", required_page: wanted.label, current_page: window.location.hostname };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------- the workaround
|
|
42
|
+
|
|
43
|
+
const SDK_NAV_KEY = "_eucera_nav"; // the SDK's own resume note (per host)
|
|
44
|
+
const COOKIE = "fs_resume";
|
|
45
|
+
const TTL_SECONDS = 60;
|
|
46
|
+
export const RESUME_MESSAGE = "Continue on this page.";
|
|
47
|
+
|
|
48
|
+
type Carried = { conversationId: string; origin: string; expiry: number };
|
|
49
|
+
|
|
50
|
+
function readCookie(name: string): string | null {
|
|
51
|
+
const hit = document.cookie.split("; ").find((entry) => entry.startsWith(`${name}=`));
|
|
52
|
+
return hit ? hit.slice(name.length + 1) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The widest domain the browser lets this page set a cookie on - the domain
|
|
57
|
+
* the product's hosts share. Asked of the browser, the way the SDK does it:
|
|
58
|
+
* guessing from the number of dots gets `.co.uk` wrong.
|
|
59
|
+
*/
|
|
60
|
+
export function sharedCookieDomain(): string | null {
|
|
61
|
+
const labels = window.location.hostname.split(".");
|
|
62
|
+
if (labels.length < 2) return null;
|
|
63
|
+
const probe = "fs_domain_probe";
|
|
64
|
+
for (let from = labels.length - 1; from >= 0; from -= 1) {
|
|
65
|
+
const domain = labels.slice(from).join(".");
|
|
66
|
+
document.cookie = `${probe}=1; domain=${domain}; path=/; max-age=30`;
|
|
67
|
+
if (readCookie(probe) === "1") {
|
|
68
|
+
document.cookie = `${probe}=; domain=${domain}; path=/; max-age=0`;
|
|
69
|
+
return domain;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The page that is leaving: hand the conversation id to the next host. */
|
|
76
|
+
function carryOut(): void {
|
|
77
|
+
if (!resumeExpected) return;
|
|
78
|
+
try {
|
|
79
|
+
const raw = window.localStorage.getItem(SDK_NAV_KEY);
|
|
80
|
+
if (!raw) return;
|
|
81
|
+
const record = JSON.parse(raw) as { payload?: { conversationId?: string }; normalizedUrl?: string };
|
|
82
|
+
const conversationId = record.payload?.conversationId;
|
|
83
|
+
if (!conversationId || !record.normalizedUrl || !/^https?:\/\//.test(record.normalizedUrl)) return;
|
|
84
|
+
const target = new URL(record.normalizedUrl);
|
|
85
|
+
const domain = sharedCookieDomain();
|
|
86
|
+
// Only to another host of the same product - never off the shared domain.
|
|
87
|
+
if (!domain || target.origin === window.location.origin) return;
|
|
88
|
+
if (target.hostname !== domain && !target.hostname.endsWith(`.${domain}`)) return;
|
|
89
|
+
const carried: Carried = { conversationId, origin: target.origin, expiry: Date.now() + TTL_SECONDS * 1000 };
|
|
90
|
+
const secure = window.location.protocol === "https:" ? "; secure" : "";
|
|
91
|
+
document.cookie = `${COOKIE}=${encodeURIComponent(JSON.stringify(carried))}; domain=${domain}; path=/; max-age=${TTL_SECONDS}; samesite=lax${secure}`;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn("[several-hosts] could not hand the conversation over", error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function waitForAgent(timeoutMs: number): Promise<any | null> {
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const tick = () => {
|
|
101
|
+
const agent = getAgent();
|
|
102
|
+
if (agent && typeof agent.message === "function") return resolve(agent);
|
|
103
|
+
if (Date.now() - started > timeoutMs) return resolve(null);
|
|
104
|
+
setTimeout(tick, 250);
|
|
105
|
+
};
|
|
106
|
+
tick();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The page that arrives: reopen the conversation and start its next turn. */
|
|
111
|
+
async function bringIn(waitMs: number, settleMs: number): Promise<"resumed" | "nothing" | "no_agent"> {
|
|
112
|
+
try {
|
|
113
|
+
const value = readCookie(COOKIE);
|
|
114
|
+
if (!value) return "nothing";
|
|
115
|
+
const domain = sharedCookieDomain();
|
|
116
|
+
// Used once: whatever happens next, the note is gone.
|
|
117
|
+
if (domain) document.cookie = `${COOKIE}=; domain=${domain}; path=/; max-age=0`;
|
|
118
|
+
const carried = JSON.parse(decodeURIComponent(value)) as Carried;
|
|
119
|
+
if (carried.origin !== window.location.origin || carried.expiry < Date.now()) return "nothing";
|
|
120
|
+
const agent = await waitForAgent(waitMs);
|
|
121
|
+
if (!agent) {
|
|
122
|
+
console.warn("[several-hosts] the agent did not appear; the conversation was not resumed");
|
|
123
|
+
return "no_agent";
|
|
124
|
+
}
|
|
125
|
+
// The chat frame has to load before it can switch conversation and send.
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
127
|
+
agent.selectConversation(carried.conversationId);
|
|
128
|
+
agent.message(RESUME_MESSAGE);
|
|
129
|
+
return "resumed";
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.warn("[several-hosts] could not resume the conversation", error);
|
|
132
|
+
return "nothing";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let installed: Promise<"resumed" | "nothing" | "no_agent"> | null = null;
|
|
137
|
+
|
|
138
|
+
/** Call once from agent/actions/index.ts, next to the registry. Safe to call twice. */
|
|
139
|
+
export function installResumeAcrossHosts({ waitMs = 20_000, settleMs = 2_500 } = {}): Promise<
|
|
140
|
+
"resumed" | "nothing" | "no_agent"
|
|
141
|
+
> {
|
|
142
|
+
if (installed) return installed;
|
|
143
|
+
installed = bringIn(waitMs, settleMs);
|
|
144
|
+
window.addEventListener("pagehide", carryOut);
|
|
145
|
+
return installed;
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "One product, several hosts",
|
|
3
|
+
"level": "any",
|
|
4
|
+
"family": "several-hosts",
|
|
5
|
+
"kind": "page-setup",
|
|
6
|
+
"entry": "agent/hosts.ts",
|
|
7
|
+
"setup": { "import": "installResumeAcrossHosts", "from": "../hosts" },
|
|
8
|
+
"outcome": "An action asked from the wrong host says so instead of failing, and the conversation carries on after the agent moves the user to the right host",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
package/src/cdp-client.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import path from "node:path";
|
|
|
14
14
|
import { assertOwnedCdp, chromeProfileDir, ownershipErrorMessage } from "./cdp-ownership.mjs";
|
|
15
15
|
import { resolveProjectDir } from "./upgrade.mjs";
|
|
16
16
|
|
|
17
|
+
import { pageCoverage } from "./observe-core.mjs";
|
|
17
18
|
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
19
|
|
|
19
20
|
/** Where inject recorded the Chrome it launched. Never guess a port. */
|
|
@@ -76,8 +77,12 @@ export function choosePage(pages, hosts = []) {
|
|
|
76
77
|
const match = real.find((page) => hostMatches(page.url, hosts));
|
|
77
78
|
if (match) return match;
|
|
78
79
|
if (hosts.length) {
|
|
80
|
+
const off = real.map((page) => pageCoverage(page.url, hosts)).find((coverage) => coverage.pattern);
|
|
79
81
|
throw new Error(
|
|
80
|
-
|
|
82
|
+
off
|
|
83
|
+
? `The test window is on ${off.host}, which this project does not cover (hosts: ${hosts.join(", ")}). ` +
|
|
84
|
+
`If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`
|
|
85
|
+
: `The test window has no tab on ${hosts.join(", ")}. Open the product there (or finish signing in), then try again.`,
|
|
81
86
|
);
|
|
82
87
|
}
|
|
83
88
|
return real[0];
|
package/src/observe-core.mjs
CHANGED
|
@@ -43,6 +43,63 @@ export function isSameSite(url, pageUrl, hosts = []) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* One product often lives on several hosts (www., analyze., admin. ...), and a
|
|
48
|
+
* project only works on the hosts it names. Given addresses seen from a page,
|
|
49
|
+
* say which hosts look like the same product but are NOT covered, and the one
|
|
50
|
+
* pattern that would cover them. The site is an approximation (see siteOf), so
|
|
51
|
+
* this only ever SUGGESTS: widening `hosts` is the human's decision.
|
|
52
|
+
*/
|
|
53
|
+
export function uncoveredSiblings(urls, pageUrl, hosts = []) {
|
|
54
|
+
let pageHost;
|
|
55
|
+
try {
|
|
56
|
+
pageHost = new URL(pageUrl).hostname.toLowerCase();
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const site = siteOf(pageHost);
|
|
61
|
+
if (!site.includes(".") || /^[\d.]+$/.test(site)) return null;
|
|
62
|
+
const counts = new Map();
|
|
63
|
+
for (const url of urls) {
|
|
64
|
+
let host;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = new URL(url);
|
|
67
|
+
if (!/^https?:$/.test(parsed.protocol)) continue;
|
|
68
|
+
host = parsed.hostname.toLowerCase();
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (host === pageHost || siteOf(host) !== site || hostMatchesAny(host, hosts)) continue;
|
|
73
|
+
counts.set(host, (counts.get(host) || 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
if (!counts.size) return null;
|
|
76
|
+
const found = [...counts].map(([host, seen]) => ({ host, seen })).sort((a, b) => b.seen - a.seen || a.host.localeCompare(b.host));
|
|
77
|
+
const pattern = `*.${site}`;
|
|
78
|
+
return {
|
|
79
|
+
hosts: found.slice(0, 12),
|
|
80
|
+
pattern,
|
|
81
|
+
hint:
|
|
82
|
+
`This product also lives on ${found.slice(0, 4).map((entry) => entry.host).join(", ")}` +
|
|
83
|
+
`${found.length > 4 ? ` and ${found.length - 4} more` : ""}, which this project does not cover. ` +
|
|
84
|
+
`To work there, add "${pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether the page the window is on is one this project works on; if not, what would make it one. */
|
|
89
|
+
export function pageCoverage(pageUrl, hosts = []) {
|
|
90
|
+
let host;
|
|
91
|
+
try {
|
|
92
|
+
host = new URL(pageUrl).hostname.toLowerCase();
|
|
93
|
+
} catch {
|
|
94
|
+
return { covered: false, host: null, pattern: null };
|
|
95
|
+
}
|
|
96
|
+
if (!hosts.length || hostMatchesAny(host, hosts)) return { covered: true, host, pattern: null };
|
|
97
|
+
const shared = hosts
|
|
98
|
+
.map((pattern) => String(pattern).toLowerCase().replace(/^\*\./, ""))
|
|
99
|
+
.some((bare) => siteOf(bare) === siteOf(host));
|
|
100
|
+
return { covered: false, host, pattern: shared ? `*.${siteOf(host)}` : null };
|
|
101
|
+
}
|
|
102
|
+
|
|
46
103
|
// ------------------------------------------------------------ navigation
|
|
47
104
|
|
|
48
105
|
export const NAV_SCOPE_SELECTOR =
|
|
@@ -50,7 +107,45 @@ export const NAV_SCOPE_SELECTOR =
|
|
|
50
107
|
export const NAV_ITEM_SELECTOR =
|
|
51
108
|
"a[href], button, [role=menuitem], [role=tab], [role=link]";
|
|
52
109
|
|
|
53
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Where `--goto` may go: a path on the page's own origin, or a full https
|
|
112
|
+
* address on a host this project names. Nowhere else - the window carries the
|
|
113
|
+
* human's session. The path is screened either way.
|
|
114
|
+
*/
|
|
115
|
+
export function gotoDestination(input, pageUrl, hosts = []) {
|
|
116
|
+
const raw = String(input ?? "").trim();
|
|
117
|
+
if (!/^https?:\/\//i.test(raw)) return { ...sameOriginPath(raw, pageUrl), host: null };
|
|
118
|
+
let target;
|
|
119
|
+
try {
|
|
120
|
+
target = new URL(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error(`--goto: "${raw}" is not an address.`);
|
|
123
|
+
}
|
|
124
|
+
if (target.protocol !== "https:" && new URL(pageUrl).protocol === "https:") {
|
|
125
|
+
throw new Error("--goto must use https.");
|
|
126
|
+
}
|
|
127
|
+
if (target.username || target.password) throw new Error("--goto takes no credentials in the address.");
|
|
128
|
+
const page = new URL(pageUrl);
|
|
129
|
+
const sameOrigin = target.origin === page.origin;
|
|
130
|
+
if (!sameOrigin && !hostMatchesAny(target.hostname, hosts)) {
|
|
131
|
+
const coverage = pageCoverage(target.href, hosts);
|
|
132
|
+
throw new Error(
|
|
133
|
+
`--goto: ${target.hostname} is not one of this project's hosts (${hosts.join(", ") || "none named"}). ` +
|
|
134
|
+
(coverage.pattern
|
|
135
|
+
? `If it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`
|
|
136
|
+
: "observe only opens screens of the product this project is for."),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const refused = pathRefusal(target.pathname);
|
|
140
|
+
if (refused) throw new Error(`--goto: ${refused}`);
|
|
141
|
+
return {
|
|
142
|
+
url: target.href,
|
|
143
|
+
path: target.pathname + target.search + target.hash,
|
|
144
|
+
host: sameOrigin ? null : target.hostname,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `--goto` with a path: the page's own origin and nothing else. */
|
|
54
149
|
export function sameOriginPath(input, pageUrl) {
|
|
55
150
|
const raw = String(input ?? "").trim();
|
|
56
151
|
if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) {
|
package/src/runtime/render.ts
CHANGED
|
@@ -14,7 +14,14 @@ import type { ApiFailure } from "./http";
|
|
|
14
14
|
export type ViewHost = HTMLElement | { container: HTMLElement };
|
|
15
15
|
|
|
16
16
|
function containerOf(host: ViewHost): HTMLElement {
|
|
17
|
-
|
|
17
|
+
// Not `instanceof HTMLElement`: the host Foldspace passes belongs to the
|
|
18
|
+
// agent panel's frame, and an element from another frame is not an instance
|
|
19
|
+
// of THIS frame's HTMLElement. The check was false for every real host, so
|
|
20
|
+
// `host.container` (undefined) was used and every failure card crashed
|
|
21
|
+
// instead of drawing (seen on a live build, 2026-09-24).
|
|
22
|
+
const wrapped = (host as { container?: HTMLElement }).container;
|
|
23
|
+
if (wrapped && typeof wrapped.appendChild === "function") return wrapped;
|
|
24
|
+
return host as HTMLElement;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
@@ -101,6 +101,16 @@ across.
|
|
|
101
101
|
|
|
102
102
|
Listed so the next person knows they exist **and knows they are unverified**.
|
|
103
103
|
|
|
104
|
+
## Workarounds
|
|
105
|
+
|
|
106
|
+
Anything built here because Foldspace - the SDK, the harness, the panel - did
|
|
107
|
+
not do it, or did not seem to. One row each, and the same note as a comment in
|
|
108
|
+
the code. "Assumed" means you did not find support for it; it may exist.
|
|
109
|
+
|
|
110
|
+
| What it does | What it works around | Where in the code | Verified or assumed | Goes when |
|
|
111
|
+
|---|---|---|---|---|
|
|
112
|
+
| | | | | |
|
|
113
|
+
|
|
104
114
|
## Failures observed
|
|
105
115
|
|
|
106
116
|
| Call | Result | What it means |
|