@foldspace_npm/harness 0.1.30 → 0.1.32

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.
Files changed (40) hide show
  1. package/CLAUDE.md +42 -11
  2. package/README.md +25 -2
  3. package/bin/ask.mjs +16 -145
  4. package/bin/attach.mjs +2 -0
  5. package/bin/cli.mjs +22 -0
  6. package/bin/inject.mjs +2 -14
  7. package/bin/record.mjs +412 -0
  8. package/package.json +1 -1
  9. package/src/attach-daemon.mjs +18 -0
  10. package/src/badge-auto.mjs +38 -1
  11. package/src/cdp-client.mjs +7 -1
  12. package/src/chrome-binary.mjs +28 -0
  13. package/src/cli-help.mjs +1 -0
  14. package/src/cli-registry.mjs +53 -3
  15. package/src/init.mjs +170 -16
  16. package/src/panel-driver.mjs +283 -0
  17. package/src/session-events.mjs +1 -0
  18. package/src/upgrade.mjs +87 -1
  19. package/src/video/README.md +29 -0
  20. package/src/video/assets/foldspace-mark.svg +27 -0
  21. package/src/video/assets/logo-dark.svg +68 -0
  22. package/src/video/assets/logo-light.svg +56 -0
  23. package/src/video/brand.mjs +46 -0
  24. package/src/video/compose.mjs +476 -0
  25. package/src/video/composer/composer.mjs +554 -0
  26. package/src/video/composer/index.html +13 -0
  27. package/src/video/fonts/OFL.txt +93 -0
  28. package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
  29. package/src/video/fonts/Poppins-Regular.woff2 +0 -0
  30. package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
  31. package/src/video/recorder.mjs +331 -0
  32. package/src/video/scenes.mjs +253 -0
  33. package/src/video/timeline.mjs +726 -0
  34. package/src/video/vendor/mp4-muxer.LICENSE +21 -0
  35. package/src/video/vendor/mp4-muxer.min.mjs +5 -0
  36. package/templates/agent-starter/AGENTS.md +24 -0
  37. package/templates/agent-starter/README.md +5 -0
  38. package/templates/agent-starter/claude-settings.json +1 -0
  39. package/templates/agent-starter/gitignore +3 -0
  40. package/templates/agent-starter/skills/share-a-video/SKILL.md +235 -0
package/CLAUDE.md CHANGED
@@ -29,9 +29,9 @@ session ends at something they can **see on their own page** - not at
29
29
 
30
30
  | Result | Do |
31
31
  |---|---|
32
- | The tool does not exist | The Foldspace MCP is not installed in this editor. Install it yourself, the one way that fits *this* region and editor. **EU** - any editor: the MCP block on <https://foldspace.ai/eu/agents.md>, never the plugin. **US** - Claude Code with git on the machine (`command -v git`): `claude plugin marketplace add https://github.com/foldspace-ai/plugins` (already added, or added from another source, is fine), then `claude plugin install foldspace-agent-builder@foldspace-plugins`; without git, or any other editor: the MCP block on <https://foldspace.ai/agents.md>. Either way it needs sign-in: start it before your turn ends, as **Connect Foldspace tools** on that page says. **The plugin** loads when your turn ends: call `list_agents` again on the human's next message. **A server added from the MCP block** (`claude mcp add`) never reaches a chat that is already running - not on the next message, not through `/mcp`: once it is signed in, the human opens a new chat in this folder and repeats their request. `claude mcp get` saying Connected proves only the setting; the check is `list_agents` in the chat you are in |
33
- | It exists but fails on auth | Installed, not signed in. Only the human can approve sign-in. Tell them exactly where - Claude Code: `/mcp` → **foldspace-eu** (**EU**) or **foldspace** (**US**; listed as `plugin:foldspace-agent-builder:foldspace` when the plugin installed it) → finish in the browser - then check again. If `/mcp` does not list that server, it was added after this chat started: the human opens a new chat in this folder, as above |
34
- | It returns agents | One agent: state its name, Agent Key and product in a line and carry on; the human objects if it is wrong. Several: ask which, as a choice |
32
+ | The tool does not exist | The Foldspace MCP is not installed in this editor. Install it yourself, the one way that fits *this* region and editor. **EU** - any editor: the MCP block on <https://foldspace.ai/eu/agents.md>, never the plugin. **US** - Claude Code with git on the machine (`command -v git`): `claude plugin marketplace add https://github.com/foldspace-ai/plugins` (already added, or added from another source, is fine), then `claude plugin install foldspace-agent-builder@foldspace-plugins`; without git, or any other editor: the MCP block on <https://foldspace.ai/agents.md>. Either way it needs sign-in: start it before your turn ends, as **Connect Foldspace tools** on that page says. **The plugin** loads when your turn ends: call `list_agents` again on the human's next message. **A server added from the MCP block** (`claude mcp add`) never reaches a chat that is already running - not on the next message, not through `/mcp`: once it is signed in, the human opens a new chat in this folder and repeats their request. `claude mcp get` saying Connected proves only the setting; the check is `list_agents` in the chat you are in. **Codex**: `codex mcp add foldspace --url https://api.foldspace.ai/mcp` (**EU**: `foldspace-eu` and `https://api.eu.foldspace.ai/mcp`) adds it and starts sign-in in one step; a server added during a thread never reaches that thread, so once it is signed in the work continues in a new thread in this folder: start it yourself if Codex gives you a tool for it - the human's request word for word, plus the one paragraph the playbook's Codex block gives - and say so in one line; otherwise the human starts it and repeats their request. **Cursor**: merge the server into `~/.cursor/mcp.json` yourself, keeping every other server; it reaches this chat on the human's next message, needing sign-in. **Any editor**: never read a saved sign-in or credentials file, and never reach the Foldspace MCP any other way (curl, a script, a client you write). If its tools are not in this chat, the fix is a new chat |
33
+ | It exists but fails on auth | Installed, not signed in. Only the human can approve sign-in. Tell them exactly where - Claude Code: `/mcp` → **foldspace-eu** (**EU**) or **foldspace** (**US**; listed as `plugin:foldspace-agent-builder:foldspace` when the plugin installed it) → finish in the browser - then check again. If `/mcp` does not list that server, it was added after this chat started: the human opens a new chat in this folder, as above. Codex: run `codex mcp login foldspace` (`foldspace-eu` for **EU**) - if the browser does not open, give the human the link it prints - then a new thread. Cursor: call the server's own sign-in tool, `mcp_auth`, yourself - the human finishes in the browser - then `list_agents` in this same chat |
34
+ | It returns agents | Work on the agent whose Agent Key this project has (`agentApiName` in `foldspace.dev.json`). Name it in one line - name, Agent Key and product - and carry on; the human objects if it is wrong. Skip that line when the setup playbook (agents.md) already named it in this chat. When no agent has that key, this account cannot see this project's agent: tell the human which Agent Key the project is for, and that they type `/mcp`, pick that server, and sign in as the account that has it; then check again. Never work on another agent from this folder - one the human wants instead gets a folder of its own (agents.md, from **Confirm the signed-in account**) |
35
35
 
36
36
  3. **Orient** — `get_agent_settings`, `list_actions`, `list_task_agents`,
37
37
  `list_navigation_routes`. Record current versions before changing anything;
@@ -39,17 +39,26 @@ session ends at something they can **see on their own page** - not at
39
39
  seeded at sign-up are often a specification someone already wrote.
40
40
  4. **Read `docs/app-profile.md`.** It is what is already known about this app,
41
41
  with how each fact was established.
42
- 5. **Does the agent already have code saved in Foldspace?** If your Foldspace
43
- tools include `repo_read`, call it with command `status` now - before the
44
- first build, because the first build is what the test window shows them.
42
+ 5. **Does the agent already have code saved in Foldspace?** If the setup
43
+ playbook (agents.md) ran in this chat, it already asked: use its answer in
44
+ the list below, and do not tell the human again what setup told them. If
45
+ setup also set this folder up and found a code project, the project's
46
+ `agent/` is already here - do not replace it again. Otherwise, if your
47
+ Foldspace tools include `repo_read`, call it with command `status` now -
48
+ before the first build, because the first build is what the test window
49
+ shows them.
45
50
  - **A code project.** Call `repo_read` with command `instructions` and
46
51
  follow what it returns. The project's `agent/` is the copy Foldspace
47
52
  builds and publishes, and it may hold work someone else saved. Before you
48
53
  build, replace this folder's `agent/` with it (`repo_read` commands `tree`
49
- and `file`): delete what is there rather than copying over the top, or
50
- their page shows the template instead of their agent. An action file here
51
- that the project lacks is work a session never saved: keep it aside and
52
- put it back after.
54
+ and `file`, with the `ref` the instructions name - the default can be
55
+ older than what someone saved): delete what is there rather than copying
56
+ over the top, or their page shows the template instead of their agent.
57
+ This folder's `agent/` may hold work a session never saved, so copy it to
58
+ `.foldspace-dev/agent-kept/` first. Put back the action files the project
59
+ lacks; where a file differs, the project's wins. If any differed, tell
60
+ the human in one line which actions had changes on this computer that
61
+ were never saved.
53
62
  - **`NO_REPO`.** Nothing is saved yet. Follow the next step `status` gives:
54
63
  it creates the code project in about a minute and changes nothing anyone
55
64
  sees - say you are setting up their project, no need to ask. Get the agent
@@ -73,6 +82,12 @@ session ends at something they can **see on their own page** - not at
73
82
  - **No `repo_read` tool.** This folder is the only copy, so there is no code
74
83
  project.
75
84
 
85
+ With a code project, a published action (step 3, `isPublished: true`)
86
+ whose handler `agent/actions/index.ts` does not register has its code
87
+ somewhere else - on someone's computer, or in their product's own code.
88
+ When you ask to publish for their users, name it and say it may stop
89
+ working once this project is published, because its code is not in it.
90
+
76
91
  Before an unfamiliar harness operation, `npx foldspace help --json` is the
77
92
  current CLI contract — risk, prerequisites, effects, next step.
78
93
 
@@ -153,6 +168,10 @@ steps in **Done, and what you say at the end**:
153
168
  Behavior instructions (Agent Studio → **Style & Behavior** → **Behavior &
154
169
  Responses**). No Foldspace tool writes those, so it is theirs to add: on a
155
170
  yes, give them the line to paste and where it goes.
171
+ 6. **A short video of it to share** - the `share-a-video` skill, in
172
+ `.claude/skills/share-a-video/SKILL.md` (any other editor: read
173
+ `node_modules/@foldspace_npm/harness/templates/agent-starter/skills/share-a-video/SKILL.md`).
174
+ Ask it as its own question next to the choice - a choice holds four options.
156
175
 
157
176
  Not before the first experience: nothing about it needs any of these, and it
158
177
  is silent work before the human has seen anything.
@@ -162,7 +181,7 @@ Uploads and extraction are a later session.
162
181
  ### Ask as a choice, and ask almost nothing
163
182
 
164
183
  Every question to the human is an **enumerated choice with the recommended
165
- option first** (in Claude Code, `AskUserQuestion`). A first session asks only
184
+ option first** (in Claude Code, `AskUserQuestion`; elsewhere, the editor's own question tool), asked once - not repeated in your message. A first session asks only
166
185
  this: which agent (only if several), the app's hostname, which experience
167
186
  first - and a yes wherever this manual asks for one, such as agreeing the
168
187
  experience (below), upgrading, publishing an action, task agent or
@@ -794,6 +813,18 @@ import is right.
794
813
  `Bash(npx foldspace observe:*)` - and meanwhile hand that step to them: for
795
814
  a chat turn, that is them typing the question into the panel. `init`,
796
815
  `upgrade` and `deploy` ask on purpose: never give an allow rule for them.
816
+ - **A sandbox error is not a refusal.** Some editors (Codex) run your commands
817
+ in a sandbox: no network, and no writes outside this folder, unless the
818
+ human approves that one command. There `npm`, `npx foldspace`, `inject` and
819
+ anything that reaches the test window fail with errors that look like
820
+ something else - a host that cannot be resolved, `EPERM` in npm's cache,
821
+ `listen EPERM`, Chrome quitting, or *the test window is closed*. Ask to run
822
+ that same command outside the sandbox through the editor's own approval,
823
+ with one plain line on why; that approval is not a retry. Never move npm's
824
+ cache, change file ownership, or have the human run the command instead.
825
+ An approval can expire when the human sends a new message, so after one,
826
+ the same errors mean ask again - not that the window closed. If the human
827
+ declines, that is a refusal: the rule above applies.
797
828
  - **Never create an account or a key** - not in Foldspace, not in the
798
829
  customer's product, not anywhere. If one is missing, say which and where it
799
830
  goes. **Never widen your own access**: if something is refused for a site or
package/README.md CHANGED
@@ -125,8 +125,16 @@ project loads (`sdkUrl`): each region has its own build, pointing at that
125
125
  region's servers. A project with no `region` is US. The values per region live
126
126
  in `src/region.mjs`.
127
127
 
128
- For safety, `init` requires a target path that does not exist. It does not
129
- initialize Git or overwrite files. On a TTY it can offer
128
+ For safety, `init` sets up a new folder, or fills one that holds nothing but
129
+ what the system or an editor put there (`.DS_Store`, `.localized`, `Icon\r`,
130
+ `._*`, `Thumbs.db`, `desktop.ini`, `.mcp.json`, and the folders `.git/`,
131
+ `.vscode/`, `.cursor/`, `.idea/`, and `.claude/` without `settings.json`). It
132
+ leaves those as they are and refuses anything else, including an existing
133
+ Foldspace project. It never fills the home directory or its Desktop, Documents
134
+ or Downloads, the disk's root, or the folder Claude Code Desktop makes for a
135
+ session started with no folder (under `scratch-workspaces`), or Codex's folder
136
+ for chats with no project (`~/Documents/Codex`) and each chat's folder in it,
137
+ even when they are empty. It never overwrites a file and does not initialize Git. On a TTY it can offer
130
138
  `npm install --ignore-scripts` after scaffolding. After creation:
131
139
 
132
140
  ```bash
@@ -314,6 +322,21 @@ Without attach, `ask` still reports the reply, the card text and a
314
322
  screenshot; `handler.execute` and `handler.render` read "not tracked here
315
323
  (published code)".
316
324
 
325
+ ### Make a short video to share
326
+
327
+ `npx foldspace record` shows the agent's key use cases, from
328
+ `video/scenes.json`: an opening card meets the product's agent, then each
329
+ question is asked in the test window, typed in a new conversation, with a
330
+ caption once it is answered - the Foldspace features it shows as pills, the
331
+ question, and one large line on what the user gets, its payoff highlighted.
332
+ About 40 s, in Foldspace's colours with the official logo:
333
+ `video/<product>-<date>.mp4` and a poster. It records with Chrome's own screencast and encodes in a hidden Google
334
+ Chrome - no ffmpeg, no screen-recording permission, nothing uploaded. The
335
+ video shows real data from the signed-in account; sharing it is the human's
336
+ call, and `video/.gitignore` keeps it out of git. The `share-a-video` skill,
337
+ written into each project's `.claude/skills/`, is the how-to. It runs for a
338
+ few minutes; stopped half-way, it puts the window back as it was.
339
+
317
340
  ### Add the harness to an existing project
318
341
 
319
342
  ```bash
package/bin/ask.mjs CHANGED
@@ -25,7 +25,6 @@ import path from "node:path";
25
25
  import {
26
26
  createSettleTracker,
27
27
  numberFlag,
28
- replyAfter,
29
28
  shouldRetry,
30
29
  squash,
31
30
  summarizeHandlerLog,
@@ -35,9 +34,15 @@ import { ATTACH_DAEMON_LOG_FILE, liveDaemon } from "../src/attach-daemon.mjs";
35
34
  import { CdpPage, parseArgs, printJson, sleep } from "../src/cdp-client.mjs";
36
35
  import { recordEvent } from "../src/session-events.mjs";
37
36
  import { withWorkingBadge } from "../src/badge-auto.mjs";
37
+ import {
38
+ PANEL_DOC,
39
+ openComposer,
40
+ readPanel,
41
+ sendQuestion,
42
+ waitForReply,
43
+ } from "../src/panel-driver.mjs";
38
44
  import { resolveProjectDir } from "../src/upgrade.mjs";
39
45
 
40
- const PANEL_ID = "eucera-agent-iframe";
41
46
  const COMPOSER_WAIT_S = 30;
42
47
  const POLL_MS = 1500;
43
48
 
@@ -81,147 +86,20 @@ function logSince(offset) {
81
86
  }
82
87
  }
83
88
 
84
- // Shared by every page snippet: the panel document, or null while it loads.
85
- const PANEL_DOC = `
86
- const frame = document.getElementById(${JSON.stringify(PANEL_ID)});
87
- let doc = null;
88
- try { doc = frame && frame.contentDocument; } catch {}
89
- `;
90
-
91
- async function panelState() {
92
- return page.evaluate(`${PANEL_DOC}
93
- if (!doc) return { frame: !!frame, composer: false };
94
- const box = doc.querySelector("textarea");
95
- const rect = frame.getBoundingClientRect();
96
- return { frame: true, composer: !!box, visible: rect.width > 50 && rect.height > 50 };`);
97
- }
98
-
99
- // open() is the SDK's public "show the panel" call, the same thing the
100
- // launcher button does. The handle comes from the bootstrap when attach made
101
- // one, else from the page's own SDK.
102
- async function openPanel(agentApiName) {
103
- return page.evaluate(`
104
- const wanted = ${JSON.stringify(agentApiName || "")};
105
- const namespace = typeof window.__FOLD_SPACE__ === "string" && window.__FOLD_SPACE__
106
- ? window.__FOLD_SPACE__ : "foldspace";
107
- const sdk = window[namespace];
108
- let agent = window.__FOLDSPACE_AGENT__ || null;
109
- if (!agent && typeof sdk?.agentIds === "function") {
110
- const ids = sdk.agentIds().filter((id) => typeof id === "string" && id.indexOf("-") > 0);
111
- const id = ids.find((item) => item.slice(item.indexOf("-") + 1) === wanted) || ids[0];
112
- if (id) {
113
- const cut = id.indexOf("-");
114
- agent = sdk.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() });
115
- }
116
- }
117
- if (typeof agent?.open !== "function") return { sdk: !!sdk, opened: false };
118
- try { agent.open(); return { sdk: true, opened: true }; }
119
- catch (error) { return { sdk: true, opened: false, error: String(error?.message || error) }; }`);
120
- }
121
-
122
- async function readPanel(cardsBefore) {
123
- return page.evaluate(`${PANEL_DOC}
124
- if (!doc || !doc.body) return { panel: "", cards: [], cardFrames: 0, newCardFrames: 0 };
125
- const squash = (text) => (text || "").replace(/\\s+/g, " ").trim();
126
- const frames = Array.from(doc.querySelectorAll("iframe"));
127
- const cards = [];
128
- let painted = 0;
129
- // Cards from earlier turns are still in the panel; only the new ones count.
130
- for (const card of frames.slice(${Number(cardsBefore) || 0})) {
131
- const rect = card.getBoundingClientRect();
132
- if (rect.width > 20 && rect.height > 20) painted += 1;
133
- try {
134
- const body = card.contentDocument && card.contentDocument.body;
135
- if (body) cards.push(squash(body.innerText || body.textContent));
136
- } catch {}
137
- }
138
- return {
139
- panel: squash(doc.body.innerText || doc.body.textContent),
140
- cards: cards.filter(Boolean),
141
- cardFrames: frames.length,
142
- newCardFrames: painted,
143
- };`);
144
- }
145
-
146
- // Real key events through the browser's input pipeline, like a person at the
147
- // keyboard. Frameworks that ignore synthetic DOM events still hear these.
148
- async function typeLikeAPerson(text) {
149
- const focused = await page.evaluate(`${PANEL_DOC}
150
- const box = doc && doc.querySelector("textarea");
151
- if (!box) return false;
152
- window.focus(); frame.contentWindow.focus(); box.focus();
153
- return doc.activeElement === box;`);
154
- if (!focused) return false;
155
- await page.send("Input.insertText", { text });
156
- const typed = await page.evaluate(`${PANEL_DOC}
157
- const box = doc && doc.querySelector("textarea");
158
- return box ? box.value : null;`);
159
- if (squash(typed) !== text) return false;
160
- const enter = {
161
- key: "Enter",
162
- code: "Enter",
163
- windowsVirtualKeyCode: 13,
164
- nativeVirtualKeyCode: 13,
165
- };
166
- await page.send("Input.dispatchKeyEvent", { type: "rawKeyDown", ...enter });
167
- await page.send("Input.dispatchKeyEvent", { type: "char", ...enter, text: "\r" });
168
- await page.send("Input.dispatchKeyEvent", { type: "keyUp", ...enter });
169
- return true;
170
- }
171
-
172
- // A background or unfocused tab can swallow CDP input. Same gesture, delivered
173
- // as DOM events on the composer; still only typing, still nothing injected.
174
- async function typeWithDomEvents(text) {
175
- return page.evaluate(`${PANEL_DOC}
176
- const box = doc && doc.querySelector("textarea");
177
- if (!box) return false;
178
- const view = frame.contentWindow;
179
- const setValue = Object.getOwnPropertyDescriptor(view.HTMLTextAreaElement.prototype, "value").set;
180
- box.focus();
181
- setValue.call(box, ${JSON.stringify(text)});
182
- box.dispatchEvent(new view.Event("input", { bubbles: true }));
183
- for (const type of ["keydown", "keypress", "keyup"]) {
184
- box.dispatchEvent(new view.KeyboardEvent(type, {
185
- key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true,
186
- }));
187
- }
188
- return true;`);
189
- }
190
-
191
- async function composerValue() {
192
- return page
193
- .evaluate(`${PANEL_DOC}
194
- const box = doc && doc.querySelector("textarea");
195
- return box ? box.value : "";`)
196
- .catch(() => "");
197
- }
198
-
199
89
  async function askOnce() {
200
- const before = await readPanel(0);
90
+ const before = await readPanel(page, 0);
201
91
  const cardsBefore = before.cardFrames;
202
92
  const logOffset = logSize();
203
93
 
204
- let typedBy = "keyboard";
205
- let sent = await typeLikeAPerson(question).catch(() => false);
206
- await sleep(700);
207
- // Still sitting in the composer means Enter never landed.
208
- if (!sent || squash(await composerValue()) === question) {
209
- typedBy = "dom-events";
210
- sent = await typeWithDomEvents(question).catch(() => false);
211
- }
94
+ const { sent, typedBy } = await sendQuestion(page, question);
212
95
  if (!sent) return { sent: false };
213
96
 
214
- const tracker = createSettleTracker();
215
- const deadline = Date.now() + timeoutS * 1000;
216
- let seen = { panel: "", cards: [], newCardFrames: 0 };
217
- let reply = "";
218
- let settled = false;
219
- while (Date.now() < deadline && !settled) {
220
- await sleep(POLL_MS);
221
- seen = await readPanel(cardsBefore).catch(() => seen);
222
- reply = replyAfter(seen.panel, question);
223
- settled = tracker.observe({ reply, cards: seen.cards }).settled;
224
- }
97
+ const { settled, reply, seen } = await waitForReply(page, question, {
98
+ cardsBefore,
99
+ timeoutS,
100
+ pollMs: POLL_MS,
101
+ tracker: createSettleTracker(),
102
+ });
225
103
  // The render line trails the card by a beat; let attach flush it.
226
104
  await sleep(400);
227
105
  return {
@@ -284,14 +162,7 @@ async function main() {
284
162
 
285
163
  // The composer does not exist until the panel has opened, and one open() is
286
164
  // not always enough, so it is retried inside the poll.
287
- let state = await panelState();
288
- let opened = { sdk: false, opened: false };
289
- for (let tick = 0; tick < COMPOSER_WAIT_S && !(state.composer && state.visible); tick++) {
290
- opened = await openPanel(page.info.target?.agentApiName).catch(() => opened);
291
- await sleep(1000);
292
- state = await panelState().catch(() => state);
293
- if (tick === 5 && !state.frame && !opened.sdk) break;
294
- }
165
+ const { state } = await openComposer(page, page.info.target?.agentApiName, { waitS: COMPOSER_WAIT_S });
295
166
  if (!state.composer) {
296
167
  finish(
297
168
  {
package/bin/attach.mjs CHANGED
@@ -303,6 +303,8 @@ const badgeSrc = `(() => {
303
303
  // one. See the playbook's test-mode rule.
304
304
  const testModeScript = (enable) => `(() => {
305
305
  if (window.top !== window.self) return;
306
+ // What this attach set, for \`foldspace record\` to read (it refuses test mode).
307
+ window.__FOLDSPACE_TEST_MODE__ = ${enable};
306
308
  const AGENT = ${JSON.stringify(agentApiName)};
307
309
  const WANT = ${enable};
308
310
  const WORD = ${JSON.stringify(enable ? "ON" : "OFF")};
package/bin/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import { runInit } from "../src/init.mjs";
8
8
  import { isWriteFlag } from "../src/observe-core.mjs";
9
9
  import {
10
10
  checkForUpdate,
11
+ ensureProjectSkills,
11
12
  gateForCommand,
12
13
  nagIfOutdated,
13
14
  resolveProjectDir,
@@ -41,12 +42,28 @@ function fail(message) {
41
42
  process.exitCode = 1;
42
43
  }
43
44
 
45
+ // A stop sent to this wrapper alone (a timeout, `kill <pid>`) goes on to the
46
+ // command: otherwise the wrapper dies and the command runs on as an orphan,
47
+ // still driving the test window. Ctrl-C reaches both; each command takes a
48
+ // second stop as the same one.
49
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
50
+
44
51
  function runScript(scriptName, args) {
45
52
  const child = spawn(process.execPath, [path.join(here, scriptName), ...args], {
46
53
  stdio: "inherit",
47
54
  env: process.env,
48
55
  });
56
+ const forward = (signal) => {
57
+ if (child.exitCode !== null || child.signalCode !== null) return;
58
+ try {
59
+ child.kill(signal);
60
+ } catch {
61
+ // already gone
62
+ }
63
+ };
64
+ for (const signal of FORWARDED_SIGNALS) process.on(signal, forward);
49
65
  child.on("exit", (code, signal) => {
66
+ for (const name of FORWARDED_SIGNALS) process.removeListener(name, forward);
50
67
  if (signal) {
51
68
  process.kill(process.pid, signal);
52
69
  return;
@@ -152,6 +169,11 @@ function normalizeCommandArgs(command, args) {
152
169
 
153
170
  const [command, ...args] = process.argv.slice(2);
154
171
 
172
+ // The harness's skills, when the project lacks them: the first upgrade to a
173
+ // version that ships one runs the older harness, which cannot write it. Only
174
+ // missing files, and only in a Foldspace project; silent.
175
+ if (command !== "init") ensureProjectSkills(resolveProjectDir());
176
+
155
177
  if (!command || command === "--help" || command === "-h") {
156
178
  printHelp(null, args.includes("--json"));
157
179
  } else if (command === "--version" || command === "-v") {
package/bin/inject.mjs CHANGED
@@ -14,13 +14,13 @@
14
14
  import { recordEvent } from "../src/session-events.mjs";
15
15
  import fs from "fs";
16
16
  import path from "path";
17
- import os from "os";
18
17
  import { spawn } from "child_process";
19
18
  import {
20
19
  ownershipErrorMessage,
21
20
  waitForOwnedCdp,
22
21
  writeLaunchSentinel,
23
22
  } from "../src/cdp-ownership.mjs";
23
+ import { findChrome } from "../src/chrome-binary.mjs";
24
24
 
25
25
  // Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
26
26
  // hosted builder point the harness at a workspace it controls.
@@ -165,19 +165,7 @@ fs.writeFileSync(
165
165
  }, null, 2),
166
166
  );
167
167
 
168
- function findChrome() {
169
- const candidates =
170
- os.platform() === "darwin"
171
- ? [
172
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
173
- "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
174
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
175
- ]
176
- : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium"];
177
- return candidates.find((p) => fs.existsSync(p));
178
- }
179
-
180
- const chrome = process.env.CHROME_PATH || findChrome();
168
+ const chrome = findChrome();
181
169
  if (!chrome) {
182
170
  console.error("\nChrome not found. Set CHROME_PATH=/path/to/chrome and retry.");
183
171
  process.exit(1);