@foldspace_npm/harness 0.1.2 → 0.1.4

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/bin/attach.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Attach to the Chrome launched by inject.mjs and serve local actions to it.
4
4
  *
5
- * Two modes, picked automatically:
5
+ * Three explicit page modes:
6
6
  *
7
7
  * swap (default) The client app already embeds Foldspace. We
8
8
  * intercept the remote-actions bundle request and fulfill it
@@ -11,7 +11,11 @@
11
11
  * page's own agent instead of creating a second one.
12
12
  *
13
13
  * --bootstrap The app has no Foldspace. Inject the SDK bootstrap from the
14
- * generated extension/index.js before page scripts run.
14
+ * harness bootstrap module before page scripts run.
15
+ *
16
+ * --replace The app embeds a different Foldspace product or agent.
17
+ * Rebind the page bootstrap to the configured product and
18
+ * make the configured agent the sole visible widget owner.
15
19
  *
16
20
  * Chrome 136+ removed --load-extension and does not run content scripts for
17
21
  * CDP-loaded extensions, so neither path uses the extension at runtime.
@@ -19,31 +23,85 @@
19
23
  import fs from "fs";
20
24
  import path from "path";
21
25
  import { fileURLToPath } from "url";
26
+ import {
27
+ ATTACH_MODES,
28
+ attachModeFromArgs,
29
+ characterizePageAgents,
30
+ hostMatches,
31
+ hostPatternsFromTarget,
32
+ parseAgentId,
33
+ } from "../src/attach-helpers.mjs";
34
+ import {
35
+ buildReplacePrelude,
36
+ detectedProductIds,
37
+ guardScriptForHosts,
38
+ guardAttachMode,
39
+ isSdkScriptRequest,
40
+ rewriteSdkRequestKey,
41
+ shouldFulfillActionRequest,
42
+ } from "../src/attach-preflight.mjs";
43
+ import { serializeDiagnostic } from "../src/diagnostics.mjs";
44
+ import {
45
+ ERROR_CODES,
46
+ createLifecycleResult,
47
+ } from "../src/protocol.mjs";
48
+ import { createCdpRequestManager } from "../src/cdp-request-manager.mjs";
49
+ import { buildActionObserverScript } from "../src/action-observer.mjs";
50
+ import { buildBootstrapScript } from "../src/bootstrap-script.mjs";
51
+ import {
52
+ createSessionCollector,
53
+ formatActionObservation,
54
+ formatSessionSummary,
55
+ } from "../src/session-summary.mjs";
22
56
 
23
57
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
58
  // Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
25
59
  // hosted builder point the harness at a workspace it controls.
26
60
  const root = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
27
- const extDir = path.join(root, ".foldspace-dev", "extension");
28
61
  const bundlePath = path.join(root, "dist", "index.js");
29
62
 
30
63
  // Prefer what inject actually launched, then an explicit override, then the
31
64
  // legacy default. Guessing here means attaching to the wrong browser.
32
- function portFromState() {
65
+ function readDevState() {
33
66
  try {
34
- return JSON.parse(fs.readFileSync(path.join(root, ".foldspace-dev", "state.json"), "utf8")).debugPort;
67
+ return JSON.parse(
68
+ fs.readFileSync(
69
+ path.join(root, ".foldspace-dev", "state.json"),
70
+ "utf8",
71
+ ),
72
+ );
35
73
  } catch {
36
- return null;
74
+ return {};
37
75
  }
38
76
  }
77
+ const devState = readDevState();
39
78
  const portArgIndex = process.argv.indexOf("--port");
79
+ const targetArgIndex = process.argv.indexOf("--target");
80
+ const explicitPort =
81
+ portArgIndex > -1 ? process.argv[portArgIndex + 1] : null;
82
+ const explicitTarget =
83
+ targetArgIndex > -1 ? process.argv[targetArgIndex + 1] : null;
84
+ const savedLaunchMatches =
85
+ !explicitTarget || explicitTarget === devState.target;
86
+ const useSavedLaunch =
87
+ !explicitPort &&
88
+ !process.env.CDP_PORT &&
89
+ savedLaunchMatches;
40
90
  const port = String(
41
- (portArgIndex > -1 && process.argv[portArgIndex + 1]) ||
91
+ explicitPort ||
42
92
  process.env.CDP_PORT ||
43
- portFromState() ||
93
+ (useSavedLaunch ? devState.debugPort : null) ||
44
94
  "9222",
45
95
  );
46
- const bootstrap = process.argv.includes("--bootstrap");
96
+ let attachMode;
97
+ try {
98
+ attachMode = attachModeFromArgs(process.argv.slice(2));
99
+ } catch (error) {
100
+ console.error(`attach: ${error instanceof Error ? error.message : String(error)}`);
101
+ process.exit(1);
102
+ }
103
+ const bootstrap = attachMode === ATTACH_MODES.BOOTSTRAP;
104
+ const replace = attachMode === ATTACH_MODES.REPLACE;
47
105
  // Test mode keeps conversations out of the customer's default list. Turn it off
48
106
  // only when you WANT the conversations and action calls to show up in the
49
107
  // Foldspace dashboard — a brand-new agent during initial setup, where there is
@@ -53,18 +111,35 @@ const noTestMode = process.argv.includes("--no-test-mode");
53
111
  // string that asserts you are safe while you are not. It now states what is
54
112
  // actually true. --no-badge drops it entirely, for recording a demo.
55
113
  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
114
  // Agent api name for setTestMode — from the same config inject.mjs used.
61
115
  const cfg = JSON.parse(fs.readFileSync(path.join(root, "foldspace.dev.json"), "utf8"));
62
- const cfgTarget = cfg.targets[cfg.defaultTarget] || {};
116
+ const targetName =
117
+ explicitTarget ||
118
+ (useSavedLaunch ? devState.target : null) ||
119
+ cfg.defaultTarget;
120
+ const configuredTarget = cfg.targets[targetName] || {};
121
+ const cfgTarget =
122
+ !explicitTarget &&
123
+ useSavedLaunch &&
124
+ devState.target === targetName &&
125
+ devState.resolvedTarget &&
126
+ typeof devState.resolvedTarget === "object"
127
+ ? { ...configuredTarget, ...devState.resolvedTarget }
128
+ : configuredTarget;
63
129
  const agentApiName =
64
- process.env.AGENT_API_NAME ||
65
130
  (process.argv.includes("--agent")
66
131
  ? process.argv[process.argv.indexOf("--agent") + 1]
67
- : cfgTarget.agentApiName);
132
+ : null) ||
133
+ process.env.AGENT_API_NAME ||
134
+ cfgTarget.agentApiName;
135
+ const productId = cfgTarget.productId;
136
+ const agentMode = String(cfgTarget.mode || "OVERLAY").toUpperCase();
137
+ const agentKey = cfgTarget.overrideKey || `EU-${productId}-1-1`;
138
+ const sdkUrl = cfgTarget.sdkUrl || cfg.sdkUrl;
139
+ const actionTarget = { productId, agentApiName };
140
+ const badgeText = noTestMode
141
+ ? `FOLDSPACE DEV \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 LIVE`
142
+ : `FOLDSPACE DEV \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 TEST MODE`;
68
143
 
69
144
  // Refuse to run against an uninitialised template. Without this, attach injects
70
145
  // the literal placeholder as an agent api name and the page fails with an opaque
@@ -77,26 +152,38 @@ const unresolved = PLACEHOLDERS.filter((ph) =>
77
152
  if (unresolved.length) {
78
153
  console.error(
79
154
  `attach: foldspace.dev.json still contains placeholders: ${unresolved.join(", ")}\n` +
80
- ` Run "node scripts/init.mjs" first, or pass --agent <apiName>.\n` +
155
+ ` Run "foldspace init" first, or pass --agent <apiName>.\n` +
81
156
  ` Refusing to run — injecting a placeholder breaks the app's agent.`
82
157
  );
83
158
  process.exit(1);
84
159
  }
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
160
+ if (!productId || !agentApiName) {
161
+ console.error("attach: foldspace.dev.json must define productId and agentApiName.");
162
+ process.exit(1);
163
+ }
164
+ if (bootstrap && (!sdkUrl || typeof sdkUrl !== "string")) {
165
+ console.error("attach: foldspace.dev.json must define sdkUrl for --bootstrap.");
166
+ process.exit(1);
167
+ }
168
+ if (!fs.existsSync(bundlePath)) {
169
+ console.error(`attach: ${path.relative(root, bundlePath)} is missing run "npm run build" first.`);
170
+ process.exit(1);
171
+ }
172
+ const hostPatterns = hostPatternsFromTarget(cfgTarget);
173
+ if (!hostPatterns.length) {
174
+ console.error(
175
+ `attach: target "${targetName}" must define hosts or a valid startUrl.`,
95
176
  );
96
- };
177
+ process.exit(1);
178
+ }
97
179
 
98
- // Any remote-actions bundle, dev or prod, for any product/agent.
180
+ // CDP patterns are intentionally broad; the paused-request handler only
181
+ // fulfills the configured product/agent and continues every unrelated request.
99
182
  const ACTIONS_PATTERN = "*/agent/actions/*";
183
+ const SDK_PATTERNS = [
184
+ "*://*/*foldspace.js*",
185
+ "*://*/*eucera.js*",
186
+ ];
100
187
 
101
188
  const badgeSrc = `(() => {
102
189
  if (window.top !== window.self) return;
@@ -202,59 +289,438 @@ const testModeSrc = `(() => {
202
289
  }, 50);
203
290
  })();`;
204
291
 
205
- let nextId = 1;
206
- const pending = new Map();
207
- const prepared = new Set();
292
+ const cdpRequests = createCdpRequestManager();
293
+ const sessionCollector = createSessionCollector();
294
+ // sessionId -> preparation state used for deterministic detach cleanup.
295
+ const prepared = new Map();
296
+ const preparing = new Set();
297
+ // Rejections are scoped to the observed URL so a later navigation can retry.
298
+ const rejected = new Map();
208
299
  // targetId -> sessionId, so a target that navigates INTO a matching host can be
209
300
  // prepared later. Without this, opening Chrome on a new tab and then browsing
210
301
  // to the app never arms the swap.
211
302
  const sessions = new Map();
303
+ const sessionUrls = new Map();
212
304
  let served = 0;
305
+ let shuttingDown = false;
306
+ let reconnecting = false;
307
+ let summaryPrinted = false;
308
+ const call = cdpRequests.call;
309
+
310
+ function logLifecycle(result) {
311
+ const detail = result.ok
312
+ ? `${result.operation}:${result.state}`
313
+ : `${result.operation}:${result.state} ${result.error.code}`;
314
+ console.log(` [lifecycle] ${detail}`);
315
+ if (!result.ok && result.error?.message) {
316
+ console.log(` ${result.error.message}`);
317
+ }
318
+ if (!result.ok && result.details?.suggestion) {
319
+ console.log(` Next: ${result.details.suggestion}`);
320
+ }
321
+ }
322
+
323
+ async function runDiagnostic(ws, sessionId, name, args = {}) {
324
+ const evaluation = await call(
325
+ ws,
326
+ "Runtime.evaluate",
327
+ {
328
+ expression: serializeDiagnostic(name, args),
329
+ awaitPromise: true,
330
+ returnByValue: true,
331
+ },
332
+ sessionId,
333
+ );
334
+ if (evaluation?.exceptionDetails) {
335
+ throw new Error(
336
+ `Diagnostic ${name} failed in the page: ${
337
+ evaluation.exceptionDetails.text || "Unknown evaluation error"
338
+ }`,
339
+ );
340
+ }
341
+ return evaluation?.result?.value || null;
342
+ }
343
+
344
+ async function inspectPage(ws, sessionId) {
345
+ let state = null;
346
+ let stableNoSdkObservations = 0;
347
+ for (let attempt = 0; attempt < 40; attempt++) {
348
+ try {
349
+ state = await runDiagnostic(ws, sessionId, "inspectSdkState", {
350
+ expectedApiName: agentApiName,
351
+ expectedMode: agentMode,
352
+ });
353
+ } catch {
354
+ await new Promise((resolve) => setTimeout(resolve, 100));
355
+ continue;
356
+ }
357
+
358
+ const ready =
359
+ state?.documentReadyState === "interactive" ||
360
+ state?.documentReadyState === "complete";
361
+ const onConfiguredHost =
362
+ !state?.pageUrl || hostMatches(state.pageUrl, hostPatterns);
363
+ if (!ready || !onConfiguredHost) {
364
+ stableNoSdkObservations = 0;
365
+ await new Promise((resolve) => setTimeout(resolve, 100));
366
+ continue;
367
+ }
368
+
369
+ if (state.sdkPresent) {
370
+ if (
371
+ attachMode !== ATTACH_MODES.SWAP ||
372
+ (state.sdkReady && state.agentIds.length > 0)
373
+ ) {
374
+ return state;
375
+ }
376
+ stableNoSdkObservations = 0;
377
+ } else if (attachMode === ATTACH_MODES.BOOTSTRAP) {
378
+ stableNoSdkObservations += 1;
379
+ if (stableNoSdkObservations >= 5) return state;
380
+ }
381
+ await new Promise((resolve) => setTimeout(resolve, 100));
382
+ }
383
+ return state;
384
+ }
213
385
 
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 }));
386
+ async function verifyPreparedPage(ws, sessionId) {
387
+ const latest = {
388
+ state: null,
389
+ characterization: null,
390
+ products: [],
391
+ widget: null,
392
+ registration: null,
393
+ observation: null,
394
+ diagnosticError: null,
395
+ };
396
+ for (let attempt = 0; attempt < 40; attempt++) {
397
+ try {
398
+ const state = await runDiagnostic(ws, sessionId, "inspectSdkState", {
399
+ expectedApiName: agentApiName,
400
+ expectedMode: agentMode,
401
+ });
402
+ const characterization = characterizePageAgents({
403
+ sdkPresent: state?.sdkPresent === true,
404
+ agentIds: state?.agentIds || [],
405
+ expectedApiName: agentApiName,
406
+ expectedMode: agentMode,
407
+ });
408
+ const products = detectedProductIds(state);
409
+ latest.state = state;
410
+ latest.characterization = characterization;
411
+ latest.products = products;
412
+ const productMatches = products.includes(productId);
413
+ const widget = await runDiagnostic(
414
+ ws,
415
+ sessionId,
416
+ "verifyWidgetVisible",
417
+ { ownerAgentId: characterization.expectedAgentId },
418
+ );
419
+ latest.widget = widget;
420
+ const visibilityMatches =
421
+ attachMode !== ATTACH_MODES.REPLACE ||
422
+ (widget?.soleVisible === true && widget?.ownerMatches === true);
423
+ if (
424
+ characterization.status === "same-agent" &&
425
+ productMatches &&
426
+ visibilityMatches
427
+ ) {
428
+ const observation = await runDiagnostic(
429
+ ws,
430
+ sessionId,
431
+ "inspectActionObservation",
432
+ { ownerAgentId: characterization.expectedAgentId },
433
+ );
434
+ latest.observation = observation;
435
+ const expectedActionNames =
436
+ observation?.installed &&
437
+ observation?.ownerMatches &&
438
+ !observation?.actionNameLimitExceeded &&
439
+ Array.isArray(observation.expectedActionNames)
440
+ ? observation.expectedActionNames
441
+ : [];
442
+ const registration = await runDiagnostic(
443
+ ws,
444
+ sessionId,
445
+ "inspectRegistration",
446
+ {
447
+ apiName: agentApiName,
448
+ mode: agentMode,
449
+ ownerAgentId: characterization.expectedAgentId,
450
+ expectedActionNames,
451
+ },
452
+ );
453
+ latest.registration = registration;
454
+ if (
455
+ observation?.captureCount > 0 &&
456
+ expectedActionNames.length > 0 &&
457
+ registration?.agentFound &&
458
+ registration.missingActionNames?.length === 0 &&
459
+ registration.unexpectedActionNames?.length === 0
460
+ ) {
461
+ return {
462
+ ok: true,
463
+ state,
464
+ characterization,
465
+ products,
466
+ widget,
467
+ registration,
468
+ observation,
469
+ };
470
+ }
471
+ }
472
+ } catch (error) {
473
+ // Reload briefly destroys the execution context. Retry until the page and
474
+ // SDK settle instead of treating that expected transition as a mismatch.
475
+ latest.diagnosticError =
476
+ error instanceof Error ? error.message.slice(0, 500) : String(error);
477
+ }
478
+ await new Promise((resolve) => setTimeout(resolve, 250));
479
+ }
480
+ const state = await inspectPage(ws, sessionId);
481
+ latest.state = state;
482
+ latest.characterization = characterizePageAgents({
483
+ sdkPresent: state?.sdkPresent === true,
484
+ agentIds: state?.agentIds || [],
485
+ expectedApiName: agentApiName,
486
+ expectedMode: agentMode,
219
487
  });
488
+ latest.products = detectedProductIds(state);
489
+ return {
490
+ ok: false,
491
+ ...latest,
492
+ };
493
+ }
220
494
 
221
495
  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"`);
496
+ if (rejected.has(sessionId) && rejected.get(sessionId) !== url) {
497
+ rejected.delete(sessionId);
248
498
  }
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);
499
+ if (
500
+ prepared.has(sessionId) ||
501
+ preparing.has(sessionId) ||
502
+ rejected.has(sessionId)
503
+ ) {
504
+ return;
254
505
  }
506
+ preparing.add(sessionId);
507
+
508
+ try {
509
+ await call(ws, "Page.enable", {}, sessionId);
510
+ await call(ws, "Runtime.enable", {}, sessionId);
511
+
512
+ const pageState = await inspectPage(ws, sessionId);
513
+ if (!pageState?.pageUrl || !hostMatches(pageState.pageUrl, hostPatterns)) {
514
+ return;
515
+ }
516
+ const characterization = characterizePageAgents({
517
+ sdkPresent: pageState?.sdkPresent === true,
518
+ agentIds: pageState?.agentIds || [],
519
+ expectedApiName: agentApiName,
520
+ expectedMode: agentMode,
521
+ });
522
+ const pageProductIds = detectedProductIds(pageState);
523
+ const guard = guardAttachMode({
524
+ mode: attachMode,
525
+ characterization,
526
+ expectedProductId: productId,
527
+ pageProductIds,
528
+ });
529
+ if (!guard.ok) {
530
+ rejected.set(sessionId, url);
531
+ logLifecycle(
532
+ createLifecycleResult({
533
+ operation: "prepare_page",
534
+ ok: false,
535
+ state: "rejected",
536
+ error: { code: guard.code, message: guard.message },
537
+ details: {
538
+ url,
539
+ mode: attachMode,
540
+ expectedAgentId: characterization.expectedAgentId,
541
+ detectedAgentIds: pageState?.agentIds || [],
542
+ expectedProductId: productId,
543
+ detectedProductIds: pageProductIds,
544
+ suggestion: guard.suggestion || "",
545
+ },
546
+ }),
547
+ );
548
+ return;
549
+ }
550
+
551
+ logLifecycle(
552
+ createLifecycleResult({
553
+ operation: "prepare_page",
554
+ ok: true,
555
+ state: "preflight_ok",
556
+ details: {
557
+ url,
558
+ mode: attachMode,
559
+ expectedAgentId: characterization.expectedAgentId,
560
+ detectedAgentIds: pageState?.agentIds || [],
561
+ expectedProductId: productId,
562
+ detectedProductIds: pageProductIds,
563
+ },
564
+ }),
565
+ );
566
+
567
+ await call(ws, "Page.setBypassCSP", { enabled: true }, sessionId);
568
+
569
+ // Workers spawned by this page attach through THIS session, not the browser
570
+ // one, so the browser-level setAutoAttach does not cover them and they start
571
+ // paused. Say so here too, or the app's workers freeze for the session.
572
+ await call(ws, "Target.setAutoAttach", {
573
+ autoAttach: true, waitForDebuggerOnStart: false, flatten: true,
574
+ }, sessionId);
575
+
576
+ const fetchPatterns = [
577
+ { urlPattern: ACTIONS_PATTERN, requestStage: "Request" },
578
+ ...(replace
579
+ ? SDK_PATTERNS.map((urlPattern) => ({
580
+ urlPattern,
581
+ requestStage: "Request",
582
+ }))
583
+ : []),
584
+ ];
585
+ await call(ws, "Fetch.enable", { patterns: fetchPatterns }, sessionId);
255
586
 
256
- console.log(` attached -> ${url}`);
257
- await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
587
+ // The action bundle, injected rather than fetched. This is what lets the
588
+ // harness drop the dev server — and what makes a non-local browser possible,
589
+ // since a remote Chrome can never reach localhost.
590
+ const scriptIds = [];
591
+ const observerResult = await call(
592
+ ws,
593
+ "Page.addScriptToEvaluateOnNewDocument",
594
+ {
595
+ source: guardScriptForHosts(
596
+ buildActionObserverScript({
597
+ agentApiName,
598
+ mode: agentMode,
599
+ namespace: pageState?.namespace || "foldspace",
600
+ }),
601
+ hostPatterns,
602
+ ),
603
+ },
604
+ sessionId,
605
+ );
606
+ if (observerResult?.identifier) {
607
+ scriptIds.push(observerResult.identifier);
608
+ }
609
+ if (replace) {
610
+ const aliases = characterization.agents
611
+ .map((agent) => agent.apiName)
612
+ .filter((apiName) => apiName !== agentApiName);
613
+ const result = await call(
614
+ ws,
615
+ "Page.addScriptToEvaluateOnNewDocument",
616
+ {
617
+ source: guardScriptForHosts(
618
+ buildReplacePrelude({
619
+ agentKey,
620
+ agentApiName,
621
+ mode: agentMode,
622
+ namespace: pageState?.namespace || "foldspace",
623
+ aliases,
624
+ }),
625
+ hostPatterns,
626
+ ),
627
+ },
628
+ sessionId,
629
+ );
630
+ if (result?.identifier) scriptIds.push(result.identifier);
631
+ }
632
+
633
+ const scripts = [];
634
+ scripts.push(fs.readFileSync(bundlePath, "utf8"));
635
+ if (!noBadge) scripts.push(badgeSrc);
636
+ if (!noTestMode) scripts.push(testModeSrc);
637
+ if (bootstrap) {
638
+ scripts.push(
639
+ buildBootstrapScript({
640
+ sdkUrl,
641
+ productId,
642
+ agentApiName,
643
+ mode: agentMode,
644
+ overrideKey: cfgTarget.overrideKey || null,
645
+ namespace: pageState?.namespace || "foldspace",
646
+ }),
647
+ );
648
+ }
649
+ for (const source of scripts) {
650
+ const result = await call(
651
+ ws,
652
+ "Page.addScriptToEvaluateOnNewDocument",
653
+ {
654
+ source: guardScriptForHosts(source, hostPatterns),
655
+ runImmediately: true,
656
+ },
657
+ sessionId,
658
+ );
659
+ if (result?.identifier) scriptIds.push(result.identifier);
660
+ }
661
+
662
+ prepared.set(sessionId, {
663
+ scriptIds,
664
+ mode: attachMode,
665
+ url,
666
+ observing: true,
667
+ fetchEnabled: true,
668
+ sdkScriptUrls: (pageState?.scripts || []).filter((source) =>
669
+ isSdkScriptRequest(source),
670
+ ),
671
+ });
672
+ console.log(` attached -> ${url}`);
673
+ await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
674
+
675
+ {
676
+ const verification = await verifyPreparedPage(ws, sessionId);
677
+ logLifecycle(
678
+ createLifecycleResult({
679
+ operation: "inspect_registration",
680
+ ok: verification.ok,
681
+ state: verification.ok ? "registration_ok" : "registration_mismatch",
682
+ error: verification.ok
683
+ ? undefined
684
+ : {
685
+ code: ERROR_CODES.REGISTRATION_MISMATCH,
686
+ message:
687
+ "The local action registry did not match the configured page agent.",
688
+ },
689
+ details: {
690
+ mode: attachMode,
691
+ expectedAgentId: verification.characterization.expectedAgentId,
692
+ detectedAgentIds: verification.state?.agentIds || [],
693
+ expectedProductId: productId,
694
+ detectedProductIds: verification.products,
695
+ widget: verification.widget,
696
+ registeredActionNames:
697
+ verification.registration?.actionNames || [],
698
+ expectedActionNames:
699
+ verification.observation?.expectedActionNames || [],
700
+ missingActionNames:
701
+ verification.registration?.missingActionNames || [],
702
+ unexpectedActionNames:
703
+ verification.registration?.unexpectedActionNames || [],
704
+ actionObserverSubscribed:
705
+ verification.observation?.subscribed === true,
706
+ actionNameLimitExceeded:
707
+ verification.observation?.actionNameLimitExceeded === true,
708
+ diagnosticError: verification.diagnosticError,
709
+ },
710
+ }),
711
+ );
712
+ if (!verification.ok) {
713
+ const state = prepared.get(sessionId);
714
+ if (state) {
715
+ await cleanupPreparedSession(ws, sessionId, state);
716
+ prepared.delete(sessionId);
717
+ }
718
+ rejected.set(sessionId, url);
719
+ }
720
+ }
721
+ } finally {
722
+ preparing.delete(sessionId);
723
+ }
258
724
  }
259
725
 
260
726
  async function onEvent(ws, msg) {
@@ -264,7 +730,10 @@ async function onEvent(ws, msg) {
264
730
  const t = params.targetInfo;
265
731
  if (t.type === "page") {
266
732
  sessions.set(t.targetId, params.sessionId);
267
- if (hostMatches(t.url)) await prepare(ws, params.sessionId, t.url);
733
+ sessionUrls.set(params.sessionId, t.url);
734
+ if (hostMatches(t.url, hostPatterns)) {
735
+ await prepare(ws, params.sessionId, t.url);
736
+ }
268
737
  } else {
269
738
  // Auto-attach pauses workers on start until the attaching client says go.
270
739
  // We do not instrument workers — but if we never release them they stay
@@ -281,17 +750,76 @@ async function onEvent(ws, msg) {
281
750
  // host, arm it now.
282
751
  if (method === "Target.targetInfoChanged") {
283
752
  const t = params.targetInfo;
284
- if (t.type === "page" && hostMatches(t.url)) {
753
+ if (t.type === "page") {
285
754
  const sid = sessions.get(t.targetId);
286
- if (sid) await prepare(ws, sid, t.url);
755
+ if (!sid) return;
756
+ sessionUrls.set(sid, t.url);
757
+ if (hostMatches(t.url, hostPatterns)) {
758
+ await prepare(ws, sid, t.url);
759
+ } else {
760
+ const state = prepared.get(sid);
761
+ if (state) {
762
+ await cleanupPreparedSession(ws, sid, state, { reload: false });
763
+ prepared.delete(sid);
764
+ }
765
+ rejected.delete(sid);
766
+ }
767
+ }
768
+ return;
769
+ }
770
+
771
+ if (method === "Target.detachedFromTarget") {
772
+ const sid = params.sessionId;
773
+ prepared.delete(sid);
774
+ preparing.delete(sid);
775
+ rejected.delete(sid);
776
+ sessionUrls.delete(sid);
777
+ for (const [targetId, mappedSessionId] of sessions) {
778
+ if (mappedSessionId === sid) sessions.delete(targetId);
287
779
  }
288
780
  return;
289
781
  }
290
782
 
291
783
  if (method === "Fetch.requestPaused") {
292
784
  const { requestId, request } = params;
293
- if (!fs.existsSync(bundlePath)) {
294
- console.log(` !! dist/index.js missing run "npm run dev"`);
785
+ if (!hostMatches(sessionUrls.get(sessionId) || "", hostPatterns)) {
786
+ await call(ws, "Fetch.continueRequest", { requestId }, sessionId);
787
+ return;
788
+ }
789
+ const preparation = prepared.get(sessionId);
790
+ if (
791
+ isSdkScriptRequest(
792
+ request.url,
793
+ preparation?.sdkScriptUrls || [],
794
+ )
795
+ ) {
796
+ if (replace) {
797
+ const rewrittenUrl = rewriteSdkRequestKey(
798
+ request.url,
799
+ agentKey,
800
+ preparation?.sdkScriptUrls || [],
801
+ );
802
+ console.log(
803
+ ` rebound SDK request to product ${productId} for ${agentApiName}`,
804
+ );
805
+ await call(
806
+ ws,
807
+ "Fetch.continueRequest",
808
+ { requestId, url: rewrittenUrl },
809
+ sessionId,
810
+ );
811
+ } else {
812
+ await call(
813
+ ws,
814
+ "Fetch.continueRequest",
815
+ { requestId },
816
+ sessionId,
817
+ );
818
+ }
819
+ return;
820
+ }
821
+ if (!shouldFulfillActionRequest(request.url, actionTarget)) {
822
+ console.log(` continued unrelated actions request -> ${request.url.slice(0, 100)}`);
295
823
  await call(ws, "Fetch.continueRequest", { requestId }, sessionId);
296
824
  return;
297
825
  }
@@ -308,14 +836,42 @@ async function onEvent(ws, msg) {
308
836
  ],
309
837
  body: body.toString("base64"),
310
838
  }, sessionId);
839
+ logLifecycle(
840
+ createLifecycleResult({
841
+ operation: "load_artifact",
842
+ ok: true,
843
+ state: "artifact_served",
844
+ details: {
845
+ productId,
846
+ agentApiName,
847
+ bytes: body.length,
848
+ requestUrl: request.url.slice(0, 2_000),
849
+ },
850
+ }),
851
+ );
311
852
  return;
312
853
  }
313
854
 
314
855
  if (method === "Runtime.consoleAPICalled") {
856
+ const state = prepared.get(sessionId);
857
+ const currentUrl = sessionUrls.get(sessionId);
858
+ if (
859
+ state?.observing !== true ||
860
+ !currentUrl ||
861
+ !hostMatches(currentUrl, hostPatterns)
862
+ ) {
863
+ return;
864
+ }
315
865
  const text = (params.args || [])
316
866
  .map((a) => (a.value !== undefined ? a.value : a.description || ""))
317
867
  .join(" ");
318
- if (/foldspace-dev|remote actions|identified|Error/i.test(text) && !/IO (entry|callback)/.test(text)) {
868
+ const actionEvent = sessionCollector.recordConsole({
869
+ level: params.type || "log",
870
+ text,
871
+ });
872
+ if (actionEvent) {
873
+ console.log(` ${formatActionObservation(actionEvent)}`);
874
+ } else if (/foldspace-dev|remote actions|identified|Error/i.test(text) && !/IO (entry|callback)/.test(text)) {
319
875
  console.log(` [page] ${text.slice(0, 160)}`);
320
876
  }
321
877
  }
@@ -330,33 +886,138 @@ const keepAlive = setInterval(() => {}, 1 << 30);
330
886
 
331
887
  async function connect() {
332
888
  const ver = await (await fetch(`http://localhost:${port}/json/version`)).json();
333
- ws = new WebSocket(ver.webSocketDebuggerUrl);
889
+ const socket = new WebSocket(ver.webSocketDebuggerUrl);
334
890
  await new Promise((resolve, reject) => {
335
- ws.onopen = resolve;
336
- ws.onerror = reject;
891
+ socket.onopen = resolve;
892
+ socket.onerror = reject;
337
893
  });
338
- ws.onmessage = (e) => {
894
+ ws = socket;
895
+ socket.onmessage = (e) => {
339
896
  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);
897
+ if (cdpRequests.handleMessage(m)) return;
898
+ if (m.method) {
899
+ void onEvent(socket, m).catch((error) => {
900
+ console.error(
901
+ ` attach event failed: ${error instanceof Error ? error.message : String(error)}`,
902
+ );
903
+ });
904
+ }
905
+ };
906
+ socket.onclose = () => {
907
+ if (socket !== ws) return;
908
+ cdpRequests.failAll(new Error("CDP socket closed"));
909
+ if (!shuttingDown) void onDisconnect(socket);
342
910
  };
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 });
911
+ await call(socket, "Target.setDiscoverTargets", { discover: true });
912
+ await call(socket, "Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
346
913
  return ver;
347
914
  }
348
915
 
349
916
  const ver = await connect();
350
917
 
351
918
  console.log(`Attached to ${ver.Browser} on :${port}`);
352
- console.log(`Mode: ${bootstrap ? "bootstrap (inject SDK)" : "swap (reuse the app's agent)"}`);
919
+ console.log(`Mode: ${attachMode}`);
920
+ console.log(`Agent: ${agentApiName} (product ${productId}, ${agentMode})`);
353
921
  console.log(`Test: ${noTestMode ? "OFF — conversations WILL appear in the dashboard" : "on"}`);
354
922
  console.log(`Hosts: ${hostPatterns.join(", ")}`);
355
923
  console.log(`Serving: ${path.relative(root, bundlePath)}\n`);
356
924
 
925
+ function printSessionSummary() {
926
+ if (summaryPrinted) return;
927
+ summaryPrinted = true;
928
+ const summary = formatSessionSummary(sessionCollector.snapshot());
929
+ if (summary) console.log(`\n${summary}`);
930
+ }
931
+
932
+ async function cleanupPreparedSession(
933
+ socket,
934
+ sessionId,
935
+ state,
936
+ { reload = true } = {},
937
+ ) {
938
+ state.observing = false;
939
+ const failures = [];
940
+ if (state.fetchEnabled) {
941
+ try {
942
+ await call(socket, "Fetch.disable", {}, sessionId);
943
+ state.fetchEnabled = false;
944
+ } catch (error) {
945
+ failures.push(`Fetch.disable: ${error instanceof Error ? error.message : String(error)}`);
946
+ }
947
+ }
948
+ for (const identifier of state.scriptIds) {
949
+ try {
950
+ await call(
951
+ socket,
952
+ "Page.removeScriptToEvaluateOnNewDocument",
953
+ { identifier },
954
+ sessionId,
955
+ );
956
+ } catch (error) {
957
+ failures.push(
958
+ `remove preload ${identifier}: ${error instanceof Error ? error.message : String(error)}`,
959
+ );
960
+ }
961
+ }
962
+ state.scriptIds.length = 0;
963
+ try {
964
+ await call(
965
+ socket,
966
+ "Page.setBypassCSP",
967
+ { enabled: false },
968
+ sessionId,
969
+ );
970
+ } catch (error) {
971
+ failures.push(`restore CSP: ${error instanceof Error ? error.message : String(error)}`);
972
+ }
973
+ // Preload scripts have already executed in the current document. Reloading
974
+ // is the generic cleanup for swap handlers, bootstrap SDKs, and replacement
975
+ // identity aliases alike.
976
+ if (reload) {
977
+ try {
978
+ await call(socket, "Page.reload", { ignoreCache: true }, sessionId);
979
+ } catch (error) {
980
+ failures.push(`restore reload: ${error instanceof Error ? error.message : String(error)}`);
981
+ }
982
+ }
983
+ return failures;
984
+ }
985
+
986
+ async function cleanup() {
987
+ const failures = [];
988
+ for (const [sessionId, state] of prepared) {
989
+ failures.push(...(await cleanupPreparedSession(ws, sessionId, state)));
990
+ }
991
+ const result = createLifecycleResult({
992
+ operation: "cleanup",
993
+ ok: failures.length === 0,
994
+ state: failures.length === 0 ? "detached" : "cleanup_failed",
995
+ error:
996
+ failures.length === 0
997
+ ? undefined
998
+ : {
999
+ code: ERROR_CODES.TRANSPORT_ERROR,
1000
+ message: failures.join("; "),
1001
+ },
1002
+ details: {
1003
+ sessions: prepared.size,
1004
+ served,
1005
+ pagesReloaded: prepared.size,
1006
+ },
1007
+ });
1008
+ logLifecycle(result);
1009
+ printSessionSummary();
1010
+ return result;
1011
+ }
1012
+
357
1013
  process.on("SIGINT", () => {
358
- console.log(`\nDetached. Served the local bundle ${served} time(s).`);
359
- process.exit(0);
1014
+ if (shuttingDown) return;
1015
+ shuttingDown = true;
1016
+ void cleanup().finally(() => {
1017
+ clearInterval(keepAlive);
1018
+ console.log(`\nDetached. Served the local bundle ${served} time(s).`);
1019
+ process.exit(0);
1020
+ });
360
1021
  });
361
1022
 
362
1023
  // A closed socket does not mean the browser closed. Chrome drops the CDP
@@ -373,27 +1034,38 @@ async function browserIsUp() {
373
1034
  }
374
1035
  }
375
1036
 
376
- async function onDisconnect() {
1037
+ async function onDisconnect(closedSocket) {
1038
+ if (closedSocket !== ws || reconnecting || shuttingDown) return;
1039
+ reconnecting = true;
377
1040
  prepared.clear();
1041
+ preparing.clear();
1042
+ rejected.clear();
378
1043
  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
1044
+ sessionUrls.clear();
1045
+ try {
1046
+ for (let attempt = 1; attempt <= 10; attempt++) {
1047
+ await new Promise((r) => setTimeout(r, Math.min(500 * attempt, 3000)));
1048
+ if (!(await browserIsUp())) {
1049
+ clearInterval(keepAlive);
1050
+ printSessionSummary();
1051
+ console.log("\nBrowser closed. Detaching.");
1052
+ process.exit(0);
1053
+ }
1054
+ try {
1055
+ console.log(` connection lost — reconnecting (${attempt}/10)…`);
1056
+ await connect();
1057
+ console.log(` reconnected. ${attachMode} mode is live again.`);
1058
+ return;
1059
+ } catch {
1060
+ // fall through and retry
1061
+ }
393
1062
  }
1063
+ clearInterval(keepAlive);
1064
+ printSessionSummary();
1065
+ console.error("\nCould not reconnect after 10 attempts. Detaching — "
1066
+ + "the page is now loading the DEPLOYED bundle, not your local build.");
1067
+ process.exit(1);
1068
+ } finally {
1069
+ reconnecting = false;
394
1070
  }
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
1071
  }