@foldspace_npm/harness 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CLAUDE.md +402 -204
  2. package/README.md +84 -36
  3. package/bin/ask.mjs +19 -9
  4. package/bin/attach.mjs +91 -56
  5. package/bin/badge.mjs +28 -8
  6. package/bin/cli.mjs +23 -6
  7. package/bin/inject.mjs +3 -4
  8. package/bin/observe.mjs +22 -6
  9. package/bin/published.mjs +141 -0
  10. package/package.json +6 -2
  11. package/recipes/INDEX.md +13 -8
  12. package/recipes/README.md +30 -7
  13. package/recipes/account-overview/README.md +6 -6
  14. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  15. package/recipes/account-overview/agent/views/brand.ts +23 -14
  16. package/recipes/account-overview/agent/views/overview.ts +13 -17
  17. package/recipes/bottom-bar/README.md +12 -9
  18. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  19. package/recipes/create-a-record/README.md +97 -0
  20. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  21. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  22. package/recipes/create-a-record/agent/projects.ts +145 -0
  23. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  24. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  25. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  26. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  27. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  28. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  29. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  30. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  31. package/recipes/create-a-record/recipe.json +13 -0
  32. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  33. package/recipes/find-by-name/README.md +3 -1
  34. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  35. package/recipes/lookup-then-view/README.md +73 -0
  36. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  37. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  38. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  39. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  40. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  41. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  42. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  43. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  44. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  45. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  46. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  47. package/recipes/lookup-then-view/recipe.json +11 -0
  48. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  49. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  50. package/recipes/opportunities-at-risk/README.md +5 -5
  51. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  52. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  53. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  54. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  55. package/recipes/pick-from-a-list/README.md +7 -6
  56. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  57. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  58. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  59. package/recipes/prepare-for-a-meeting/README.md +4 -4
  60. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  61. package/recipes/records-list/README.md +6 -6
  62. package/recipes/records-list/agent/accounts.ts +3 -1
  63. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  64. package/recipes/records-list/agent/views/brand.ts +26 -14
  65. package/recipes/server-rendered-forms/README.md +35 -0
  66. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  67. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  68. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  69. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  70. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  71. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  72. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  73. package/recipes/server-rendered-forms/recipe.json +10 -0
  74. package/recipes/several-hosts/README.md +10 -13
  75. package/recipes/several-hosts/agent/hosts.ts +19 -136
  76. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  77. package/recipes/several-hosts/recipe.json +3 -3
  78. package/recipes/swap-the-login-method/README.md +16 -5
  79. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  80. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  81. package/recipes/update-meeting-notes/README.md +10 -4
  82. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  83. package/recipes/upload-contacts/README.md +5 -5
  84. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  85. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  86. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  87. package/recipes/who-is-the-user/README.md +7 -3
  88. package/src/ask-core.mjs +16 -4
  89. package/src/attach-daemon.mjs +9 -2
  90. package/src/attach-helpers.mjs +105 -0
  91. package/src/attach-preflight.mjs +34 -11
  92. package/src/badge-core.mjs +1 -1
  93. package/src/cdp-client.mjs +15 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +102 -47
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/keep-focus.mjs +17 -13
  100. package/src/lint/index.mjs +8 -1
  101. package/src/lint/rules.mjs +240 -43
  102. package/src/observe-core.mjs +163 -34
  103. package/src/published-core.mjs +392 -0
  104. package/src/region.mjs +1 -1
  105. package/src/runtime/agent.ts +16 -12
  106. package/src/runtime/http.ts +25 -15
  107. package/src/runtime/match.ts +1 -1
  108. package/src/runtime/render.ts +4 -4
  109. package/src/transports/README.md +2 -2
  110. package/src/upgrade.mjs +33 -10
  111. package/templates/agent-starter/CLAUDE.md +8 -5
  112. package/templates/agent-starter/README.md +32 -33
  113. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  114. package/templates/agent-starter/agent/actions/index.ts +2 -2
  115. package/templates/agent-starter/agent/constants.ts +4 -3
  116. package/templates/agent-starter/agent/utils.ts +15 -18
  117. package/templates/agent-starter/claude-settings.json +2 -1
  118. package/templates/agent-starter/docs/app-profile.md +19 -14
  119. package/templates/agent-starter/foldspace.dev.json +1 -1
  120. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
package/bin/attach.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * --replace The app embeds a different Foldspace product or agent.
17
17
  * Rebind the page bootstrap to the configured product and
18
- * make the configured agent the sole visible widget owner.
18
+ * make the configured agent the only visible one.
19
19
  *
20
20
  * Chrome 136+ removed --load-extension and does not run content scripts for
21
21
  * CDP-loaded extensions, so neither path uses the extension at runtime.
@@ -25,11 +25,13 @@ import path from "path";
25
25
  import { fileURLToPath } from "url";
26
26
  import {
27
27
  ATTACH_MODES,
28
+ attachAgainFlags,
28
29
  attachModeFromArgs,
29
30
  characterizePageAgents,
30
31
  hostMatches,
31
32
  hostPatternsFromTarget,
32
33
  parseAgentId,
34
+ registrationMismatchReport,
33
35
  registrationVerified,
34
36
  resolveAttachLaunch,
35
37
  } from "../src/attach-helpers.mjs";
@@ -101,8 +103,8 @@ const { useSavedLaunch, port } = resolveAttachLaunch({
101
103
  });
102
104
  if (!port) {
103
105
  console.error(
104
- `attach: no CDP port. Run "foldspace inject" first, or pass --port / CDP_PORT.\n` +
105
- ` Refusing to guess 9222 — that port is often someone else's Chrome.`,
106
+ `attach: no CDP port recorded here. Run \`npm run inject\` first; it opens the test window and records its port.\n` +
107
+ ` Refusing to guess 9222 - that port is often someone else's Chrome.`,
106
108
  );
107
109
  process.exit(1);
108
110
  }
@@ -113,7 +115,7 @@ const claimed = verifyLaunchSentinel({
113
115
  });
114
116
  if (!claimed.ok) {
115
117
  console.error(
116
- `attach: ${ownershipErrorMessage(claimed.reason, { port, profileDir, root })}`,
118
+ `attach: ${ownershipErrorMessage(claimed.reason, { port, command: "attach" })}`,
117
119
  );
118
120
  process.exit(1);
119
121
  }
@@ -124,7 +126,7 @@ const owned = await assertOwnedCdp({
124
126
  });
125
127
  if (!owned.ok) {
126
128
  console.error(
127
- `attach: ${ownershipErrorMessage(owned.reason, { port, profileDir, root })}`,
129
+ `attach: ${ownershipErrorMessage(owned.reason, { port, command: "attach" })}`,
128
130
  );
129
131
  process.exit(1);
130
132
  }
@@ -148,6 +150,8 @@ const noTestMode = !process.argv.includes("--test-mode");
148
150
  // string that asserts you are safe while you are not. It now states what is
149
151
  // actually true. --no-badge drops it entirely, for recording a demo.
150
152
  const noBadge = process.argv.includes("--no-badge");
153
+ // What a restart step names, so it keeps this attach's mode, test mode and tag.
154
+ const againFlags = attachAgainFlags({ mode: attachMode, testMode: !noTestMode, noBadge });
151
155
  // Agent api name for setTestMode — from the same config inject.mjs used.
152
156
  const cfg = JSON.parse(fs.readFileSync(path.join(root, "foldspace.dev.json"), "utf8"));
153
157
  const targetName =
@@ -189,7 +193,7 @@ const unresolved = PLACEHOLDERS.filter((ph) =>
189
193
  if (unresolved.length) {
190
194
  console.error(
191
195
  `attach: foldspace.dev.json still contains placeholders: ${unresolved.join(", ")}\n` +
192
- ` Run "foldspace init" first, or pass --agent <apiName>.\n` +
196
+ ` Run "foldspace init" first, or pass --agent <agent-key>.\n` +
193
197
  ` Refusing to run — injecting a placeholder breaks the app's agent.`
194
198
  );
195
199
  process.exit(1);
@@ -203,7 +207,7 @@ if (bootstrap && (!sdkUrl || typeof sdkUrl !== "string")) {
203
207
  process.exit(1);
204
208
  }
205
209
  if (!fs.existsSync(bundlePath)) {
206
- console.error(`attach: ${path.relative(root, bundlePath)} is missing — run "npm run build" first.`);
210
+ console.error(`attach: ${path.relative(root, bundlePath)} is missing - run \`npm run build\` first.`);
207
211
  process.exit(1);
208
212
  }
209
213
  const hostPatterns = hostPatternsFromTarget(cfgTarget);
@@ -224,10 +228,10 @@ const SDK_PATTERNS = [
224
228
 
225
229
  // The badge has three states, set from outside with \`foldspace badge\`:
226
230
  // label - the small corner tag (default): who is attached, which mode
227
- // working - a pill at the foot of the window: the agent is building in this
228
- // window, please leave it alone
231
+ // working - a pill at the foot of the window: the build session is working
232
+ // in this window, please leave it alone
229
233
  // ready - the pill goes away and the small tag comes back: their turn.
230
- // No bar for that - they may type whatever, whenever
234
+ // No pill for that - they may type whatever, whenever
231
235
  // The pill sits bottom-left, never across the top: a bar there covered the
232
236
  // app's own header and menu (seen on a CRM, 2026-09-22). The agent's panel
233
237
  // is on the right, so bottom-left is the corner nothing else uses.
@@ -260,11 +264,11 @@ const badgeSrc = `(() => {
260
264
  '#foldspace-dev-badge .fs-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#FFD314;margin-right:10px;vertical-align:middle;animation:fsPulse 1s ease-in-out infinite}' +
261
265
  '#foldspace-dev-badge .fs-line{position:absolute;left:0;right:0;bottom:0;height:3px;background:rgba(255,255,255,.15);overflow:hidden}' +
262
266
  '#foldspace-dev-badge .fs-line::after{content:"";position:absolute;top:0;width:30%;height:100%;background:#FFD314;animation:fsSweep 1.4s linear infinite}</style>' +
263
- '<span class="fs-dot"></span>Foldspace is building your agent on this page. <b style="color:#FFD314">Please leave this window alone</b> until the agent says it is ready.' +
267
+ '<span class="fs-dot"></span>Foldspace is building your agent on this page. <b style="color:#FFD314">Please leave this window alone</b> until you are told in the chat that it is ready.' +
264
268
  (s.text ? " " + s.text.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]) : "") +
265
269
  '<span class="fs-line"></span>';
266
270
  } else {
267
- // "ready" and "label" both draw the small tag: their turn needs no bar -
271
+ // "ready" and "label" both draw the small tag: their turn needs no pill -
268
272
  // they may type whatever, whenever. Only the wait ever gets a pill.
269
273
  b.style.cssText = base + "background:#3247F2;padding:5px 10px;border-bottom-right-radius:4px;";
270
274
  b.textContent = LABEL;
@@ -292,14 +296,16 @@ const badgeSrc = `(() => {
292
296
  // https://docs.foldspace.ai/reference/test-mode.md
293
297
  //
294
298
  // A handle is scoped by mode: window.foldspace.agent({apiName}) alone returns
295
- // the OVERLAY instance. On an app that embeds the copilot that is NOT the
299
+ // the OVERLAY instance. On an app that embeds the agent in-page that is NOT the
296
300
  // instance serving the chat, so arming it succeeds, logs success, and leaves
297
301
  // the real conversations untagged — the failure that actually leaks. Enumerate
298
302
  // foldspace.agentIds() — each id is shaped "<mode>-<apiName>" — and arm every
299
303
  // one. See the playbook's test-mode rule.
300
- const testModeSrc = `(() => {
304
+ const testModeScript = (enable) => `(() => {
301
305
  if (window.top !== window.self) return;
302
306
  const AGENT = ${JSON.stringify(agentApiName)};
307
+ const WANT = ${enable};
308
+ const WORD = ${JSON.stringify(enable ? "ON" : "OFF")};
303
309
  // Log once per instance; setTestMode itself is re-applied on every pass so an
304
310
  // instance that is torn down and rebuilt gets re-armed.
305
311
  const logged = new Set();
@@ -316,10 +322,10 @@ const testModeSrc = `(() => {
316
322
  // Older SDK with no agentIds(): the unscoped handle is all there is.
317
323
  if (!ids) {
318
324
  try {
319
- fs.agent({ apiName: AGENT }).setTestMode(true);
325
+ fs.agent({ apiName: AGENT }).setTestMode(WANT);
320
326
  if (!logged.has(AGENT)) {
321
327
  logged.add(AGENT);
322
- console.log("[foldspace-dev] test mode ON (" + AGENT + " — SDK has no agentIds(), overlay handle only)");
328
+ console.log("[foldspace-dev] test mode " + WORD + " (" + AGENT + " — SDK has no agentIds(), overlay handle only)");
323
329
  }
324
330
  } catch (e) {
325
331
  console.warn("[foldspace-dev] setTestMode failed:", e && e.message);
@@ -332,10 +338,10 @@ const testModeSrc = `(() => {
332
338
  if (cut < 1) continue;
333
339
  try {
334
340
  fs.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() })
335
- .setTestMode(true);
341
+ .setTestMode(WANT);
336
342
  if (!logged.has(id)) {
337
343
  logged.add(id);
338
- console.log("[foldspace-dev] test mode ON (" + id + ")");
344
+ console.log("[foldspace-dev] test mode " + WORD + " (" + id + ")");
339
345
  }
340
346
  } catch (e) {
341
347
  console.warn("[foldspace-dev] setTestMode failed for " + id + ":", e && e.message);
@@ -347,7 +353,7 @@ const testModeSrc = `(() => {
347
353
  try { window.foldspace("when", "ready", armAll); } catch (e) {}
348
354
  armAll();
349
355
 
350
- // The instance serving the chat is built when the copilot panel opens, long
356
+ // The instance serving the chat is built when the agent panel opens, long
351
357
  // after "ready" — arming once is too early. Keep arming.
352
358
  setInterval(armAll, 1000);
353
359
 
@@ -404,6 +410,9 @@ function logLifecycle(result) {
404
410
  if (!result.ok && result.error?.message) {
405
411
  console.log(` ${result.error.message}`);
406
412
  }
413
+ if (!result.ok && Array.isArray(result.details?.findings)) {
414
+ for (const line of result.details.findings) console.log(` ${line}`);
415
+ }
407
416
  if (!result.ok && result.details?.suggestion) {
408
417
  console.log(` Next: ${result.details.suggestion}`);
409
418
  }
@@ -701,6 +710,7 @@ async function prepare(ws, sessionId, url) {
701
710
  mode: agentMode,
702
711
  namespace: pageState?.namespace || "foldspace",
703
712
  aliases,
713
+ testMode: !noTestMode,
704
714
  }),
705
715
  hostPatterns,
706
716
  ),
@@ -713,7 +723,12 @@ async function prepare(ws, sessionId, url) {
713
723
  const scripts = [];
714
724
  scripts.push(fs.readFileSync(bundlePath, "utf8"));
715
725
  if (!noBadge) scripts.push(badgeSrc);
716
- if (!noTestMode) scripts.push(testModeSrc);
726
+ // Test mode is armed when --test-mode is set, and actively CLEARED otherwise:
727
+ // it is page-session state that only a reload or setTestMode(false) undoes,
728
+ // so a page left in test mode by an earlier attach would keep the striped
729
+ // test-mode banner under a LIVE corner tag until reload (a customer build,
730
+ // 2026-09-30).
731
+ scripts.push(testModeScript(!noTestMode));
717
732
  if (bootstrap) {
718
733
  scripts.push(
719
734
  buildBootstrapScript({
@@ -756,6 +771,45 @@ async function prepare(ws, sessionId, url) {
756
771
  const verification = await verifyPreparedPage(ws, sessionId);
757
772
  // For scoring a session: the moment the customer could first see their agent.
758
773
  if (verification.ok) recordEvent(root, "agent_visible", { mode: attachMode });
774
+ const registrationDetails = {
775
+ mode: attachMode,
776
+ testMode: !noTestMode,
777
+ noBadge,
778
+ expectedAgentId: verification.characterization.expectedAgentId,
779
+ detectedAgentIds: verification.state?.agentIds || [],
780
+ expectedProductId: productId,
781
+ detectedProductIds: verification.products,
782
+ widget: verification.widget,
783
+ registeredActionNames:
784
+ verification.registration?.actionNames || [],
785
+ expectedActionNames:
786
+ verification.observation?.expectedActionNames || [],
787
+ expectedActionCount: Array.isArray(
788
+ verification.observation?.expectedActionNames,
789
+ )
790
+ ? verification.observation.expectedActionNames.length
791
+ : 0,
792
+ missingActionNames:
793
+ verification.registration?.missingActionNames || [],
794
+ unexpectedActionNames:
795
+ verification.registration?.unexpectedActionNames || [],
796
+ actionObserverSubscribed:
797
+ verification.observation?.subscribed === true,
798
+ // null when attach never got as far as the action checks.
799
+ actionObserverInstalled: verification.observation
800
+ ? verification.observation.installed === true
801
+ : null,
802
+ actionCaptureCount: verification.observation
803
+ ? Number(verification.observation.captureCount) || 0
804
+ : null,
805
+ agentFound: verification.registration
806
+ ? verification.registration.agentFound === true
807
+ : null,
808
+ actionNameLimitExceeded:
809
+ verification.observation?.actionNameLimitExceeded === true,
810
+ diagnosticError: verification.diagnosticError,
811
+ };
812
+ const mismatch = verification.ok ? null : registrationMismatchReport(registrationDetails);
759
813
  logLifecycle(
760
814
  createLifecycleResult({
761
815
  operation: "inspect_registration",
@@ -768,32 +822,9 @@ async function prepare(ws, sessionId, url) {
768
822
  message:
769
823
  "The local action registry did not match the configured page agent.",
770
824
  },
771
- details: {
772
- mode: attachMode,
773
- expectedAgentId: verification.characterization.expectedAgentId,
774
- detectedAgentIds: verification.state?.agentIds || [],
775
- expectedProductId: productId,
776
- detectedProductIds: verification.products,
777
- widget: verification.widget,
778
- registeredActionNames:
779
- verification.registration?.actionNames || [],
780
- expectedActionNames:
781
- verification.observation?.expectedActionNames || [],
782
- expectedActionCount: Array.isArray(
783
- verification.observation?.expectedActionNames,
784
- )
785
- ? verification.observation.expectedActionNames.length
786
- : 0,
787
- missingActionNames:
788
- verification.registration?.missingActionNames || [],
789
- unexpectedActionNames:
790
- verification.registration?.unexpectedActionNames || [],
791
- actionObserverSubscribed:
792
- verification.observation?.subscribed === true,
793
- actionNameLimitExceeded:
794
- verification.observation?.actionNameLimitExceeded === true,
795
- diagnosticError: verification.diagnosticError,
796
- },
825
+ details: mismatch
826
+ ? { ...registrationDetails, findings: mismatch.lines, suggestion: mismatch.next }
827
+ : registrationDetails,
797
828
  }),
798
829
  );
799
830
  if (!verification.ok) {
@@ -819,7 +850,7 @@ function noteUncoveredHost(url) {
819
850
  if (coverage.covered || !coverage.pattern || uncoveredHostsSeen.has(coverage.host)) return;
820
851
  uncoveredHostsSeen.add(coverage.host);
821
852
  console.log(` [hosts] the window is on ${coverage.host}, which this project does not cover - the agent is not there.`);
822
- console.log(` Next: add "${coverage.pattern}" to "hosts" in foldspace.dev.json (with the human's yes), then inject and attach again.`);
853
+ console.log(` Next: if it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json (with the human's yes), then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach${againFlags} --daemon\`. If it is not, ask the human to go back to the product in the test window.`);
823
854
  }
824
855
 
825
856
  async function onEvent(ws, msg) {
@@ -1020,11 +1051,11 @@ const ver = await connect(owned.version);
1020
1051
  console.log(`Attached to ${ver.Browser} on :${port}`);
1021
1052
  console.log(`Mode: ${attachMode}`);
1022
1053
  console.log(`Agent: ${agentApiName} (product ${productId}, ${agentMode})`);
1023
- console.log(`Test: ${noTestMode ? "off — conversations appear in the dashboard (pass --test-mode to hide them)" : "on"}`);
1054
+ console.log(`Test: ${noTestMode ? "off - conversations appear in their Foldspace dashboard (--test-mode is only for an agent that already has real users)" : "on"}`);
1024
1055
  console.log(`Hosts: ${hostPatterns.join(", ")}`);
1025
1056
  console.log(`Serving: ${path.relative(root, bundlePath)}`);
1026
1057
  console.log(
1027
- `CDP: attached. \`foldspace observe\` reads this same window while attach runs; stop attach only to load rebuilt actions.\n`,
1058
+ `CDP: attached. \`npx foldspace observe\` and \`npx foldspace ask\` use this same window while attach runs; stop attach only to load rebuilt actions or changed hosts, or to check published code on dev.\n`,
1028
1059
  );
1029
1060
 
1030
1061
  function printSessionSummary() {
@@ -1130,9 +1161,9 @@ process.on("SIGTERM", requestDetach);
1130
1161
 
1131
1162
  // A closed socket does not mean the browser closed. Chrome drops the CDP
1132
1163
  // connection on target churn and transient errors too, and exiting on those
1133
- // silently ends the swap while the user keeps browsing — the page then loads
1134
- // the deployed bundle with no signal that anything changed. Reconnect, and
1135
- // only give up once the debugging port is genuinely gone.
1164
+ // silently ends the swap while the human keeps browsing - the page then runs
1165
+ // only what the site loads by itself, with no signal that anything changed.
1166
+ // Reconnect, and only give up once the debugging port is genuinely gone.
1136
1167
  async function browserIsUp() {
1137
1168
  try {
1138
1169
  const r = await fetch(`http://localhost:${port}/json/version`);
@@ -1156,12 +1187,16 @@ async function onDisconnect(closedSocket) {
1156
1187
  if (!(await browserIsUp())) {
1157
1188
  clearInterval(keepAlive);
1158
1189
  printSessionSummary();
1159
- console.log("\nBrowser closed. Detaching.");
1190
+ console.log("\nThe test window was closed. Detaching.");
1160
1191
  process.exit(0);
1161
1192
  }
1162
1193
  try {
1163
1194
  console.log(` connection lost — reconnecting (${attempt}/10)…`);
1164
- await connect();
1195
+ // connect() needs the browser's version endpoint; re-check ownership
1196
+ // so attach never adopts another Chrome that took the port meanwhile.
1197
+ const again = await assertOwnedCdp({ profileDir, port, token: devState.sentinel });
1198
+ if (!again.ok) throw new Error(again.reason);
1199
+ await connect(again.version);
1165
1200
  console.log(` reconnected. ${attachMode} mode is live again.`);
1166
1201
  return;
1167
1202
  } catch {
@@ -1170,8 +1205,8 @@ async function onDisconnect(closedSocket) {
1170
1205
  }
1171
1206
  clearInterval(keepAlive);
1172
1207
  printSessionSummary();
1173
- console.error("\nCould not reconnect after 10 attempts. Detaching — "
1174
- + "the page is now loading the DEPLOYED bundle, not your local build.");
1208
+ console.error("\nCould not reconnect after 10 attempts. Detaching - after a reload the page runs only what "
1209
+ + `the site loads by itself, not this build. Run \`npx foldspace attach${againFlags} --daemon\` again.`);
1175
1210
  process.exit(1);
1176
1211
  } finally {
1177
1212
  reconnecting = false;
package/bin/badge.mjs CHANGED
@@ -2,15 +2,16 @@
2
2
  /**
3
3
  * foldspace badge <working|ready|label|off> [--text "<what to show>"]
4
4
  *
5
- * Sets the pill the attach badge draws at the foot of the test window.
6
- * The agent and the human share that one Chrome window, so the pill says
7
- * when to wait:
5
+ * Sets what attach draws in the test window: the pill or the corner tag.
6
+ * You and the human share that one Chrome window, so the pill says when to
7
+ * wait:
8
8
  * working - "Foldspace is building your agent on this page. Please leave
9
- * this window alone until the agent says it is ready."
9
+ * this window alone until you are told in the chat that it is
10
+ * ready."
10
11
  * ready - the pill goes away, the small corner tag comes back: their
11
- * turn. No bar for that - they may type whatever, whenever.
12
+ * turn. No pill for that - they may type whatever, whenever.
12
13
  * label - the small corner tag
13
- * off - remove it
14
+ * off - draw nothing: no pill, no corner tag
14
15
  * The state is kept in the page's sessionStorage, so it survives navigation;
15
16
  * attach's badge script redraws within a second. Nothing else on the page is
16
17
  * touched.
@@ -36,12 +37,31 @@ try {
36
37
  let page;
37
38
  try {
38
39
  page = await CdpPage.open({ projectDir });
40
+ // attach's badge script redraws within a second (after `badge off`, or
41
+ // before the body exists, there is no element yet): give it that second.
39
42
  const shown = await page.evaluate(
40
43
  `sessionStorage.setItem("__foldspace_badge__", ${JSON.stringify(JSON.stringify(payload))});
41
- const b = document.getElementById("foldspace-dev-badge");
44
+ let b = document.getElementById("foldspace-dev-badge");
45
+ for (let i = 0; !b && ${payload.state !== "off"} && i < 8; i++) {
46
+ await new Promise((resolve) => setTimeout(resolve, 250));
47
+ b = document.getElementById("foldspace-dev-badge");
48
+ }
42
49
  return { url: location.href.split(/[?#]/)[0], hadBadge: Boolean(b) };`,
43
50
  );
44
- printJson({ ok: true, state: payload.state, ...(payload.text ? { text: payload.text } : {}), ...shown });
51
+ // Still no element: the state was stored but nothing will show it.
52
+ const note =
53
+ !shown.hadBadge && payload.state !== "off"
54
+ ? {
55
+ note:
56
+ "Nothing is drawn on this page. After `npx foldspace published` that is expected - attach is " +
57
+ "stopped - so say in chat whose turn it is instead. Otherwise run `npx foldspace attach --status`. " +
58
+ "With no live daemon, run `npx foldspace attach --daemon` with the flags it ran with. With one, " +
59
+ "read its log (`logPath`): a `registration_mismatch` there names what differs and the next step; " +
60
+ "without one, attach runs with --no-badge - `npx foldspace attach --stop`, then " +
61
+ "`npx foldspace attach --daemon` with the flags it ran with, minus --no-badge.",
62
+ }
63
+ : {};
64
+ printJson({ ok: true, state: payload.state, ...(payload.text ? { text: payload.text } : {}), ...shown, ...note });
45
65
  } catch (error) {
46
66
  printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
47
67
  process.exit(1);
package/bin/cli.mjs CHANGED
@@ -5,6 +5,7 @@ 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 { isWriteFlag } from "../src/observe-core.mjs";
8
9
  import {
9
10
  checkForUpdate,
10
11
  gateForCommand,
@@ -17,7 +18,6 @@ import {
17
18
  runAttachControl,
18
19
  } from "../src/attach-daemon.mjs";
19
20
  import {
20
- CLI_COMMANDS,
21
21
  createCliRegistry,
22
22
  commandByName,
23
23
  } from "../src/cli-registry.mjs";
@@ -75,13 +75,17 @@ function printHelp(topic, json) {
75
75
  );
76
76
  return;
77
77
  }
78
- const update = checkForUpdate(resolveProjectDir());
79
78
  if (json) {
80
- const payload = topic ? document : { ...document, update };
79
+ // help --json is the first thing the manual reads, so it asks the registry
80
+ // now (as upgrade does) rather than trusting a 12-hour-old answer; offline,
81
+ // it falls back to that answer. One command's contract carries no update.
82
+ const payload = topic
83
+ ? document
84
+ : { ...document, update: checkForUpdate(resolveProjectDir(), { preferFresh: true }) };
81
85
  console.log(JSON.stringify(payload, null, 2));
82
86
  return;
83
87
  }
84
- nagIfOutdated(update);
88
+ nagIfOutdated(checkForUpdate(resolveProjectDir()));
85
89
  console.log(
86
90
  topic ? renderCommandHelp(registry, topic) : renderGeneralHelp(registry),
87
91
  );
@@ -112,7 +116,18 @@ function normalizeCommandArgs(command, args) {
112
116
  const separator = token.indexOf("=");
113
117
  const name = separator > -1 ? token.slice(0, separator) : token;
114
118
  const match = options.get(name);
115
- if (!match) throw new Error(`unknown option '${name}'`);
119
+ if (!match) {
120
+ // The reason matters more than the refusal: observe never writes, and
121
+ // always prints JSON. The write reason fits only `observe read`.
122
+ const verb = args.find((item) => !item.startsWith("--"));
123
+ throw new Error(
124
+ command.name === "observe" && name === "--json"
125
+ ? "unknown option '--json': observe always prints JSON."
126
+ : command.name === "observe" && verb === "read" && isWriteFlag(name)
127
+ ? `unknown option '${name}': observe read is GET only - it never sets a method, a header or a body.`
128
+ : `unknown option '${name}'`,
129
+ );
130
+ }
116
131
  const { option, canonicalName } = match;
117
132
  if (seen.has(canonicalName)) {
118
133
  throw new Error(`option '${canonicalName}' was provided twice`);
@@ -233,7 +248,9 @@ if (!command || command === "--help" || command === "-h") {
233
248
  }
234
249
  }
235
250
  } else {
236
- const names = CLI_COMMANDS.map((entry) => entry.name).join(", ");
251
+ // The visible commands only: listing deploy here would offer a coding agent
252
+ // the Foldspace-internal command help hides.
253
+ const names = registry.commands.map((entry) => entry.name).join(", ");
237
254
  fail(
238
255
  `unknown command '${command}'. Available commands: ${names}. ` +
239
256
  `Run 'foldspace help'.`,
package/bin/inject.mjs CHANGED
@@ -235,8 +235,8 @@ const chromeArgs = [
235
235
  startUrl,
236
236
  ];
237
237
 
238
- console.log(`\nLaunching Chrome \u2014 profile ${path.relative(root, profileDir)}, devtools on :${debugPort}.`);
239
- console.log("Log in to the app once — the profile persists between runs.\n");
238
+ console.log(`\nOpening the test window - profile ${path.relative(root, profileDir)}, debugging port :${debugPort}.`);
239
+ console.log("The human signs in there; the profile keeps what the site remembers between runs.\n");
240
240
 
241
241
  const child = spawn(chrome, chromeArgs, { detached: true, stdio: "ignore" });
242
242
  child.unref();
@@ -254,8 +254,7 @@ if (!owned.ok) {
254
254
  }
255
255
  console.error(`inject: ${ownershipErrorMessage(owned.reason, {
256
256
  port: debugPort,
257
- profileDir,
258
- root,
257
+ command: "inject",
259
258
  })}`);
260
259
  process.exit(1);
261
260
  }
package/bin/observe.mjs CHANGED
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
 
24
24
  import { CdpPage, parseArgs, printJson, readLaunchState, sleep } from "../src/cdp-client.mjs";
25
+ import { ownershipErrorMessage } from "../src/cdp-ownership.mjs";
25
26
  import { withoutStealingFocus } from "../src/keep-focus.mjs";
26
27
  import { withWorkingBadge } from "../src/badge-auto.mjs";
27
28
  import {
@@ -294,9 +295,7 @@ async function runPages() {
294
295
  try {
295
296
  targets = await (await fetch(`http://127.0.0.1:${state.port}/json/list`)).json();
296
297
  } catch {
297
- throw new Error(
298
- `The inject Chrome is not answering on :${state.port}. It was closed; run \`npm run inject\` again.`,
299
- );
298
+ throw new Error(ownershipErrorMessage("cdp_not_ready", { port: state.port }));
300
299
  }
301
300
  const real = targets.filter(
302
301
  (target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
@@ -321,7 +320,7 @@ async function runPages() {
321
320
  ? {
322
321
  hostsHint:
323
322
  `The window is on ${off.host}, which this project does not cover (hosts: ${state.hosts.join(", ")}). ` +
324
- `If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
323
+ `If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach --daemon\` with the flags it ran with. If it is not, ask the human to go back to the product in the test window.`,
325
324
  }
326
325
  : {}),
327
326
  };
@@ -338,7 +337,7 @@ async function runMenu(page) {
338
337
  items.push(publicItem(entry));
339
338
  }
340
339
  const url = await page.evaluate("return location.href;");
341
- // Links that leave this host but stay in the product (analyze., admin. ...).
340
+ // Links that leave this host but stay in the product (reports., admin. ...).
342
341
  // Looked for across the whole page: product tiles are often not navigation.
343
342
  const leaving = await page
344
343
  .evaluate(
@@ -768,7 +767,24 @@ async function runWaitLogin(flags) {
768
767
  }
769
768
  await sleep(1500);
770
769
  }
771
- throw new ObserveExit({ ok: false, loggedIn: false, url, ...(lastError ? { lastError } : {}) }, 2);
770
+ // A last check that failed (window closed, or on a host this project does
771
+ // not cover) says what to do itself; asking the human would only loop.
772
+ const secs = Math.round(timeoutMs / 1000);
773
+ throw new ObserveExit(
774
+ {
775
+ ok: false,
776
+ loggedIn: false,
777
+ error: lastError
778
+ ? `No sign-in seen in ${secs} seconds. The last check said: ${lastError}`
779
+ : `No sign-in seen in ${secs} seconds: the test window still shows a sign-in page (${url}). ` +
780
+ "Ask the human whether they managed to sign in there. If they have not, run " +
781
+ "`npx foldspace observe wait-login` again while they do; if they say they are in, run " +
782
+ "`npx foldspace observe screenshot` and look at the page.",
783
+ url,
784
+ ...(lastError ? { lastError } : {}),
785
+ },
786
+ 2,
787
+ );
772
788
  }
773
789
 
774
790
  // -------------------------------------------------------------------- main