@foldspace_npm/harness 0.1.15 → 0.1.17
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 +94 -37
- package/README.md +3 -3
- package/bin/attach.mjs +50 -19
- package/bin/badge.mjs +50 -0
- package/bin/inject.mjs +5 -2
- package/bin/observe.mjs +790 -0
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -0
- package/recipes/bottom-bar/README.md +43 -0
- package/recipes/bottom-bar/agent/bottomBar.ts +94 -0
- package/recipes/bottom-bar/fixtures/configuration.sent.json +17 -0
- package/recipes/bottom-bar/recipe.json +9 -0
- package/src/badge-core.mjs +22 -0
- package/src/cdp-client.mjs +233 -0
- package/src/cli-help.mjs +2 -1
- package/src/cli-registry.mjs +85 -6
- package/src/observe-core.mjs +627 -0
- package/src/session-events.mjs +42 -0
- package/templates/agent-starter/README.md +2 -2
package/package.json
CHANGED
package/recipes/INDEX.md
CHANGED
|
@@ -7,6 +7,7 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
7
7
|
| [`who-is-the-user`](who-is-the-user/) | **L0** | Foldspace knows who is signed in — id, email, name, role, subscription — so conversations and analytics are not anonymous | Runs once at start-up. No action, no card | 4 production builds |
|
|
8
8
|
| [`find-by-name`](find-by-name/) | **L2** | The agent turns a name the user said into the id the next action needs; a count when no name is given; every tie when the name is ambiguous; near-misses when nothing matches | A data action. **No card** | 3 production builds |
|
|
9
9
|
| [`pick-from-a-list`](pick-from-a-list/) | **L2** | A clickable list, shown **only** when the user has to choose — with "none of these" and cancel | An action with a card that waits for the user | 1 production build |
|
|
10
|
+
| [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page whose starters change with the screen the user is on — configured from code, the way customers do it | Page set-up, runs once at start-up | 2 |
|
|
10
11
|
| [`swap-the-login-method`](swap-the-login-method/) | any | `apiFetch` for an app that authenticates with a custom header, or with cookies, instead of a bearer token | A replacement `agent/utils.ts` | 2 production builds (3 builds, 3 different schemes) |
|
|
11
12
|
|
|
12
13
|
**Not here yet — no production build proves it:** the user's own plan or
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Bottom bar with page starters — L0
|
|
2
|
+
|
|
3
|
+
The bottom bar is the agent's resting entry point: a bar at the foot of the
|
|
4
|
+
page that shows a few starters and opens the agent when one is clicked. Its
|
|
5
|
+
starters change with the screen the user is on, so an agent with two actions
|
|
6
|
+
still looks useful everywhere.
|
|
7
|
+
|
|
8
|
+
It is configured **from code** — Agent Studio has no UI for it today, and this
|
|
9
|
+
is how customers set it. **Proven by 2 production builds** (one in the
|
|
10
|
+
product's own source, one from the agent project behind a feature flag).
|
|
11
|
+
|
|
12
|
+
## Adapt it
|
|
13
|
+
|
|
14
|
+
| In `agent/bottomBar.ts` | Change |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `PAGE_STARTERS` | This product's screens (path prefixes) and, for each, two or three questions **its actions can answer**. A starter for something the agent cannot do is worse than none |
|
|
17
|
+
| `DEFAULT_STARTERS` | What to show on any other page |
|
|
18
|
+
| `BOTTOM_BAR_SETTINGS` | Leave as the docs' defaults unless the product asks |
|
|
19
|
+
|
|
20
|
+
Then call it from the bundle's entry point:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// agent/actions/index.ts — last line
|
|
24
|
+
import { installBottomBar } from "../bottomBar";
|
|
25
|
+
installBottomBar();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## What those builds learned
|
|
29
|
+
|
|
30
|
+
- **`setConversationStarters` replaces the whole set for the page** — both
|
|
31
|
+
groups. `null` restores Agent Studio's. So always pass the full list you
|
|
32
|
+
want shown.
|
|
33
|
+
- **In embedded mode the bar needs `openEmbeddedCallback`**, or nothing opens
|
|
34
|
+
when a starter is clicked. This recipe is for the overlay agent; see the
|
|
35
|
+
docs for embedded.
|
|
36
|
+
- **Route changes in a single-page app don't reload**, so the starters must be
|
|
37
|
+
re-applied on navigation — this recipe wraps `pushState` / `replaceState`
|
|
38
|
+
and listens to `popstate`.
|
|
39
|
+
- **Dark mode**: the SDK's default colours are being fixed (PLG-5891). Until
|
|
40
|
+
then a product in dark mode may need `theme` / `darkModeSettings` set.
|
|
41
|
+
|
|
42
|
+
Docs: [Bottom Bar](https://docs.foldspace.ai/customize/bottom-bar/) ·
|
|
43
|
+
[Conversation starters](https://docs.foldspace.ai/customize/conversation-starters/)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The bottom bar: the agent's resting entry point at the foot of the page,
|
|
2
|
+
// cycling starters that fit the screen the user is on. Configured from code -
|
|
3
|
+
// Agent Studio has no UI for it today, and this is how customers set it.
|
|
4
|
+
// https://docs.foldspace.ai/customize/bottom-bar/
|
|
5
|
+
// https://docs.foldspace.ai/customize/conversation-starters/
|
|
6
|
+
//
|
|
7
|
+
// Product-neutral: the paths and starters below are placeholders. Replace them
|
|
8
|
+
// with this product's own screens and with questions its actions can answer.
|
|
9
|
+
|
|
10
|
+
import { getAgent } from "./utils";
|
|
11
|
+
|
|
12
|
+
/** Starters per screen. Paths are matched as prefixes, first match wins. */
|
|
13
|
+
export const PAGE_STARTERS: Array<{ match: RegExp; starters: string[] }> = [
|
|
14
|
+
{ match: /^\/dashboard/, starters: ["<A question about what this screen shows>", "<Another one>"] },
|
|
15
|
+
{ match: /^\/<records>/, starters: ["Find <a record> by name", "Show me <a record>'s details"] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Shown on any page not listed above. */
|
|
19
|
+
export const DEFAULT_STARTERS: string[] = ["What can you do?", "Show my account"];
|
|
20
|
+
|
|
21
|
+
// The docs' own settings, unchanged. Change nothing here unless the product
|
|
22
|
+
// asks for it: every field is documented on the Bottom Bar page.
|
|
23
|
+
export const BOTTOM_BAR_SETTINGS = {
|
|
24
|
+
enabled: true,
|
|
25
|
+
maxVisibleStarters: 3,
|
|
26
|
+
reopenFrequency: "EVERY_LOAD",
|
|
27
|
+
starterClickBehavior: "OPEN_AGENT",
|
|
28
|
+
idleTimeoutMs: 5000,
|
|
29
|
+
initialBehaviorMode: "FULL",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export function startersForPath(pathname: string): string[] {
|
|
33
|
+
const hit = PAGE_STARTERS.find((entry) => entry.match.test(pathname));
|
|
34
|
+
return hit ? hit.starters : DEFAULT_STARTERS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// On window, not in module scope: a bundle evaluated twice on one page must
|
|
38
|
+
// not install two route listeners.
|
|
39
|
+
const INSTALLED_FLAG = "__foldspace_bottom_bar__";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Turn the bar on and keep its starters matched to the page. Call once from
|
|
43
|
+
* the bundle's entry point; safe to call again.
|
|
44
|
+
*
|
|
45
|
+
* `setConversationStarters` REPLACES the whole set for this page view (both
|
|
46
|
+
* groups); passing `null` restores what Agent Studio has. Starters go in the
|
|
47
|
+
* KNOWLEDGE group so the bar shows them as plain questions.
|
|
48
|
+
*/
|
|
49
|
+
export function installBottomBar(): void {
|
|
50
|
+
const foldspace = (window as any).foldspace;
|
|
51
|
+
if (typeof foldspace !== "function") return;
|
|
52
|
+
if ((window as any)[INSTALLED_FLAG]) return;
|
|
53
|
+
(window as any)[INSTALLED_FLAG] = true;
|
|
54
|
+
|
|
55
|
+
foldspace("when", "ready", () => {
|
|
56
|
+
const agent = getAgent();
|
|
57
|
+
if (!agent) return;
|
|
58
|
+
let current: string[] = [];
|
|
59
|
+
|
|
60
|
+
const apply = () => {
|
|
61
|
+
const next = startersForPath(window.location.pathname);
|
|
62
|
+
if (next === current) return;
|
|
63
|
+
current = next;
|
|
64
|
+
agent.setConversationStarters?.(
|
|
65
|
+
{ KNOWLEDGE: next.map((title) => ({ title })), ACTION: [] },
|
|
66
|
+
"KNOWLEDGE",
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// One whole settings group per call, as the docs do.
|
|
71
|
+
agent.setConfiguration?.({ bottomBarSettings: BOTTOM_BAR_SETTINGS });
|
|
72
|
+
apply();
|
|
73
|
+
|
|
74
|
+
// Single-page apps change the URL without a load: re-apply on navigation
|
|
75
|
+
// and bring the bar back so the new starters are seen.
|
|
76
|
+
const onRouteChange = () => {
|
|
77
|
+
apply();
|
|
78
|
+
agent.openBottomBar?.({ mode: "FULL" });
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener("popstate", onRouteChange);
|
|
81
|
+
const history = window.history as any;
|
|
82
|
+
for (const method of ["pushState", "replaceState"] as const) {
|
|
83
|
+
const original = history[method];
|
|
84
|
+
if (typeof original !== "function" || original.__foldspaceWrapped) continue;
|
|
85
|
+
const wrapped = function (this: any, ...args: unknown[]) {
|
|
86
|
+
const result = original.apply(this, args);
|
|
87
|
+
onRouteChange();
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
(wrapped as any).__foldspaceWrapped = true;
|
|
91
|
+
history[method] = wrapped;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "This recipe makes no network calls. What it sends to the SDK on /dashboard:",
|
|
3
|
+
"setConfiguration": {
|
|
4
|
+
"bottomBarSettings": {
|
|
5
|
+
"enabled": true,
|
|
6
|
+
"maxVisibleStarters": 3,
|
|
7
|
+
"reopenFrequency": "EVERY_LOAD",
|
|
8
|
+
"starterClickBehavior": "OPEN_AGENT",
|
|
9
|
+
"idleTimeoutMs": 5000,
|
|
10
|
+
"initialBehaviorMode": "FULL"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"setConversationStarters": {
|
|
14
|
+
"starters": { "KNOWLEDGE": [{ "title": "<A question about what this screen shows>" }, { "title": "<Another one>" }], "ACTION": [] },
|
|
15
|
+
"defaultStarterType": "KNOWLEDGE"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Bottom bar with page starters",
|
|
3
|
+
"level": "L0",
|
|
4
|
+
"family": "entry-point",
|
|
5
|
+
"kind": "page-setup",
|
|
6
|
+
"entry": "agent/bottomBar.ts",
|
|
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
|
|
9
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Pure logic behind `foldspace badge`, unit-tested.
|
|
2
|
+
|
|
3
|
+
export const BADGE_STATES = ["working", "ready", "label", "off"];
|
|
4
|
+
|
|
5
|
+
/** What gets written into the page for a badge state, or throws on bad input. */
|
|
6
|
+
export function badgePayload(state, text) {
|
|
7
|
+
const wanted = String(state || "").trim().toLowerCase();
|
|
8
|
+
if (!BADGE_STATES.includes(wanted)) {
|
|
9
|
+
throw new Error(`usage: foldspace badge <${BADGE_STATES.join("|")}> [--text "<what to show>"]`);
|
|
10
|
+
}
|
|
11
|
+
const payload = { state: wanted };
|
|
12
|
+
if (text !== undefined) {
|
|
13
|
+
if (wanted !== "working" && wanted !== "ready") {
|
|
14
|
+
throw new Error(`--text only applies to working or ready (got ${wanted}).`);
|
|
15
|
+
}
|
|
16
|
+
const clean = String(text).replace(/\s+/g, " ").trim();
|
|
17
|
+
if (!clean) throw new Error("--text is empty.");
|
|
18
|
+
if (clean.length > 160) throw new Error("--text is longer than 160 characters; keep it to one line.");
|
|
19
|
+
payload.text = clean;
|
|
20
|
+
}
|
|
21
|
+
return payload;
|
|
22
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// A small CDP page client for the read-only verbs (`observe`, `ask`, `run`).
|
|
2
|
+
//
|
|
3
|
+
// It is a second client on the inject Chrome and is safe to use while
|
|
4
|
+
// `foldspace attach` is running: attach's claim on the debug port is about
|
|
5
|
+
// request interception (`Fetch.enable`) and navigation, and nothing here
|
|
6
|
+
// enables Fetch. `Runtime.evaluate`, `Network.enable` and
|
|
7
|
+
// `Page.captureScreenshot` from a second client do not disturb it.
|
|
8
|
+
//
|
|
9
|
+
// Uses the global WebSocket, as bin/attach.mjs does.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { assertOwnedCdp, chromeProfileDir, ownershipErrorMessage } from "./cdp-ownership.mjs";
|
|
15
|
+
import { resolveProjectDir } from "./upgrade.mjs";
|
|
16
|
+
|
|
17
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
|
|
19
|
+
/** Where inject recorded the Chrome it launched. Never guess a port. */
|
|
20
|
+
export function readLaunchState(projectDir = resolveProjectDir()) {
|
|
21
|
+
const statePath = path.join(projectDir, ".foldspace-dev", "state.json");
|
|
22
|
+
if (!fs.existsSync(statePath)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"No .foldspace-dev/state.json here. Run `npm run inject` first, from the project folder.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
28
|
+
const port = process.env.CDP_PORT || state.debugPort;
|
|
29
|
+
if (!port) throw new Error("state.json has no debugPort. Run `npm run inject` again.");
|
|
30
|
+
const hosts = state.resolvedTarget?.hosts || [];
|
|
31
|
+
return {
|
|
32
|
+
projectDir,
|
|
33
|
+
port: String(port),
|
|
34
|
+
hosts,
|
|
35
|
+
target: state.resolvedTarget || {},
|
|
36
|
+
sentinel: state.sentinel,
|
|
37
|
+
profileDir: chromeProfileDir(projectDir),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listTargets(port) {
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`The inject Chrome is not answering on :${port}. It was closed; run \`npm run inject\` again.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return response.json();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hostMatches(url, hosts) {
|
|
54
|
+
try {
|
|
55
|
+
const host = new URL(url).hostname;
|
|
56
|
+
return hosts.some((pattern) => {
|
|
57
|
+
const bare = String(pattern).replace(/^\*\./, "");
|
|
58
|
+
return host === bare || host.endsWith(`.${bare}`);
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The human's tab: the page on the product's host. When the project names its
|
|
67
|
+
* hosts and no tab is on one of them, that is an error, never "the first tab":
|
|
68
|
+
* these commands reload, click and read with a session attached, and the first
|
|
69
|
+
* tab may be anything the human opened.
|
|
70
|
+
*/
|
|
71
|
+
export function choosePage(pages, hosts = []) {
|
|
72
|
+
const real = pages.filter(
|
|
73
|
+
(target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
|
|
74
|
+
);
|
|
75
|
+
if (!real.length) throw new Error("The test window has no open page.");
|
|
76
|
+
const match = real.find((page) => hostMatches(page.url, hosts));
|
|
77
|
+
if (match) return match;
|
|
78
|
+
if (hosts.length) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`The test window has no tab on ${hosts.join(", ")}. Open the product there (or finish signing in), then try again.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return real[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function pickPage(port, hosts = []) {
|
|
87
|
+
return choosePage(await listTargets(port), hosts);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One check per process: it opens a browser-level session.
|
|
91
|
+
let ownershipVerified = false;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Prove the Chrome on this port is the one `inject` launched for THIS project -
|
|
95
|
+
* same check as `attach`. A stale port, or CDP_PORT pointing elsewhere, would
|
|
96
|
+
* otherwise have these commands reload and click in someone else's browser.
|
|
97
|
+
*/
|
|
98
|
+
export async function assertTestWindow(state, verify = assertOwnedCdp) {
|
|
99
|
+
if (ownershipVerified) return;
|
|
100
|
+
const owned = await verify({ profileDir: state.profileDir, port: state.port, token: state.sentinel });
|
|
101
|
+
if (!owned.ok) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
ownershipErrorMessage(owned.reason, {
|
|
104
|
+
port: state.port,
|
|
105
|
+
profileDir: state.profileDir,
|
|
106
|
+
root: state.projectDir,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
ownershipVerified = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class CdpPage {
|
|
114
|
+
#socket;
|
|
115
|
+
#nextId = 0;
|
|
116
|
+
#pending = new Map();
|
|
117
|
+
#listeners = new Map();
|
|
118
|
+
|
|
119
|
+
constructor(socket, info) {
|
|
120
|
+
this.#socket = socket;
|
|
121
|
+
this.info = info;
|
|
122
|
+
socket.addEventListener("message", (event) => this.#dispatch(JSON.parse(event.data)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static async open({ projectDir } = {}) {
|
|
126
|
+
const state = readLaunchState(projectDir);
|
|
127
|
+
await assertTestWindow(state);
|
|
128
|
+
const page = await pickPage(state.port, state.hosts);
|
|
129
|
+
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
130
|
+
await new Promise((resolve, reject) => {
|
|
131
|
+
socket.addEventListener("open", resolve, { once: true });
|
|
132
|
+
socket.addEventListener("error", () => reject(new Error("Could not open the CDP socket.")), {
|
|
133
|
+
once: true,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
const client = new CdpPage(socket, { ...state, url: page.url, title: page.title });
|
|
137
|
+
await client.send("Runtime.enable");
|
|
138
|
+
await client.send("Page.enable");
|
|
139
|
+
return client;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#dispatch(message) {
|
|
143
|
+
if (message.id && this.#pending.has(message.id)) {
|
|
144
|
+
const { resolve, reject } = this.#pending.get(message.id);
|
|
145
|
+
this.#pending.delete(message.id);
|
|
146
|
+
if (message.error) reject(new Error(message.error.message));
|
|
147
|
+
else resolve(message.result);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const listener of this.#listeners.get(message.method) || []) listener(message.params);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
send(method, params = {}, { timeoutMs = 30000 } = {}) {
|
|
154
|
+
const id = ++this.#nextId;
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
if (this.#pending.delete(id)) {
|
|
158
|
+
reject(new Error(`CDP ${method} did not answer within ${timeoutMs}ms.`));
|
|
159
|
+
}
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
this.#pending.set(id, {
|
|
162
|
+
resolve: (value) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
resolve(value);
|
|
165
|
+
},
|
|
166
|
+
reject: (error) => {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
reject(error);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
this.#socket.send(JSON.stringify({ id, method, params }));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
on(method, listener) {
|
|
176
|
+
if (!this.#listeners.has(method)) this.#listeners.set(method, []);
|
|
177
|
+
this.#listeners.get(method).push(listener);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Evaluate the body of an async function in the page; returns its JSON value. */
|
|
181
|
+
async evaluate(body, { timeoutMs = 30000 } = {}) {
|
|
182
|
+
const result = await this.send(
|
|
183
|
+
"Runtime.evaluate",
|
|
184
|
+
{ expression: `(async () => { ${body} })()`, awaitPromise: true, returnByValue: true },
|
|
185
|
+
{ timeoutMs },
|
|
186
|
+
);
|
|
187
|
+
if (result.exceptionDetails) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
result.exceptionDetails.exception?.description ||
|
|
190
|
+
result.exceptionDetails.text ||
|
|
191
|
+
"evaluate failed",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return result.result?.value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** PNG screenshot of the viewport, or of `clip` ({x,y,width,height}). */
|
|
198
|
+
async screenshot(filePath, clip) {
|
|
199
|
+
const params = { format: "png" };
|
|
200
|
+
if (clip) params.clip = { ...clip, scale: 1 };
|
|
201
|
+
const shot = await this.send("Page.captureScreenshot", params);
|
|
202
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
203
|
+
fs.writeFileSync(filePath, Buffer.from(shot.data, "base64"));
|
|
204
|
+
return filePath;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
close() {
|
|
208
|
+
try {
|
|
209
|
+
this.#socket.close();
|
|
210
|
+
} catch {
|
|
211
|
+
// already closed
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Parse `--flag value` / `--flag` out of argv; returns { flags, rest }. */
|
|
217
|
+
export function parseArgs(argv, valueFlags = []) {
|
|
218
|
+
const flags = {};
|
|
219
|
+
const rest = [];
|
|
220
|
+
for (let index = 0; index < argv.length; index++) {
|
|
221
|
+
const arg = argv[index];
|
|
222
|
+
if (arg.startsWith("--")) {
|
|
223
|
+
const name = arg.slice(2);
|
|
224
|
+
if (valueFlags.includes(name)) flags[name] = argv[++index];
|
|
225
|
+
else flags[name] = true;
|
|
226
|
+
} else rest.push(arg);
|
|
227
|
+
}
|
|
228
|
+
return { flags, rest };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function printJson(value) {
|
|
232
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
233
|
+
}
|
package/src/cli-help.mjs
CHANGED
|
@@ -49,7 +49,8 @@ export function renderGeneralHelp(registry) {
|
|
|
49
49
|
" foldspace upgrade --check Compare the installed pin to npm latest",
|
|
50
50
|
"",
|
|
51
51
|
"attach loads local actions and observes the normal agent experience.",
|
|
52
|
-
"
|
|
52
|
+
"observe, ask and run look at and test that experience without the customer's help.",
|
|
53
|
+
...(registry.publishWorkflow.length ? ["deploy is a separate remote publication step."] : []),
|
|
53
54
|
);
|
|
54
55
|
return sections.join("\n");
|
|
55
56
|
}
|
package/src/cli-registry.mjs
CHANGED
|
@@ -241,9 +241,10 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
241
241
|
),
|
|
242
242
|
value("--agent", "api-name", "Override the configured agent API name"),
|
|
243
243
|
flag(
|
|
244
|
-
"--
|
|
245
|
-
"
|
|
244
|
+
"--test-mode",
|
|
245
|
+
"Mark this session's conversations as test traffic, so they stay out of the dashboard; for an agent that already has real users",
|
|
246
246
|
),
|
|
247
|
+
flag("--no-test-mode", "Accepted for older instructions; test mode is already off by default"),
|
|
247
248
|
flag("--no-badge", "Hide the visible Foldspace development badge"),
|
|
248
249
|
flag(
|
|
249
250
|
"--daemon",
|
|
@@ -261,7 +262,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
261
262
|
effects: [
|
|
262
263
|
"May reload and instrument matching target pages",
|
|
263
264
|
"Refuses a Chrome that is not the profile inject launched",
|
|
264
|
-
"Test mode is
|
|
265
|
+
"Test mode is off unless --test-mode is passed: conversations appear in the dashboard",
|
|
265
266
|
"Never directly invokes an action handler",
|
|
266
267
|
"An empty local action registry is valid; named actions are not required",
|
|
267
268
|
"Restores prepared pages when detached cleanly",
|
|
@@ -276,8 +277,81 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
276
277
|
"Detach with Ctrl-C, or foldspace attach --stop for a daemon",
|
|
277
278
|
],
|
|
278
279
|
}),
|
|
280
|
+
Object.freeze({
|
|
281
|
+
name: "observe",
|
|
282
|
+
entry: "observe.mjs",
|
|
283
|
+
group: "verify",
|
|
284
|
+
summary: "Look at the signed-in test window, read-only: screens, requests, auth, styles",
|
|
285
|
+
usage:
|
|
286
|
+
"foldspace observe <pages|menu|screen|read|auth|styles|screenshot|wait-login> [options]",
|
|
287
|
+
risk: "browser-session",
|
|
288
|
+
environment: "local-chrome",
|
|
289
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT"],
|
|
290
|
+
capabilities: ["browser.cdp", "page.evaluate"],
|
|
291
|
+
positionals: [
|
|
292
|
+
Object.freeze({
|
|
293
|
+
name: "verb",
|
|
294
|
+
required: true,
|
|
295
|
+
description:
|
|
296
|
+
"pages | menu | screen | read <path> | auth | styles | screenshot | wait-login",
|
|
297
|
+
}),
|
|
298
|
+
Object.freeze({
|
|
299
|
+
name: "path",
|
|
300
|
+
required: false,
|
|
301
|
+
description: "read: the GET path or same-site URL to replay",
|
|
302
|
+
}),
|
|
303
|
+
],
|
|
304
|
+
options: [
|
|
305
|
+
value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
|
|
306
|
+
value("--goto", "path", "screen: a same-origin path"),
|
|
307
|
+
value("--match", "words", "screen: rank the requests seen by these words"),
|
|
308
|
+
value("--wait", "ms", "screen/auth: how long to keep listening"),
|
|
309
|
+
value("--out", "file", "screenshot: where to save the PNG"),
|
|
310
|
+
value("--timeout", "seconds", "wait-login: give up after this long", { default: "600" }),
|
|
311
|
+
],
|
|
312
|
+
prerequisites: ["foldspace inject has opened the test window"],
|
|
313
|
+
effects: [
|
|
314
|
+
"GET only. Never submits a form; clicks navigation elements only",
|
|
315
|
+
"Prints names, shapes and counts - never values, tokens or cookies",
|
|
316
|
+
"Works while foldspace attach is running",
|
|
317
|
+
],
|
|
318
|
+
next: ["Record what you established in docs/app-profile.md", "Write the handler"],
|
|
319
|
+
}),
|
|
320
|
+
Object.freeze({
|
|
321
|
+
name: "badge",
|
|
322
|
+
entry: "badge.mjs",
|
|
323
|
+
group: "verify",
|
|
324
|
+
summary: "Set the banner across the top of the test window: working, ready, label or off",
|
|
325
|
+
usage: 'foldspace badge <working|ready|label|off> [--text "<what to show>"]',
|
|
326
|
+
risk: "browser-session",
|
|
327
|
+
environment: "local-chrome",
|
|
328
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT"],
|
|
329
|
+
capabilities: ["browser.cdp", "page.evaluate"],
|
|
330
|
+
positionals: [
|
|
331
|
+
Object.freeze({
|
|
332
|
+
name: "state",
|
|
333
|
+
required: true,
|
|
334
|
+
description:
|
|
335
|
+
"working: the agent is building here, leave the window alone | ready: the human's turn | label: the small corner tag | off: remove it",
|
|
336
|
+
}),
|
|
337
|
+
],
|
|
338
|
+
options: [
|
|
339
|
+
value("--text", "text", "working/ready: the line to show, e.g. the question to try (one line, up to 160 characters)"),
|
|
340
|
+
],
|
|
341
|
+
prerequisites: ["foldspace attach is running (it draws the badge)"],
|
|
342
|
+
effects: [
|
|
343
|
+
"Writes one key to the test window's sessionStorage; touches nothing else on the page",
|
|
344
|
+
"The banner survives navigation inside the test window",
|
|
345
|
+
],
|
|
346
|
+
next: ["Tell the human in chat what the banner says"],
|
|
347
|
+
}),
|
|
279
348
|
Object.freeze({
|
|
280
349
|
name: "deploy",
|
|
350
|
+
// Foldspace-internal: it uploads to Foldspace's own storage, which no
|
|
351
|
+
// customer can write to. It stays runnable (the deploy pipeline calls it)
|
|
352
|
+
// but is left out of help unless FOLDSPACE_INTERNAL=1, because a coding
|
|
353
|
+
// agent that finds it in the contract offers it to the customer.
|
|
354
|
+
internal: true,
|
|
281
355
|
entry: "deploy.mjs",
|
|
282
356
|
group: "publish",
|
|
283
357
|
summary: "Publish dist/index.js to remote Foldspace action storage",
|
|
@@ -351,7 +425,9 @@ export function commandByName(name) {
|
|
|
351
425
|
return CLI_COMMANDS.find((command) => command.name === name) || null;
|
|
352
426
|
}
|
|
353
427
|
|
|
354
|
-
export function createCliRegistry({ packageName, packageVersion }) {
|
|
428
|
+
export function createCliRegistry({ packageName, packageVersion, env = process.env }) {
|
|
429
|
+
const showInternal = env.FOLDSPACE_INTERNAL === "1";
|
|
430
|
+
const visible = CLI_COMMANDS.filter((command) => showInternal || !command.internal);
|
|
355
431
|
const diagnostics = diagnosticCatalogue()
|
|
356
432
|
.filter((diagnostic) => PUBLIC_DIAGNOSTICS.has(diagnostic.name))
|
|
357
433
|
.map((diagnostic) => ({
|
|
@@ -368,7 +444,8 @@ export function createCliRegistry({ packageName, packageVersion }) {
|
|
|
368
444
|
},
|
|
369
445
|
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
370
446
|
workflow: ["init", "build", "inject", "attach"],
|
|
371
|
-
|
|
447
|
+
verifyWorkflow: ["observe"],
|
|
448
|
+
publishWorkflow: showInternal ? ["deploy"] : [],
|
|
372
449
|
capabilities: CAPABILITY_CATALOGUE.map(([id, description]) => ({
|
|
373
450
|
id,
|
|
374
451
|
description,
|
|
@@ -378,7 +455,7 @@ export function createCliRegistry({ packageName, packageVersion }) {
|
|
|
378
455
|
id,
|
|
379
456
|
description,
|
|
380
457
|
})),
|
|
381
|
-
commands:
|
|
458
|
+
commands: visible.map(({ entry: _entry, internal: _internal, ...command }) => command),
|
|
382
459
|
diagnostics,
|
|
383
460
|
notes: [
|
|
384
461
|
"Capability metadata describes current behavior; it is not hosted-environment enforcement.",
|
|
@@ -387,6 +464,8 @@ export function createCliRegistry({ packageName, packageVersion }) {
|
|
|
387
464
|
"attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
|
|
388
465
|
"Coding agents should run attach --daemon; foreground attach is for humans watching the terminal.",
|
|
389
466
|
"If update.outdated is true, ask the user before foldspace upgrade --yes.",
|
|
467
|
+
"observe, ask and run are read-only second clients on the test window and work while attach runs.",
|
|
468
|
+
"There is no customer-run publication step: putting actions in front of end users is done with Foldspace's team.",
|
|
390
469
|
],
|
|
391
470
|
};
|
|
392
471
|
}
|