@foldspace_npm/harness 0.1.0

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/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # @foldspace_npm/harness
2
+
3
+ Build, inject and verify Foldspace agent experiences against a live app.
4
+
5
+ **This package is the part that is the same in both architectures.** Everything
6
+ else about how a builder runs differs between them; this does not.
7
+
8
+ ---
9
+
10
+ ## The two architectures
11
+
12
+ | | **A — local** *(today)* | **B — remote** *(designed, unbuilt)* |
13
+ |---|---|---|
14
+ | Who runs the agent | Claude Code on the builder's desktop | Claude Agent SDK in GCP |
15
+ | Whose browser | an isolated Chrome the harness launches | the user's **own** Chrome |
16
+ | How code reaches the page | CDP injection | Web Store extension injects a `<script src>` from the CDN |
17
+ | Prerequisites | Claude Code, a client repo, Node, an isolated Chrome profile | the extension installed |
18
+ | Session and cookies | in a profile the harness creates | the user's own, untouched |
19
+ | Code and history | a git repo on the builder's machine | a workspace in the hosted environment |
20
+
21
+ "Export to repo" moves a build from B to A — **and it is only a copy, not a
22
+ rewrite, while `agent/` is identical in both.** Keeping it identical is the
23
+ constraint this package exists to protect.
24
+
25
+ ### What is genuinely shared
26
+
27
+ - `agent/actions/*` and `agent/api/*` — a handler is `fetch` plus `runTask`,
28
+ nothing more. The actions built for Figma run unmodified in either track.
29
+ - `foldspace-build` — esbuild → `dist/index.js`
30
+ - `foldspace-deploy` — publish to `agent/actions/<env>/<productId>/<agentApiName>`
31
+ - fixtures and tests
32
+ - **the verb interface** below
33
+
34
+ ### What differs — only the transport
35
+
36
+ | Verb | A (CDP) | B (extension) |
37
+ |---|---|---|
38
+ | `loadBundle` | `addScriptToEvaluateOnNewDocument` | `<script src>` at the deployed CDN path |
39
+ | `evaluate` | `Runtime.evaluate` | content-script message |
40
+ | `screenshot` | `Page.captureScreenshot` | `tabs.captureVisibleTab` |
41
+ | `relaxCSP` | `Page.setBypassCSP` | `declarativeNetRequest` header rules |
42
+ | `navigate` | `Page.navigate` | `tabs.update` |
43
+
44
+ Detail and status: [`src/transports/README.md`](src/transports/README.md).
45
+
46
+ ### Why one package rather than two
47
+
48
+ Splitting `harness-local` and `harness-remote` forks `build` and `deploy` — the
49
+ exact failure this package was created to end. And the verb interface only stays
50
+ honest while both implementations sit behind it; separate them and "the same
51
+ action runs in both modes" quietly stops being true.
52
+
53
+ A hosted container installs this and never calls `inject`. That is kilobytes of
54
+ dead weight, against a contract that cannot drift.
55
+
56
+ ---
57
+
58
+ ## Use
59
+
60
+ ### Create a project
61
+
62
+ Create a configured actions project from the template bundled with the installed
63
+ harness version:
64
+
65
+ ```bash
66
+ npm exec --package=@foldspace_npm/harness -- foldspace init my-agent \
67
+ --product-id FR8JUQZAQRZB \
68
+ --agent-api-name my-agent \
69
+ --domain app.example.com \
70
+ --name "My Agent"
71
+ ```
72
+
73
+ When running directly from a harness checkout during development:
74
+
75
+ ```bash
76
+ node bin/cli.mjs init ../my-agent \
77
+ --product-id FR8JUQZAQRZB \
78
+ --agent-api-name my-agent \
79
+ --domain app.example.com
80
+ ```
81
+
82
+ Until `@foldspace_npm/harness` is published, install the local checkout in the
83
+ generated project instead of running the standard install step:
84
+
85
+ ```bash
86
+ cd ../my-agent
87
+ npm install --ignore-scripts --save-dev /absolute/path/to/harness
88
+ ```
89
+
90
+ `--name` is optional and defaults to the target directory name. The product ID
91
+ must be the bare ID, not an `EU-…` SDK key. The domain may be a hostname or an
92
+ HTTP(S) URL without a port or path.
93
+
94
+ For safety, `init` requires a target path that does not exist. It does not
95
+ install dependencies, initialize Git, or overwrite files. After creation:
96
+
97
+ ```bash
98
+ cd my-agent
99
+ npm install --ignore-scripts
100
+ npm run build
101
+ npm run inject
102
+ npm run attach
103
+ ```
104
+
105
+ The generated npm scripts intentionally remain the normal project interface;
106
+ `foldspace init` is the one-time project creation command.
107
+
108
+ ### Add the harness to an existing project
109
+
110
+ ```bash
111
+ npm i -D @foldspace_npm/harness
112
+ ```
113
+
114
+ ```json
115
+ { "scripts": {
116
+ "dev": "foldspace-build --watch",
117
+ "build": "foldspace-build",
118
+ "inject": "foldspace-inject",
119
+ "attach": "foldspace-attach",
120
+ "deploy": "foldspace-deploy"
121
+ } }
122
+ ```
123
+
124
+ | Command | A | B |
125
+ |---|:--:|:--:|
126
+ | `foldspace init` | ✅ | ✅ |
127
+ | `foldspace-build` | ✅ | ✅ |
128
+ | `foldspace-deploy` | ✅ | ✅ — it *is* the delivery mechanism |
129
+ | `foldspace-inject` | ✅ | — no browser to launch |
130
+ | `foldspace-attach` | ✅ | replaced by the extension bridge |
131
+
132
+ Every command resolves the **consuming** repo — `process.cwd()`, or
133
+ `FOLDSPACE_PROJECT_DIR` so a hosted builder can point it at a workspace it
134
+ controls. Config comes from the consumer's `foldspace.dev.json`.
135
+
136
+ ---
137
+
138
+ ## Why this package exists at all
139
+
140
+ `scripts/` used to be copied into every client repo, and copies fork. By
141
+ 2026-08-25 `client-template` held one fix and a client repo held four others,
142
+ and **neither had all five**:
143
+
144
+ | Fix | Why it matters |
145
+ |---|---|
146
+ | SDK load guard + retry | On an app with no `document.body` at document-start the append throws; the old guard tested for the stub, so nothing retried and the agent silently never loaded |
147
+ | Worker release on auto-attach | Auto-attach pauses every worker until the attaching client releases it — the harness froze the app's own workers for the whole session |
148
+ | `agentIds` enumeration | `agent({apiName})` returns the OVERLAY handle; arming it on an embedded copilot leaves real conversations untagged |
149
+ | `--no-test-mode` | Test mode was armed unconditionally, hiding exactly what initial setup needs to see |
150
+ | Honest badge | It asserted `TEST MODE` whether or not test mode was on |
151
+
152
+ Two further changes came from packaging it: the bundle is **injected** rather
153
+ than fetched from a dev server — which removed `express` and `concurrently`, 95
154
+ packages, and is the precondition for driving a browser that cannot reach the
155
+ build machine — and `inject` now records the port that `attach` reads, since
156
+ they previously disagreed by construction.
package/bin/attach.mjs ADDED
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Attach to the Chrome launched by inject.mjs and serve local actions to it.
4
+ *
5
+ * Two modes, picked automatically:
6
+ *
7
+ * swap (default) The client app already embeds Foldspace. We
8
+ * intercept the remote-actions bundle request and fulfill it
9
+ * with dist/index.js from disk. This is the CDP equivalent of
10
+ * the production extension's redirect rule, and it reuses the
11
+ * page's own agent instead of creating a second one.
12
+ *
13
+ * --bootstrap The app has no Foldspace. Inject the SDK bootstrap from the
14
+ * generated extension/index.js before page scripts run.
15
+ *
16
+ * Chrome 136+ removed --load-extension and does not run content scripts for
17
+ * CDP-loaded extensions, so neither path uses the extension at runtime.
18
+ */
19
+ import fs from "fs";
20
+ import path from "path";
21
+ import { fileURLToPath } from "url";
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ // Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
25
+ // hosted builder point the harness at a workspace it controls.
26
+ const root = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
27
+ const extDir = path.join(root, ".foldspace-dev", "extension");
28
+ const bundlePath = path.join(root, "dist", "index.js");
29
+
30
+ // Prefer what inject actually launched, then an explicit override, then the
31
+ // legacy default. Guessing here means attaching to the wrong browser.
32
+ function portFromState() {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(path.join(root, ".foldspace-dev", "state.json"), "utf8")).debugPort;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ const portArgIndex = process.argv.indexOf("--port");
40
+ const port = String(
41
+ (portArgIndex > -1 && process.argv[portArgIndex + 1]) ||
42
+ process.env.CDP_PORT ||
43
+ portFromState() ||
44
+ "9222",
45
+ );
46
+ const bootstrap = process.argv.includes("--bootstrap");
47
+ // Test mode keeps conversations out of the customer's default list. Turn it off
48
+ // only when you WANT the conversations and action calls to show up in the
49
+ // Foldspace dashboard — a brand-new agent during initial setup, where there is
50
+ // no production traffic to pollute.
51
+ const noTestMode = process.argv.includes("--no-test-mode");
52
+ // The badge used to hardcode "TEST MODE" whether or not test mode was on — a
53
+ // string that asserts you are safe while you are not. It now states what is
54
+ // actually true. --no-badge drops it entirely, for recording a demo.
55
+ const noBadge = process.argv.includes("--no-badge");
56
+ const badgeText = noTestMode
57
+ ? "FOLDSPACE DEV \u00b7 local actions \u00b7 LIVE"
58
+ : "FOLDSPACE DEV \u00b7 local actions \u00b7 TEST MODE";
59
+
60
+ // Agent api name for setTestMode — from the same config inject.mjs used.
61
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, "foldspace.dev.json"), "utf8"));
62
+ const cfgTarget = cfg.targets[cfg.defaultTarget] || {};
63
+ const agentApiName =
64
+ process.env.AGENT_API_NAME ||
65
+ (process.argv.includes("--agent")
66
+ ? process.argv[process.argv.indexOf("--agent") + 1]
67
+ : cfgTarget.agentApiName);
68
+
69
+ // Refuse to run against an uninitialised template. Without this, attach injects
70
+ // the literal placeholder as an agent api name and the page fails with an opaque
71
+ // "Failed to fetch agent config: status 400" — after having already broken the
72
+ // app's own agent.
73
+ const PLACEHOLDERS = ["AGENT_API_NAME", "PRODUCT_ID", "__APP_DOMAIN__", "__APP_ORIGIN__"];
74
+ const unresolved = PLACEHOLDERS.filter((ph) =>
75
+ agentApiName === ph || JSON.stringify(cfgTarget).includes(ph)
76
+ );
77
+ if (unresolved.length) {
78
+ console.error(
79
+ `attach: foldspace.dev.json still contains placeholders: ${unresolved.join(", ")}\n` +
80
+ ` Run "node scripts/init.mjs" first, or pass --agent <apiName>.\n` +
81
+ ` Refusing to run — injecting a placeholder breaks the app's agent.`
82
+ );
83
+ process.exit(1);
84
+ }
85
+
86
+ const manifest = JSON.parse(fs.readFileSync(path.join(extDir, "manifest.json"), "utf8"));
87
+ const hostPatterns = manifest.content_scripts[0].matches.map((m) =>
88
+ m.replace("*://", "").replace("/*", "")
89
+ );
90
+ const hostMatches = (url) => {
91
+ let h;
92
+ try { h = new URL(url).hostname; } catch { return false; }
93
+ return hostPatterns.some((p) =>
94
+ p.startsWith("*.") ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p
95
+ );
96
+ };
97
+
98
+ // Any remote-actions bundle, dev or prod, for any product/agent.
99
+ const ACTIONS_PATTERN = "*/agent/actions/*";
100
+
101
+ const badgeSrc = `(() => {
102
+ if (window.top !== window.self) return;
103
+ const add = () => {
104
+ if (document.getElementById("foldspace-dev-badge") || !document.body) return;
105
+ const b = document.createElement("div");
106
+ b.id = "foldspace-dev-badge";
107
+ b.textContent = ${JSON.stringify(badgeText)};
108
+ b.style.cssText = "position:fixed;top:0;left:0;z-index:2147483647;background:#3247F2;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;padding:5px 10px;border-bottom-right-radius:4px;pointer-events:none;box-shadow:0 1px 6px rgba(0,0,0,.35)";
109
+ document.body.appendChild(b);
110
+ };
111
+ add();
112
+ document.addEventListener("DOMContentLoaded", add);
113
+ setInterval(add, 2000);
114
+ })();`;
115
+
116
+ // Flag every local session as test traffic so it is excluded from analytics and
117
+ // hidden from the default conversation list.
118
+ // https://docs.foldspace.ai/reference/test-mode.md
119
+ //
120
+ // A handle is scoped by mode: window.foldspace.agent({apiName}) alone returns
121
+ // the OVERLAY instance. On an app that embeds the copilot that is NOT the
122
+ // instance serving the chat, so arming it succeeds, logs success, and leaves
123
+ // the real conversations untagged — the failure that actually leaks. Enumerate
124
+ // foldspace.agentIds() — each id is shaped "<mode>-<apiName>" — and arm every
125
+ // one. See the playbook's test-mode rule.
126
+ const testModeSrc = `(() => {
127
+ if (window.top !== window.self) return;
128
+ const AGENT = ${JSON.stringify(agentApiName)};
129
+ // Log once per instance; setTestMode itself is re-applied on every pass so an
130
+ // instance that is torn down and rebuilt gets re-armed.
131
+ const logged = new Set();
132
+
133
+ const armAll = () => {
134
+ const fs = window.foldspace;
135
+ if (!fs) return;
136
+
137
+ let ids = null;
138
+ try {
139
+ if (typeof fs.agentIds === "function") ids = fs.agentIds();
140
+ } catch (e) {}
141
+
142
+ // Older SDK with no agentIds(): the unscoped handle is all there is.
143
+ if (!ids) {
144
+ try {
145
+ fs.agent({ apiName: AGENT }).setTestMode(true);
146
+ if (!logged.has(AGENT)) {
147
+ logged.add(AGENT);
148
+ console.log("[foldspace-dev] test mode ON (" + AGENT + " — SDK has no agentIds(), overlay handle only)");
149
+ }
150
+ } catch (e) {
151
+ console.warn("[foldspace-dev] setTestMode failed:", e && e.message);
152
+ }
153
+ return;
154
+ }
155
+
156
+ for (const id of ids) {
157
+ const cut = id.indexOf("-");
158
+ if (cut < 1) continue;
159
+ try {
160
+ fs.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() })
161
+ .setTestMode(true);
162
+ if (!logged.has(id)) {
163
+ logged.add(id);
164
+ console.log("[foldspace-dev] test mode ON (" + id + ")");
165
+ }
166
+ } catch (e) {
167
+ console.warn("[foldspace-dev] setTestMode failed for " + id + ":", e && e.message);
168
+ }
169
+ }
170
+ };
171
+
172
+ const arm = () => {
173
+ try { window.foldspace("when", "ready", armAll); } catch (e) {}
174
+ armAll();
175
+
176
+ // The instance serving the chat is built when the copilot panel opens, long
177
+ // after "ready" — arming once is too early. Keep arming.
178
+ setInterval(armAll, 1000);
179
+
180
+ let queued = false;
181
+ const armSoon = () => {
182
+ if (queued) return;
183
+ queued = true;
184
+ setTimeout(() => { queued = false; armAll(); }, 200);
185
+ };
186
+ try {
187
+ const obs = new MutationObserver(armSoon);
188
+ const observe = () => {
189
+ if (document.body) obs.observe(document.body, { childList: true, subtree: true });
190
+ };
191
+ if (document.body) observe();
192
+ else document.addEventListener("DOMContentLoaded", observe);
193
+ } catch (e) {}
194
+ };
195
+
196
+ if (window.foldspace) return arm();
197
+ // The app bootstraps its own SDK — wait for it rather than racing it.
198
+ let n = 0;
199
+ const t = setInterval(() => {
200
+ if (window.foldspace) { clearInterval(t); arm(); }
201
+ else if (++n > 200) clearInterval(t);
202
+ }, 50);
203
+ })();`;
204
+
205
+ let nextId = 1;
206
+ const pending = new Map();
207
+ const prepared = new Set();
208
+ // targetId -> sessionId, so a target that navigates INTO a matching host can be
209
+ // prepared later. Without this, opening Chrome on a new tab and then browsing
210
+ // to the app never arms the swap.
211
+ const sessions = new Map();
212
+ let served = 0;
213
+
214
+ const call = (ws, method, params = {}, sessionId) =>
215
+ new Promise((resolve) => {
216
+ const id = nextId++;
217
+ pending.set(id, resolve);
218
+ ws.send(JSON.stringify({ id, method, params, sessionId }));
219
+ });
220
+
221
+ async function prepare(ws, sessionId, url) {
222
+ if (prepared.has(sessionId)) return;
223
+ prepared.add(sessionId);
224
+
225
+ await call(ws, "Page.enable", {}, sessionId);
226
+ await call(ws, "Runtime.enable", {}, sessionId);
227
+ await call(ws, "Page.setBypassCSP", { enabled: true }, sessionId);
228
+
229
+ // Workers spawned by this page attach through THIS session, not the browser
230
+ // one, so the browser-level setAutoAttach does not cover them and they start
231
+ // paused. Say so here too, or the app's workers freeze for the session.
232
+ await call(ws, "Target.setAutoAttach", {
233
+ autoAttach: true, waitForDebuggerOnStart: false, flatten: true,
234
+ }, sessionId);
235
+
236
+ await call(ws, "Fetch.enable", {
237
+ patterns: [{ urlPattern: ACTIONS_PATTERN, requestStage: "Request" }],
238
+ }, sessionId);
239
+
240
+ // The action bundle, injected rather than fetched. This is what lets the
241
+ // harness drop the dev server — and what makes a non-local browser possible,
242
+ // since a remote Chrome can never reach localhost.
243
+ const scripts = [];
244
+ if (fs.existsSync(bundlePath)) {
245
+ scripts.push(fs.readFileSync(bundlePath, "utf8"));
246
+ } else {
247
+ console.log(` !! ${path.relative(root, bundlePath)} missing — run "npm run dev"`);
248
+ }
249
+ if (!noBadge) scripts.push(badgeSrc);
250
+ if (!noTestMode) scripts.push(testModeSrc);
251
+ if (bootstrap) scripts.push(fs.readFileSync(path.join(extDir, "index.js"), "utf8"));
252
+ for (const source of scripts) {
253
+ await call(ws, "Page.addScriptToEvaluateOnNewDocument", { source, runImmediately: true }, sessionId);
254
+ }
255
+
256
+ console.log(` attached -> ${url}`);
257
+ await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
258
+ }
259
+
260
+ async function onEvent(ws, msg) {
261
+ const { method, params, sessionId } = msg;
262
+
263
+ if (method === "Target.attachedToTarget") {
264
+ const t = params.targetInfo;
265
+ if (t.type === "page") {
266
+ sessions.set(t.targetId, params.sessionId);
267
+ if (hostMatches(t.url)) await prepare(ws, params.sessionId, t.url);
268
+ } else {
269
+ // Auto-attach pauses workers on start until the attaching client says go.
270
+ // We do not instrument workers — but if we never release them they stay
271
+ // frozen for the whole session. On an app that does real work in workers
272
+ // that is a broken app, and Chrome tells the user "Debugger paused in
273
+ // another tab". Only this session can release them; a second CDP client
274
+ // sending runIfWaitingForDebugger has no effect.
275
+ await call(ws, "Runtime.runIfWaitingForDebugger", {}, params.sessionId);
276
+ }
277
+ return;
278
+ }
279
+
280
+ // A target we already know about navigated. If it just entered a matching
281
+ // host, arm it now.
282
+ if (method === "Target.targetInfoChanged") {
283
+ const t = params.targetInfo;
284
+ if (t.type === "page" && hostMatches(t.url)) {
285
+ const sid = sessions.get(t.targetId);
286
+ if (sid) await prepare(ws, sid, t.url);
287
+ }
288
+ return;
289
+ }
290
+
291
+ if (method === "Fetch.requestPaused") {
292
+ const { requestId, request } = params;
293
+ if (!fs.existsSync(bundlePath)) {
294
+ console.log(` !! dist/index.js missing — run "npm run dev"`);
295
+ await call(ws, "Fetch.continueRequest", { requestId }, sessionId);
296
+ return;
297
+ }
298
+ const body = fs.readFileSync(bundlePath);
299
+ served++;
300
+ console.log(` served local actions (${(body.length / 1024).toFixed(0)}kb) for ${request.url.slice(0, 78)}`);
301
+ await call(ws, "Fetch.fulfillRequest", {
302
+ requestId,
303
+ responseCode: 200,
304
+ responseHeaders: [
305
+ { name: "content-type", value: "application/javascript; charset=utf-8" },
306
+ { name: "access-control-allow-origin", value: "*" },
307
+ { name: "cache-control", value: "no-store" },
308
+ ],
309
+ body: body.toString("base64"),
310
+ }, sessionId);
311
+ return;
312
+ }
313
+
314
+ if (method === "Runtime.consoleAPICalled") {
315
+ const text = (params.args || [])
316
+ .map((a) => (a.value !== undefined ? a.value : a.description || ""))
317
+ .join(" ");
318
+ if (/foldspace-dev|remote actions|identified|Error/i.test(text) && !/IO (entry|callback)/.test(text)) {
319
+ console.log(` [page] ${text.slice(0, 160)}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ let ws;
325
+
326
+ // Declared before connect() wires ws.onclose: a socket that closes during
327
+ // startup would otherwise reach onDisconnect() while keepAlive is still in the
328
+ // temporal dead zone, throwing instead of reconnecting.
329
+ const keepAlive = setInterval(() => {}, 1 << 30);
330
+
331
+ async function connect() {
332
+ const ver = await (await fetch(`http://localhost:${port}/json/version`)).json();
333
+ ws = new WebSocket(ver.webSocketDebuggerUrl);
334
+ await new Promise((resolve, reject) => {
335
+ ws.onopen = resolve;
336
+ ws.onerror = reject;
337
+ });
338
+ ws.onmessage = (e) => {
339
+ const m = JSON.parse(e.data);
340
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
341
+ else if (m.method) onEvent(ws, m);
342
+ };
343
+ ws.onclose = () => { void onDisconnect(); };
344
+ await call(ws, "Target.setDiscoverTargets", { discover: true });
345
+ await call(ws, "Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
346
+ return ver;
347
+ }
348
+
349
+ const ver = await connect();
350
+
351
+ console.log(`Attached to ${ver.Browser} on :${port}`);
352
+ console.log(`Mode: ${bootstrap ? "bootstrap (inject SDK)" : "swap (reuse the app's agent)"}`);
353
+ console.log(`Test: ${noTestMode ? "OFF — conversations WILL appear in the dashboard" : "on"}`);
354
+ console.log(`Hosts: ${hostPatterns.join(", ")}`);
355
+ console.log(`Serving: ${path.relative(root, bundlePath)}\n`);
356
+
357
+ process.on("SIGINT", () => {
358
+ console.log(`\nDetached. Served the local bundle ${served} time(s).`);
359
+ process.exit(0);
360
+ });
361
+
362
+ // A closed socket does not mean the browser closed. Chrome drops the CDP
363
+ // connection on target churn and transient errors too, and exiting on those
364
+ // silently ends the swap while the user keeps browsing — the page then loads
365
+ // the deployed bundle with no signal that anything changed. Reconnect, and
366
+ // only give up once the debugging port is genuinely gone.
367
+ async function browserIsUp() {
368
+ try {
369
+ const r = await fetch(`http://localhost:${port}/json/version`);
370
+ return r.ok;
371
+ } catch {
372
+ return false;
373
+ }
374
+ }
375
+
376
+ async function onDisconnect() {
377
+ prepared.clear();
378
+ sessions.clear();
379
+ for (let attempt = 1; attempt <= 10; attempt++) {
380
+ await new Promise((r) => setTimeout(r, Math.min(500 * attempt, 3000)));
381
+ if (!(await browserIsUp())) {
382
+ clearInterval(keepAlive);
383
+ console.log("\nBrowser closed. Detaching.");
384
+ process.exit(0);
385
+ }
386
+ try {
387
+ console.log(` connection lost — reconnecting (${attempt}/10)…`);
388
+ await connect();
389
+ console.log(" reconnected. Swap is live again.");
390
+ return;
391
+ } catch {
392
+ // fall through and retry
393
+ }
394
+ }
395
+ clearInterval(keepAlive);
396
+ console.error("\nCould not reconnect after 10 attempts. Detaching — "
397
+ + "the page is now loading the DEPLOYED bundle, not your local build.");
398
+ process.exit(1);
399
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Thin launcher so the TypeScript build script runs without the consumer
3
+ // needing tsx wired up themselves.
4
+ import { spawn } from "child_process";
5
+ import path from "path";
6
+ import { fileURLToPath } from "url";
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ const child = spawn(
9
+ process.execPath,
10
+ [path.join(here, "..", "node_modules", "tsx", "dist", "cli.mjs"), path.join(here, "build.ts"), ...process.argv.slice(2)],
11
+ { stdio: "inherit", env: { ...process.env, FOLDSPACE_PROJECT_DIR: process.env.FOLDSPACE_PROJECT_DIR || process.cwd() } },
12
+ );
13
+ child.on("exit", (code) => process.exit(code ?? 0));
package/bin/build.ts ADDED
@@ -0,0 +1,82 @@
1
+ import * as esbuild from "esbuild";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
8
+ const distDir = path.join(projectDir, "dist");
9
+ const agentActionsEntry = path.join(projectDir, "agent", "actions", "index.ts");
10
+
11
+ const watchMode = process.argv.includes("--watch");
12
+
13
+ function buildOptions(): esbuild.BuildOptions {
14
+ return {
15
+ entryPoints: [agentActionsEntry],
16
+ outfile: path.join(distDir, "index.js"),
17
+ bundle: true,
18
+ format: "iife",
19
+ platform: "browser",
20
+ target: "es2022",
21
+ minify: true,
22
+ sourcemap: false,
23
+ };
24
+ }
25
+
26
+ function rebuildPlugin(): esbuild.Plugin {
27
+ return {
28
+ name: "rebuild-notify",
29
+ setup(build) {
30
+ build.onEnd((result) => {
31
+ const time = new Date().toLocaleTimeString();
32
+ if (result.errors.length === 0) {
33
+ console.log(`[${time}] Rebuilt: dist/index.js`);
34
+ } else {
35
+ console.log(`[${time}] Error: dist/index.js`);
36
+ }
37
+ });
38
+ },
39
+ };
40
+ }
41
+
42
+ async function build() {
43
+ console.log(`Building agent actions...\n`);
44
+
45
+ if (!fs.existsSync(agentActionsEntry)) {
46
+ console.error(`Entry point not found: ${agentActionsEntry}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ if (!fs.existsSync(distDir)) {
51
+ fs.mkdirSync(distDir, { recursive: true });
52
+ }
53
+
54
+ try {
55
+ if (watchMode) {
56
+ console.log("Watch mode — watching for changes...\n");
57
+
58
+ const context = await esbuild.context({
59
+ ...buildOptions(),
60
+ plugins: [rebuildPlugin()],
61
+ });
62
+
63
+ await context.watch();
64
+ console.log("Initial build complete! Watching for changes...\n");
65
+
66
+ process.on("SIGINT", async () => {
67
+ console.log("\nStopping watcher...");
68
+ await context.dispose();
69
+ process.exit(0);
70
+ });
71
+ } else {
72
+ await esbuild.build(buildOptions());
73
+ console.log("Built: dist/index.js");
74
+ console.log("\nBuild complete!");
75
+ }
76
+ } catch (error) {
77
+ console.error("Build failed:", error);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ build();