@foldspace_npm/harness 0.1.9 → 0.1.11

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 ADDED
@@ -0,0 +1,235 @@
1
+ # Foldspace Agent
2
+
3
+ ## Harness version
4
+
5
+ At the start of a session, run `npx foldspace help --json` (or, from an older
6
+ install, `npx --yes @foldspace_npm/harness@latest upgrade --check`).
7
+
8
+ If `update.outdated` is true, tell the user the pinned and latest versions and
9
+ **ask before upgrading**. After they agree, run `foldspace upgrade --yes`, then
10
+ `npm run build`. Do not upgrade as a side effect of build, attach, or
11
+ implementing an action. A harness bump is not a deploy.
12
+
13
+ If the tenant `CLAUDE.md` does not contain
14
+ `@node_modules/@foldspace_npm/harness/CLAUDE.md`, ask before
15
+ `foldspace upgrade --refresh-instructions`. That replaces the snapshot with an
16
+ import of this file.
17
+
18
+ ## Before building
19
+
20
+ 1. Run `npx foldspace help --json` before an unfamiliar harness operation.
21
+ Treat its risk, prerequisites, effects, next-step, and `update` fields as
22
+ the current CLI contract.
23
+ 2. Connect Product MCP and verify that `list_agents` works.
24
+ 3. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
25
+ `list_task_agents`.
26
+ 4. Use `discover_actions` to identify candidate experiences, then let the user
27
+ choose what to build. Treat those ideas as candidates, not an inventory.
28
+
29
+ ## Build workflow
30
+
31
+ An experience can require several reusable actions. Separate lookup actions
32
+ from actions that read or mutate a selected resource.
33
+
34
+ 1. Agree on the candidate experience and how its actions would compose. This is
35
+ not a publish and not a commitment.
36
+ 2. Run `npm run inject`. Ask the user to sign in and perform the real target
37
+ workflow in that Chrome window.
38
+ 3. Capture at least one real HTTP 200 for the data the action needs. Use
39
+ chrome-devtools MCP against the inject Chrome, or a page-context `fetch`,
40
+ **before** `attach` owns the debug port. If there is no 200,
41
+ pivot; do not create Foldspace resources. Write what you establish into
42
+ `docs/app-profile.md` with **how you know it**. Do not promote an assumption
43
+ by quoting that file.
44
+ 4. Create action metadata as a draft. `generate_action_handler` works from a
45
+ draft schema; do not publish yet.
46
+ 5. Implement the handler using the observed request and response shapes.
47
+ Import helpers from `agent/utils.ts` — inspect that file before writing
48
+ another `fetch` or rank helper.
49
+ 6. Register the handler in `agent/actions/index.ts` and build (`foldspace build` lints first).
50
+ 7. Run `npx foldspace attach --daemon` (add `--bootstrap` or `--replace` when
51
+ the page requires it). An empty local registry is valid if you only want to
52
+ see how the agent works.
53
+ 8. Ask before publishing. Publishing is required only so the copilot can call
54
+ the action, and it is a live product change when the agent has real users.
55
+ 9. Complete the verification gates below.
56
+
57
+ ## API rules
58
+
59
+ Actions execute in the user's signed-in browser session.
60
+
61
+ - Import HTTP, ranking, and widget helpers from `../utils`, not a new
62
+ `agent/api.ts` copy of `fetch`.
63
+ - Do not guess `API_BASE` or `AUTH_SOURCE`. Capture at least one real 200
64
+ (and the auth header the page actually sends) before filling them in.
65
+ `credentials: "include"` is correct only when you have observed the app
66
+ using cookies that way. Many apps use `Authorization: Bearer` from
67
+ `localStorage` instead; some use a custom header.
68
+ - Never guess endpoints or schemas. Capture at least one real 200 before
69
+ implementing a parser. Do not implement a path that was not observed.
70
+ - Verify that the user is signed in before observing a workflow.
71
+ - Do not substitute a public developer API when the browser session is missing;
72
+ ask the user to sign in.
73
+ - Validate parameters and return sanitized errors. Return **data only** — never
74
+ `directive`, `instructions`, or a paragraph telling the copilot what to say.
75
+ Action and agent instructions live in Agent Studio / MCP. Never return
76
+ `ApiResult.detail` from `execute` — it is for the console.
77
+ - Actions that return data the user will inspect should include a `render`
78
+ function for in-chat UI (a chatterblock). If it makes more sense to output the data
79
+ in a UI component instead of text then consider using render to show a component.
80
+ - `render` receives **`execute`'s return value**, not the action's input params.
81
+ Returning anything that lacks the ids the card needs is why widgets pass in
82
+ isolation and fail in the real chat.
83
+ - `runAction` refuses render actions (`cannot be executed silently`). Driving
84
+ `render()` yourself never runs `execute()`, so it does not test that contract.
85
+ - Check https://docs.foldspace.ai/guides/in-chat-ui/ for more information
86
+
87
+ ## Task agents
88
+
89
+ Use a Task Agent when the handler needs a one-time LLM subtask that
90
+ deterministic code cannot do well: extraction, summarization,
91
+ classification, normalization, enrichment, or generation.
92
+
93
+ Do not use a Task Agent for API calls, CRUD, routing, or parsing a
94
+ known response shape. Those stay in `execute`.
95
+
96
+ Task agents are created in Agent Studio, not in this repo. Ask before
97
+ creating or publishing one. Call a published task agent from the
98
+ handler with `runTask({ taskKey, data })` (if not published the runTask won't work).
99
+ `data` carries extracted facts only — not `prompt` / `instructions` strings.
100
+ Prefer JSON output when the handler must consume the result.
101
+
102
+ See https://docs.foldspace.ai/user-guides/task-agents/ and
103
+ https://docs.foldspace.ai/reference/task-agent-api/
104
+
105
+ ## Local harness loop
106
+
107
+ ```bash
108
+ npm run dev
109
+ npm run inject
110
+ npx foldspace attach --daemon
111
+ ```
112
+
113
+ `inject` launches an isolated Chrome profile and records its debug port.
114
+ It does not generate or load an application extension. Observe the customer's
115
+ workflow after inject and **before** attach, while chrome-devtools MCP can use
116
+ the same Chrome. `attach` prepares the page and loads the local `dist/index.js`
117
+ bundle through CDP. Coding agents must use `--daemon` so the invoking tool
118
+ returns after `[lifecycle] inspect_registration:…`; foreground `npm run attach`
119
+ is for humans watching the terminal. An empty local registry is valid. `npm run
120
+ build` is still required so `dist/index.js` exists.
121
+
122
+ Use the default swap only when the page already has the configured product and
123
+ agent. Use `--bootstrap` only when the page has no Foldspace SDK, and
124
+ `--replace` when it embeds a different product or agent, or when an SDK is
125
+ present without the configured agent.
126
+
127
+ The attach log must report `inspect_registration:registration_ok` before
128
+ treating the page as registered. Zero captured actions is success when the
129
+ local registry is empty. On `registration_mismatch`, read
130
+ `npx foldspace help attach --json` diagnostics and map those names onto the
131
+ lifecycle details (`missingActionNames`, `unexpectedActionNames`,
132
+ `diagnosticError`).
133
+
134
+ While attach is running it owns the debug port. Detach (`attach --stop`, or
135
+ Ctrl-C in the foreground) before using chrome-devtools MCP against the same
136
+ Chrome.
137
+
138
+ Prove a named action through the visible agent. After the user talks to the
139
+ copilot, read the daemon log for `[actions]` SDK callback and local
140
+ execute/render lines; do not invoke the handler directly. Those lines record
141
+ names, statuses, durations, and parameter keys only — not results or error
142
+ bodies.
143
+
144
+ ## Product defaults
145
+
146
+ These are not product-specific. Lint can catch some of them in handler code;
147
+ Agent Studio copy is on you.
148
+
149
+ - **No emoji** in copilot replies, cards, or handler strings. Put that in the
150
+ agent's Behavior instructions (MCP cannot write that field, so each new
151
+ action's Studio `instructions` must carry the line too).
152
+ - **Never send the user out of the host app.** No "open in <product>"
153
+ button and no pasted URLs — navigate with a navigation route, same tab.
154
+ - **Check row counts before choosing the experience.** On an empty account the
155
+ useful first action is one that creates data.
156
+
157
+ ## Verification gates
158
+
159
+ Do not report success without all six:
160
+
161
+ 1. TypeScript compiles with `npm run typecheck` (`tsc --noEmit -p tsconfig.json`).
162
+ Do not run `npx tsc` — that can install the wrong package.
163
+ 2. `foldspace lint` reports no errors (`foldspace build` runs this first).
164
+ 3. The expected handler appears in `dist/index.js`.
165
+ 4. The browser reports `inspect_registration:registration_ok`. For a named
166
+ action, the captured registry includes that handler.
167
+ 5. The action behaves correctly against the real target workflow. Confirm
168
+ `[actions] local-handler:execute` in the attach log after the user exercises
169
+ the visible agent. For a widget, that log must include `local-handler:render`
170
+ from a real chat turn — not a hand-built `render()` call.
171
+ 6. Browser evidence came from the live target, not from hand-authored examples.
172
+ Neighbour fixtures still pass when they exist.
173
+
174
+ ## Layout
175
+
176
+ - `agent/actions/` — one handler per action (`execute`, optional `render`),
177
+ registered in `index.ts`
178
+ - `agent/api/` — one HTTP helper per endpoint
179
+ - `agent/constants.ts` — agent, product, domain, plus empty `API_BASE` /
180
+ `AUTH_SOURCE` and `LOAD_MODE`
181
+ - `agent/utils.ts` — configure the harness runtime and re-export it. **This is
182
+ the only import surface for actions.**
183
+ - `foldspace.dev.json` — local harness target configuration
184
+ - `docs/app-profile.md` — what is known about this app, and how it was established
185
+ - `CLAUDE.md` — this file, imported — plus what is specific to this product
186
+
187
+ Do not introduce another bundler or bundle format.
188
+
189
+ ## What you already have
190
+
191
+ Import from `../utils`. Inspect that file before implementing another
192
+ general-purpose helper. MCP `generate_action_handler` may still emit a
193
+ skeleton that does not import it — fix the import when you implement.
194
+
195
+ | Helper | Use when | Do not use when |
196
+ |---|---|---|
197
+ | `apiFetch` / `apiFetchBinary` | The app's own JSON/file API, after a real 200 | Public marketing hosts (`publicFetch`); custom auth headers |
198
+ | `publicFetch` | Unauthenticated / marketing origin | Signed-in product APIs |
199
+ | `getAuthToken` / `parseJwt` | `AUTH_SOURCE` is `localStorage` or `cookie` | Custom header schemes — override `apiFetch` in `utils.ts` |
200
+ | `rankBy` | User typed a name; API search is exact or ignored | Domain ranking (invoices, coverage, MasterFormat) |
201
+ | `renderLoading` / `Empty` / `Error` / `Fatal` | Chatterblock empty/error paths | Branded cards — those stay in `agent/views/` |
202
+ | `getAgent` | Talking to Foldspace | You need a specific instance — then `agentIds()` |
203
+ | `armAllInstances` | Attach/bootstrap setup | Inside `execute` (can ship into `dist`; attach already arms test mode) |
204
+ | `redact` / `mapWithConcurrency` | Logging tokens; batching fetches | — |
205
+
206
+ If the observed auth is not Bearer + `localStorage`/`cookie`, stop re-exporting
207
+ that one function and keep the rest:
208
+
209
+ ```ts
210
+ export { getAgent, rankBy, renderEmpty } from "@foldspace_npm/harness/runtime";
211
+ export { apiFetch } from "./api";
212
+ ```
213
+
214
+ ## Agent learnings
215
+
216
+ `docs/app-profile.md` is the durable record of this app. Fill it as you probe,
217
+ not afterwards. Other notes in `docs/` are fine for session-specific lessons.
218
+
219
+ - Before similar work, read `docs/app-profile.md` and any other notes in `docs/`.
220
+ - After non-obvious discoveries, add them to the profile (or a short extra
221
+ note) covering verified gotchas, failed approaches, and how they were
222
+ established.
223
+ - Keep notes concise and evidence-based.
224
+ - Do not store secrets, cookies, HAR files, browser storage, or other transient
225
+ session data in `docs/`.
226
+
227
+ ## Safety
228
+
229
+ - Do not commit secrets, cookies, HAR files, browser storage, or
230
+ `.foldspace-dev/`.
231
+ - Local handler changes are not cloud publication.
232
+ - Creating or publishing Foldspace resources requires explicit approval.
233
+ - Action keys and parameter names must match the published schema.
234
+ - Search Foldspace documentation before asserting unfamiliar platform
235
+ behaviour.
package/README.md CHANGED
@@ -97,7 +97,7 @@ Non-interactive / CI form:
97
97
  npx --yes @foldspace_npm/harness init foldspace-agent \
98
98
  --product-id FR8JUQZAQRZB \
99
99
  --agent-key my-agent \
100
- --domain app.example.com \
100
+ --app-domain app.example.com \
101
101
  --name "My Agent"
102
102
  ```
103
103
 
@@ -107,14 +107,15 @@ When running directly from a harness checkout during development:
107
107
  node bin/cli.mjs init ../foldspace-agent \
108
108
  --product-id FR8JUQZAQRZB \
109
109
  --agent-key my-agent \
110
- --domain app.example.com
110
+ --app-domain app.example.com
111
111
  ```
112
112
 
113
113
  The Agent Key is the value shown in Agent Studio, such as `my-agent`. It is
114
114
  not the sidecar directory name. `--agent-api-name` remains a deprecated alias
115
- for `--agent-key`. `--name` is optional and defaults to the target directory
115
+ for `--agent-key`. `--domain` remains a deprecated alias for `--app-domain`.
116
+ `--name` is optional and defaults to the target directory
116
117
  name (`foldspace-agent` unless you pass a directory). The product ID must be
117
- the bare ID, not the `EU-…-1-1` SDK loader key. The domain may be a hostname
118
+ the bare ID, not the `EU-…-1-1` SDK loader key. The app domain may be a hostname
118
119
  or an HTTP(S) URL without a port or path.
119
120
 
120
121
  For safety, `init` requires a target path that does not exist. It does not
@@ -132,6 +133,24 @@ npm run attach
132
133
  The generated npm scripts intentionally remain the normal project interface;
133
134
  `foldspace init` is the one-time project creation command.
134
135
 
136
+ Generated `CLAUDE.md` imports platform instructions from
137
+ `@foldspace_npm/harness` instead of copying them. Existing projects can switch
138
+ to that import with `foldspace upgrade --refresh-instructions`.
139
+
140
+ ### Keep the harness current
141
+
142
+ Tenant projects pin an exact `@foldspace_npm/harness` version. Publishing a
143
+ newer package does not move them. Check, ask the user, then bump:
144
+
145
+ ```bash
146
+ npx --yes @foldspace_npm/harness@latest upgrade --check
147
+ foldspace upgrade --yes # only after the user agrees
148
+ ```
149
+
150
+ `--check` (and a non-interactive `upgrade` with no `--yes`) never installs.
151
+ `--yes` rewrites the exact pin and runs `npm install --ignore-scripts`. Rebuild
152
+ afterward; that is still not a deploy.
153
+
135
154
  ### Lint handlers before they ship
136
155
 
137
156
  `foldspace build` runs `foldspace lint` first. Errors skip bundling; warnings
package/bin/attach.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  hostPatternsFromTarget,
32
32
  parseAgentId,
33
33
  registrationVerified,
34
+ resolveAttachLaunch,
34
35
  } from "../src/attach-helpers.mjs";
35
36
  import {
36
37
  buildReplacePrelude,
@@ -47,6 +48,12 @@ import {
47
48
  createLifecycleResult,
48
49
  } from "../src/protocol.mjs";
49
50
  import { createCdpRequestManager } from "../src/cdp-request-manager.mjs";
51
+ import {
52
+ assertOwnedCdp,
53
+ chromeProfileDir,
54
+ ownershipErrorMessage,
55
+ verifyLaunchSentinel,
56
+ } from "../src/cdp-ownership.mjs";
50
57
  import { buildActionObserverScript } from "../src/action-observer.mjs";
51
58
  import { buildBootstrapScript } from "../src/bootstrap-script.mjs";
52
59
  import {
@@ -61,8 +68,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
61
68
  const root = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
62
69
  const bundlePath = path.join(root, "dist", "index.js");
63
70
 
64
- // Prefer what inject actually launched, then an explicit override, then the
65
- // legacy default. Guessing here means attaching to the wrong browser.
71
+ // Prefer an explicit override, then the port inject recorded. Do not guess
72
+ // 9222 that is how we attach to a different browser than the one we launched.
66
73
  function readDevState() {
67
74
  try {
68
75
  return JSON.parse(
@@ -82,18 +89,42 @@ const explicitPort =
82
89
  portArgIndex > -1 ? process.argv[portArgIndex + 1] : null;
83
90
  const explicitTarget =
84
91
  targetArgIndex > -1 ? process.argv[targetArgIndex + 1] : null;
85
- const savedLaunchMatches =
86
- !explicitTarget || explicitTarget === devState.target;
87
- const useSavedLaunch =
88
- !explicitPort &&
89
- !process.env.CDP_PORT &&
90
- savedLaunchMatches;
91
- const port = String(
92
- explicitPort ||
93
- process.env.CDP_PORT ||
94
- (useSavedLaunch ? devState.debugPort : null) ||
95
- "9222",
96
- );
92
+ const { useSavedLaunch, port } = resolveAttachLaunch({
93
+ explicitPort,
94
+ envPort: process.env.CDP_PORT,
95
+ explicitTarget,
96
+ savedTarget: devState.target,
97
+ savedDebugPort: devState.debugPort,
98
+ });
99
+ if (!port) {
100
+ console.error(
101
+ `attach: no CDP port. Run "foldspace inject" first, or pass --port / CDP_PORT.\n` +
102
+ ` Refusing to guess 9222 — that port is often someone else's Chrome.`,
103
+ );
104
+ process.exit(1);
105
+ }
106
+ const profileDir = chromeProfileDir(root);
107
+ const claimed = verifyLaunchSentinel({
108
+ profileDir,
109
+ token: devState.sentinel,
110
+ });
111
+ if (!claimed.ok) {
112
+ console.error(
113
+ `attach: ${ownershipErrorMessage(claimed.reason, { port, profileDir, root })}`,
114
+ );
115
+ process.exit(1);
116
+ }
117
+ const owned = await assertOwnedCdp({
118
+ profileDir,
119
+ port,
120
+ token: devState.sentinel,
121
+ });
122
+ if (!owned.ok) {
123
+ console.error(
124
+ `attach: ${ownershipErrorMessage(owned.reason, { port, profileDir, root })}`,
125
+ );
126
+ process.exit(1);
127
+ }
97
128
  let attachMode;
98
129
  try {
99
130
  attachMode = attachModeFromArgs(process.argv.slice(2));
@@ -883,8 +914,8 @@ let ws;
883
914
  // temporal dead zone, throwing instead of reconnecting.
884
915
  const keepAlive = setInterval(() => {}, 1 << 30);
885
916
 
886
- async function connect() {
887
- const ver = await (await fetch(`http://localhost:${port}/json/version`)).json();
917
+ async function connect(version) {
918
+ const ver = version;
888
919
  const socket = new WebSocket(ver.webSocketDebuggerUrl);
889
920
  await new Promise((resolve, reject) => {
890
921
  socket.onopen = resolve;
@@ -912,7 +943,7 @@ async function connect() {
912
943
  return ver;
913
944
  }
914
945
 
915
- const ver = await connect();
946
+ const ver = await connect(owned.version);
916
947
 
917
948
  console.log(`Attached to ${ver.Browser} on :${port}`);
918
949
  console.log(`Mode: ${attachMode}`);
package/bin/cli.mjs CHANGED
@@ -5,6 +5,12 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runInit } from "../src/init.mjs";
8
+ import {
9
+ checkForUpdate,
10
+ nagIfOutdated,
11
+ resolveProjectDir,
12
+ runUpgrade,
13
+ } from "../src/upgrade.mjs";
8
14
  import {
9
15
  classifyAttachControl,
10
16
  runAttachControl,
@@ -56,10 +62,13 @@ function printHelp(topic, json) {
56
62
  );
57
63
  return;
58
64
  }
65
+ const update = checkForUpdate(resolveProjectDir());
59
66
  if (json) {
60
- console.log(JSON.stringify(document, null, 2));
67
+ const payload = topic ? document : { ...document, update };
68
+ console.log(JSON.stringify(payload, null, 2));
61
69
  return;
62
70
  }
71
+ nagIfOutdated(update);
63
72
  console.log(
64
73
  topic ? renderCommandHelp(registry, topic) : renderGeneralHelp(registry),
65
74
  );
@@ -118,6 +127,7 @@ const [command, ...args] = process.argv.slice(2);
118
127
  if (!command || command === "--help" || command === "-h") {
119
128
  printHelp(null, args.includes("--json"));
120
129
  } else if (command === "--version" || command === "-v") {
130
+ nagIfOutdated(checkForUpdate(resolveProjectDir()));
121
131
  console.log(
122
132
  `${registry.package.name} ${registry.package.version} ` +
123
133
  `(protocol ${registry.protocolVersion}, CLI schema ${registry.schemaVersion})`,
@@ -149,6 +159,24 @@ if (!command || command === "--help" || command === "-h") {
149
159
  fail(error instanceof Error ? error.message : String(error));
150
160
  }
151
161
  }
162
+ } else if (command === "upgrade") {
163
+ if (args.includes("--help") || args.includes("-h")) {
164
+ printHelp("upgrade", args.includes("--json"));
165
+ } else {
166
+ try {
167
+ const normalized = normalizeCommandArgs(commandByName("upgrade"), args);
168
+ Promise.resolve()
169
+ .then(() => runUpgrade(normalized))
170
+ .then((report) => {
171
+ console.log(JSON.stringify(report, null, 2));
172
+ })
173
+ .catch((error) => {
174
+ fail(error instanceof Error ? error.message : String(error));
175
+ });
176
+ } catch (error) {
177
+ fail(error instanceof Error ? error.message : String(error));
178
+ }
179
+ }
152
180
  } else if (commandByName(command)) {
153
181
  if (args.includes("--help") || args.includes("-h")) {
154
182
  printHelp(command, args.includes("--json"));
package/bin/inject.mjs CHANGED
@@ -15,6 +15,11 @@ import fs from "fs";
15
15
  import path from "path";
16
16
  import os from "os";
17
17
  import { spawn } from "child_process";
18
+ import {
19
+ ownershipErrorMessage,
20
+ waitForOwnedCdp,
21
+ writeLaunchSentinel,
22
+ } from "../src/cdp-ownership.mjs";
18
23
 
19
24
  // Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
20
25
  // hosted builder point the harness at a workspace it controls.
@@ -180,26 +185,7 @@ if (!chrome) {
180
185
  fs.mkdirSync(profileDir, { recursive: true });
181
186
 
182
187
  const debugPort = args.port || "9222";
183
-
184
- // Record it so attach does not have to be told again. inject and attach
185
- // disagreeing about the port is how you end up attached to a different
186
- // browser than the one you launched.
187
- fs.mkdirSync(workDir, { recursive: true });
188
- fs.writeFileSync(
189
- path.join(workDir, "state.json"),
190
- JSON.stringify(
191
- {
192
- debugPort,
193
- target: targetName,
194
- resolvedTarget: {
195
- ...target,
196
- sdkUrl: cfg.sdkUrl,
197
- },
198
- },
199
- null,
200
- 2,
201
- ),
202
- );
188
+ const sentinel = writeLaunchSentinel(profileDir);
203
189
 
204
190
  // Name the profile so this window is identifiable among other Chrome windows.
205
191
  function nameProfile() {
@@ -244,6 +230,7 @@ const chromeArgs = [
244
230
  "--no-first-run",
245
231
  "--no-default-browser-check",
246
232
  "--test-type",
233
+ "--remote-allow-origins=*",
247
234
  startUrl,
248
235
  ];
249
236
 
@@ -253,6 +240,45 @@ console.log("Log in to the app once — the profile persists between runs.\n");
253
240
  const child = spawn(chrome, chromeArgs, { detached: true, stdio: "ignore" });
254
241
  child.unref();
255
242
 
243
+ const owned = await waitForOwnedCdp({
244
+ profileDir,
245
+ port: debugPort,
246
+ token: sentinel,
247
+ });
248
+ if (!owned.ok) {
249
+ try {
250
+ if (child.pid) process.kill(child.pid, "SIGTERM");
251
+ } catch {
252
+ // The isolated window may still be open without a debug port.
253
+ }
254
+ console.error(`inject: ${ownershipErrorMessage(owned.reason, {
255
+ port: debugPort,
256
+ profileDir,
257
+ root,
258
+ })}`);
259
+ process.exit(1);
260
+ }
261
+
262
+ // Record only after the listener is provably this profile. Writing the port
263
+ // first is how attach adopted someone else's Chrome on :9222.
264
+ fs.mkdirSync(workDir, { recursive: true });
265
+ fs.writeFileSync(
266
+ path.join(workDir, "state.json"),
267
+ JSON.stringify(
268
+ {
269
+ debugPort,
270
+ sentinel,
271
+ target: targetName,
272
+ resolvedTarget: {
273
+ ...target,
274
+ sdkUrl: cfg.sdkUrl,
275
+ },
276
+ },
277
+ null,
278
+ 2,
279
+ ),
280
+ );
281
+
256
282
  // --- theme -------------------------------------------------------------
257
283
  //
258
284
  // Chrome 151 ignores --load-extension, so load the optional cosmetic theme
@@ -260,34 +286,24 @@ child.unref();
260
286
  //
261
287
  // The CDP Extensions domain still works (that is what
262
288
  // --enable-unsafe-extension-debugging is for), so load the theme that way.
263
- // Only the theme: the SDK and actions arrive over CDP from attach.mjs, and the
264
- // dev extension is not required for them.
265
- async function loadTheme() {
266
- const endpoint = `http://127.0.0.1:${debugPort}/json/version`;
267
- for (let i = 0; i < 40; i++) {
268
- try {
269
- const version = await (await fetch(endpoint)).json();
270
- const ws = new WebSocket(version.webSocketDebuggerUrl);
271
- await new Promise((resolve, reject) => {
272
- ws.addEventListener("open", resolve);
273
- ws.addEventListener("error", reject);
274
- });
275
- const done = new Promise((resolve) => {
276
- ws.addEventListener("message", (event) => {
277
- const message = JSON.parse(event.data);
278
- if (message.id === 1) resolve(message);
279
- });
280
- });
281
- ws.send(JSON.stringify({ id: 1, method: "Extensions.loadUnpacked", params: { path: themeDir } }));
282
- const result = await done;
283
- ws.close();
284
- if (result.error) console.log(` theme not applied: ${result.error.message}`);
285
- return;
286
- } catch {
287
- await new Promise((resolve) => setTimeout(resolve, 500));
288
- }
289
- }
290
- console.log(" theme not applied: Chrome did not expose CDP in time");
289
+ // Only the theme: the SDK and actions arrive over CDP from attach.mjs, and
290
+ // the dev extension is not required for them.
291
+ async function loadTheme(version) {
292
+ const ws = new WebSocket(version.webSocketDebuggerUrl);
293
+ await new Promise((resolve, reject) => {
294
+ ws.addEventListener("open", resolve);
295
+ ws.addEventListener("error", reject);
296
+ });
297
+ const done = new Promise((resolve) => {
298
+ ws.addEventListener("message", (event) => {
299
+ const message = JSON.parse(event.data);
300
+ if (message.id === 1) resolve(message);
301
+ });
302
+ });
303
+ ws.send(JSON.stringify({ id: 1, method: "Extensions.loadUnpacked", params: { path: themeDir } }));
304
+ const result = await done;
305
+ ws.close();
306
+ if (result.error) console.log(` theme not applied: ${result.error.message}`);
291
307
  }
292
308
 
293
- await loadTheme();
309
+ await loadTheme(owned.version);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  "bin",
22
22
  "src",
23
23
  "templates",
24
- "README.md"
24
+ "README.md",
25
+ "CLAUDE.md"
25
26
  ],
26
27
  "dependencies": {
27
28
  "esbuild": "^0.20.0",
@@ -15,6 +15,38 @@ export function attachModeFromArgs(argv) {
15
15
  return ATTACH_MODES.SWAP;
16
16
  }
17
17
 
18
+ function present(value) {
19
+ if (value == null) return null;
20
+ const text = String(value);
21
+ return text ? text : null;
22
+ }
23
+
24
+ /**
25
+ * Resolve the Chrome debug port attach should connect to.
26
+ *
27
+ * An explicit --port or CDP_PORT always wins. Otherwise the port inject
28
+ * recorded is used only when the recorded target still matches. There is no
29
+ * fallback to 9222: that is Chrome's conventional debugging port, and guessing
30
+ * it attaches to whoever is listening — often not the profile we launched.
31
+ */
32
+ export function resolveAttachLaunch({
33
+ explicitPort = null,
34
+ envPort = null,
35
+ explicitTarget = null,
36
+ savedTarget = null,
37
+ savedDebugPort = null,
38
+ } = {}) {
39
+ const portOverride = present(explicitPort);
40
+ const envOverride = present(envPort);
41
+ const savedPort = present(savedDebugPort);
42
+ const useSavedLaunch =
43
+ !portOverride &&
44
+ !envOverride &&
45
+ (!explicitTarget || explicitTarget === savedTarget);
46
+ const port = portOverride || envOverride || (useSavedLaunch ? savedPort : null);
47
+ return { useSavedLaunch, port };
48
+ }
49
+
18
50
  export function hostPatternsFromMatches(matches) {
19
51
  return matches.map((match) =>
20
52
  match.replace("*://", "").replace("/*", ""),