@foldspace_npm/harness 0.1.16 → 0.1.18

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 (56) hide show
  1. package/CLAUDE.md +55 -21
  2. package/README.md +1 -1
  3. package/bin/attach.mjs +73 -20
  4. package/bin/badge.mjs +50 -0
  5. package/bin/inject.mjs +2 -2
  6. package/bin/observe.mjs +4 -3
  7. package/package.json +1 -1
  8. package/recipes/INDEX.md +7 -2
  9. package/recipes/account-overview/README.md +26 -0
  10. package/recipes/account-overview/agent/accounts.ts +159 -0
  11. package/recipes/account-overview/agent/actions/show_account_overview.ts +61 -0
  12. package/recipes/account-overview/agent/api/accounts.ts +59 -0
  13. package/recipes/account-overview/agent/views/brand.ts +22 -0
  14. package/recipes/account-overview/agent/views/overview.ts +303 -0
  15. package/recipes/account-overview/fixtures/overview.empty.json +12 -0
  16. package/recipes/account-overview/fixtures/overview.ok.json +30 -0
  17. package/recipes/account-overview/fixtures/overview.unsigned.json +9 -0
  18. package/recipes/account-overview/recipe.json +10 -0
  19. package/recipes/bottom-bar/README.md +43 -0
  20. package/recipes/bottom-bar/agent/bottomBar.ts +94 -0
  21. package/recipes/bottom-bar/fixtures/configuration.sent.json +17 -0
  22. package/recipes/bottom-bar/recipe.json +9 -0
  23. package/recipes/opportunities-at-risk/README.md +26 -0
  24. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +49 -0
  25. package/recipes/opportunities-at-risk/agent/api/opportunities.ts +30 -0
  26. package/recipes/opportunities-at-risk/agent/opportunities.ts +75 -0
  27. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +115 -0
  28. package/recipes/opportunities-at-risk/agent/views/brand.ts +20 -0
  29. package/recipes/opportunities-at-risk/fixtures/at-risk.empty.json +4 -0
  30. package/recipes/opportunities-at-risk/fixtures/at-risk.ok.json +25 -0
  31. package/recipes/opportunities-at-risk/fixtures/at-risk.unsigned.json +3 -0
  32. package/recipes/opportunities-at-risk/recipe.json +10 -0
  33. package/recipes/prepare-for-a-meeting/README.md +27 -0
  34. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +70 -0
  35. package/recipes/prepare-for-a-meeting/agent/api/meetings.ts +24 -0
  36. package/recipes/prepare-for-a-meeting/agent/meetings.ts +66 -0
  37. package/recipes/prepare-for-a-meeting/fixtures/prep.ok.json +24 -0
  38. package/recipes/prepare-for-a-meeting/fixtures/prep.unsigned.json +9 -0
  39. package/recipes/prepare-for-a-meeting/recipe.json +10 -0
  40. package/recipes/update-meeting-notes/README.md +22 -0
  41. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +36 -0
  42. package/recipes/update-meeting-notes/agent/api/meetings.ts +22 -0
  43. package/recipes/update-meeting-notes/fixtures/note.ok.json +3 -0
  44. package/recipes/update-meeting-notes/recipe.json +10 -0
  45. package/recipes/upload-contacts/README.md +25 -0
  46. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +77 -0
  47. package/recipes/upload-contacts/agent/api/contacts.ts +29 -0
  48. package/recipes/upload-contacts/agent/contacts.ts +133 -0
  49. package/recipes/upload-contacts/agent/views/brand.ts +21 -0
  50. package/recipes/upload-contacts/agent/views/uploader.ts +394 -0
  51. package/recipes/upload-contacts/fixtures/import.ok.json +9 -0
  52. package/recipes/upload-contacts/recipe.json +10 -0
  53. package/src/attach-preflight.mjs +9 -0
  54. package/src/badge-core.mjs +22 -0
  55. package/src/cli-registry.mjs +32 -3
  56. package/src/keep-focus.mjs +49 -0
package/CLAUDE.md CHANGED
@@ -71,11 +71,15 @@ current CLI contract — risk, prerequisites, effects, next step.
71
71
  so and offer the next one. On an empty account the useful first action is
72
72
  one that creates data - say so instead.
73
73
 
74
- **Say when the test window is yours, and when it is theirs.** You and the
75
- human share one Chrome window. Before you start looking at the app or testing
76
- in it: *"I'm going to work in that Chrome window for a few minutes - please
77
- leave it alone until I tell you it's ready."* When it is their turn: *"It's
78
- ready. Try asking: <the question this experience answers>."*
74
+ **Say when the test window is yours, and when it is theirs - on the page and
75
+ in chat.** You and the human share one Chrome window. Before you start looking
76
+ at the app or testing in it, run `npx foldspace badge working` (a bar across
77
+ the top of the window says Foldspace is building here, leave it alone) and say
78
+ so in chat: *"I'm going to work in that Chrome window for a few minutes -
79
+ please leave it alone until I tell you it's ready."* When it is their turn,
80
+ `npx foldspace badge ready --text "<the question this experience answers>"` -
81
+ the bar adds "Try asking:" and a Copy button itself - and say the same in chat. `foldspace badge label` puts the small
82
+ corner tag back; `off` removes it, for a screenshot they will share.
79
83
 
80
84
  Navigation, uploads, extraction and anything that writes are a later session.
81
85
 
@@ -207,11 +211,17 @@ with how you established each.
207
211
  | Navigation route | **yes** | `isActive: true` |
208
212
 
209
213
  Publishing a **new** object is inert; publishing an **edit to a live one** is
210
- the deploy above. Navigation has **no publish tool on the MCP** and
211
- `get_navigation_route` returns only `isActive` publish routes in Agent Studio
212
- and say so. Route descriptions are a retrieval surface: write what the page is
213
- *for*, in the user's words, and pair every parameterised route with the lookup
214
- action that produces its parameter or ship neither.
214
+ the deploy above. **Navigation has two switches, and both must be on.** Each
215
+ route has its own `isActive`, set when you create it (`create_navigation_route`
216
+ / `bulk_create_navigation_routes`, `isActive: true`). The agent has one
217
+ agent-level switch, **off by default on a new subscription**: `publish_navigation`
218
+ turns it on, `unpublish_navigation` off, and neither touches the routes' own
219
+ `isActive`. `list_navigation_routes` shows both. There is no MCP tool to flip a
220
+ single route's `isActive` after creation (it was disabled until Agent Studio
221
+ can reverse it) - create it right, or `update_navigation_route`, which replaces
222
+ the whole route. Route descriptions are a retrieval surface: write what the
223
+ page is *for*, in the user's words, and pair every parameterised route with the
224
+ lookup action that produces its parameter — or ship neither.
215
225
 
216
226
  **Ask before publishing.** It is required only so the copilot can call the
217
227
  action, and it is a live product change when the agent has real users.
@@ -247,6 +257,23 @@ copilot, read the daemon log for `[actions]` SDK callback and local
247
257
  execute/render lines. Those record names, statuses, durations and parameter keys
248
258
  only — never results or error bodies.
249
259
 
260
+ ## Done, and what you say at the end
261
+
262
+ A first session is done when the customer has seen one experience answer from
263
+ their own data, in the test window, and been invited to try it. Not when the
264
+ gates pass, and not when there is nothing left to improve.
265
+
266
+ If they ask *"how do I put this in front of my users?"*, the honest answer is:
267
+
268
+ > What you've seen runs in a test window on your machine. Putting it in front
269
+ > of your users is a separate step, and the Foldspace team does it with you.
270
+ > Reach them through the website - <https://foldspace.ai/demo> - or at
271
+ > <support@foldspace.ai>. Everything built today carries over: the actions are
272
+ > already in your Foldspace account, and the code is in this folder.
273
+
274
+ Never run `foldspace deploy`, never install cloud tooling, never ask them for
275
+ cloud credentials: `deploy` is Foldspace-internal and no customer can run it.
276
+
250
277
  ## Verification gates
251
278
 
252
279
  Do not report success without all six:
@@ -288,14 +315,17 @@ Do not report success without all six:
288
315
  MCP (`search_docs`, `fetch_page`) before asserting how the platform behaves;
289
316
  cite the page, or say plainly that the docs do not cover it and write down
290
317
  what you learned.
291
- 4. **Do not send chat messages to test.** Every message is a real conversation
292
- in the customer's list. `runTask` creates no conversation record use it.
293
- 5. **Test mode is off by default.** A new agent has no traffic to pollute, and
294
- watching real conversations appear is the point of the first run. Arm it
295
- only when the tenant already has real conversations and then on **every**
296
- instance via `armAllInstances`, because `foldspace.agent({apiName})` returns
297
- the overlay handle, which is not the one serving an embedded chat. Nothing
298
- can confirm it is on afterwards; never send probe messages to find out.
318
+ 4. **Test through the agent, then let them.** In a first session, talk to the
319
+ agent yourself to prove an experience - type the real question into its
320
+ panel in the test window and read the reply - and only then invite the
321
+ customer to try it. Those conversations appear in their Foldspace dashboard,
322
+ and that is wanted: seeing their own conversations recorded is part of what
323
+ they came to see. Never pass `--test-mode` in a first session.
324
+ 5. **Test mode is for an agent that already has real users.** Then `attach
325
+ --test-mode` keeps your conversations out of their data. It is not a safety
326
+ net: publishing still changes the live agent. Working on an agent in
327
+ production - test mode, pinned action versions, what may be published - is
328
+ a separate playbook, not this one.
299
329
  6. **The human is not necessarily a developer.** Never hand over a terminal
300
330
  line, a stack trace, or a choice between flags. Name experiences by outcome
301
331
  — *"show my plan and credits"*, not `get_account_status`. Their two jobs:
@@ -408,10 +438,14 @@ placeholder path and fields with what you observed. They match the harness
408
438
  version you have installed, and every one is built and run on each release.
409
439
 
410
440
  L0 → `who-is-the-user`. L2 → `find-by-name`, and `pick-from-a-list` when the
411
- user has to choose. App does not use a bearer token → `swap-the-login-method`.
441
+ user has to choose. Show one account on a card → `account-overview`. Upload a
442
+ CSV of contacts → `upload-contacts`. Save a note on a meeting →
443
+ `update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
444
+ attention, with reasons → `opportunities-at-risk`.
445
+ App does not use a bearer token → `swap-the-login-method`.
412
446
  Each says how many production builds carry the pattern and what they learned.
413
- If nothing fits — there is no L1 card recipe yet say so and build from
414
- `agent/actions/_example.ts`.
447
+ If nothing fits — there is no L1 card recipe yet, and no navigation recipe
448
+ say so and build from `agent/actions/_example.ts`.
415
449
 
416
450
  ## What you already have
417
451
 
package/README.md CHANGED
@@ -252,7 +252,7 @@ and **neither had all five**:
252
252
  | 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 |
253
253
  | 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 |
254
254
  | `agentIds` enumeration | `agent({apiName})` returns the OVERLAY handle; arming it on an embedded copilot leaves real conversations untagged |
255
- | `--no-test-mode` | Test mode was armed unconditionally, hiding exactly what initial setup needs to see |
255
+ | `--test-mode` (off by default) | Test mode was armed unconditionally, hiding exactly what initial setup needs to see; now it is opt-in |
256
256
  | Honest badge | It asserted `TEST MODE` whether or not test mode was on |
257
257
 
258
258
  Two further changes came from packaging it: the bundle is **injected** rather
package/bin/attach.mjs CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  } from "../src/attach-preflight.mjs";
45
45
  import { serializeDiagnostic } from "../src/diagnostics.mjs";
46
46
  import { recordEvent } from "../src/session-events.mjs";
47
+ import { withoutStealingFocus } from "../src/keep-focus.mjs";
47
48
  import {
48
49
  ERROR_CODES,
49
50
  createLifecycleResult,
@@ -135,11 +136,13 @@ try {
135
136
  }
136
137
  const bootstrap = attachMode === ATTACH_MODES.BOOTSTRAP;
137
138
  const replace = attachMode === ATTACH_MODES.REPLACE;
138
- // Test mode keeps conversations out of the customer's default list. Turn it off
139
- // only when you WANT the conversations and action calls to show up in the
140
- // Foldspace dashboard a brand-new agent during initial setup, where there is
141
- // no production traffic to pollute.
142
- const noTestMode = process.argv.includes("--no-test-mode");
139
+ // Test mode keeps conversations out of the customer's default list. It is OFF
140
+ // by default: in a first session the customer's own conversations appearing in
141
+ // their dashboard is the point - they see what Foldspace records - and an
142
+ // orange TEST MODE label would imply a safety (nothing published) that does
143
+ // not exist. --test-mode opts in, for an agent that already has real users.
144
+ // --no-test-mode is still accepted so older instructions keep working.
145
+ const noTestMode = !process.argv.includes("--test-mode");
143
146
  // The badge used to hardcode "TEST MODE" whether or not test mode was on — a
144
147
  // string that asserts you are safe while you are not. It now states what is
145
148
  // actually true. --no-badge drops it entirely, for recording a demo.
@@ -171,8 +174,8 @@ const agentKey = cfgTarget.overrideKey || `EU-${productId}-1-1`;
171
174
  const sdkUrl = cfgTarget.sdkUrl || cfg.sdkUrl;
172
175
  const actionTarget = { productId, agentApiName };
173
176
  const badgeText = noTestMode
174
- ? `FOLDSPACE DEV \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 LIVE`
175
- : `FOLDSPACE DEV \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 TEST MODE`;
177
+ ? `FOLDSPACE AGENT BUILDER \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 LIVE`
178
+ : `FOLDSPACE AGENT BUILDER \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 TEST MODE`;
176
179
 
177
180
  // Refuse to run against an uninitialised template. Without this, attach injects
178
181
  // the literal placeholder as an agent api name and the page fails with an opaque
@@ -218,19 +221,69 @@ const SDK_PATTERNS = [
218
221
  "*://*/*eucera.js*",
219
222
  ];
220
223
 
224
+ // The badge has three states, set from outside with \`foldspace badge\`:
225
+ // label - the small corner tag (default): who is attached, which mode
226
+ // working - a bar across the top: the agent is building in this window,
227
+ // please leave it alone
228
+ // ready - a bar: it is the human's turn, with the question to try
229
+ // The state lives in sessionStorage so it survives navigation inside the
230
+ // test window; \`foldspace badge\` writes it and the script redraws.
221
231
  const badgeSrc = `(() => {
222
232
  if (window.top !== window.self) return;
223
- const add = () => {
224
- if (document.getElementById("foldspace-dev-badge") || !document.body) return;
225
- const b = document.createElement("div");
226
- b.id = "foldspace-dev-badge";
227
- b.textContent = ${JSON.stringify(badgeText)};
228
- 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)";
229
- document.body.appendChild(b);
233
+ const LABEL = ${JSON.stringify(badgeText)};
234
+ const KEY = "__foldspace_badge__";
235
+ const read = () => {
236
+ try { return JSON.parse(sessionStorage.getItem(KEY) || "null") || { state: "label" }; }
237
+ catch (e) { return { state: "label" }; }
230
238
  };
231
- add();
232
- document.addEventListener("DOMContentLoaded", add);
233
- setInterval(add, 2000);
239
+ const draw = () => {
240
+ if (!document.body) return;
241
+ const s = read();
242
+ if (s.state === "off") { document.getElementById("foldspace-dev-badge")?.remove(); return; }
243
+ let b = document.getElementById("foldspace-dev-badge");
244
+ if (!b) { b = document.createElement("div"); b.id = "foldspace-dev-badge"; document.body.appendChild(b); }
245
+ const want = s.state + "|" + (s.text || "");
246
+ if (b.dataset.fsState === want) return;
247
+ b.dataset.fsState = want;
248
+ const base = "position:fixed;top:0;left:0;z-index:2147483647;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;pointer-events:none;box-shadow:0 1px 6px rgba(0,0,0,.35);";
249
+ if (s.state === "working") {
250
+ // Attention, not decoration: a pulsing marker, the ask in bold, and an
251
+ // animated line underneath so it reads as "in progress" at a glance.
252
+ b.style.cssText = base + "right:0;background:#1F2A8A;padding:10px 14px 12px;font-size:13px;letter-spacing:.02em;text-align:center;line-height:1.3;";
253
+ b.innerHTML =
254
+ '<style>@keyframes fsPulse{0%,100%{opacity:1}50%{opacity:.25}}@keyframes fsSweep{0%{left:-30%}100%{left:100%}}' +
255
+ '#foldspace-dev-badge .fs-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#FFB300;margin-right:10px;vertical-align:middle;animation:fsPulse 1s ease-in-out infinite}' +
256
+ '#foldspace-dev-badge .fs-line{position:absolute;left:0;right:0;bottom:0;height:3px;background:rgba(255,255,255,.15);overflow:hidden}' +
257
+ '#foldspace-dev-badge .fs-line::after{content:"";position:absolute;top:0;width:30%;height:100%;background:#FFB300;animation:fsSweep 1.4s linear infinite}</style>' +
258
+ '<span class="fs-dot"></span>Foldspace is building your agent on this page. <b style="color:#FFD54F">Please leave this window alone</b> until Claude says it is ready.' +
259
+ (s.text ? " " + s.text.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]) : "") +
260
+ '<span class="fs-line"></span>';
261
+ } else if (s.state === "ready") {
262
+ // Calm, and useful: the question to try, with a Copy button so nobody
263
+ // retypes it into the panel. The bar accepts clicks only on that button.
264
+ const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" })[c]);
265
+ const question = s.text ? esc(s.text) : "";
266
+ b.style.cssText = base + "right:0;background:#0F9D58;padding:10px 14px;font-size:13px;letter-spacing:.02em;text-align:center;line-height:1.3;pointer-events:none;";
267
+ b.innerHTML =
268
+ '<style>#foldspace-dev-badge .fs-copy{pointer-events:auto;margin-left:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.7);border-radius:4px;background:transparent;color:#fff;font:inherit;font-size:12px;cursor:pointer;letter-spacing:0}' +
269
+ '#foldspace-dev-badge .fs-copy:hover{background:rgba(255,255,255,.15)}</style>' +
270
+ '<b>Ready - your turn.</b> ' +
271
+ (question ? 'Try asking: <span class="fs-q">' + question + '</span><button class="fs-copy" type="button">Copy</button>' : "Open your agent and try it.");
272
+ const btn = b.querySelector(".fs-copy");
273
+ if (btn) btn.addEventListener("click", () => {
274
+ const q = b.querySelector(".fs-q")?.textContent || "";
275
+ const done = () => { btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); };
276
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(q).then(done, done);
277
+ else { const ta = document.createElement("textarea"); ta.value = q; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (e) {} ta.remove(); done(); }
278
+ });
279
+ } else {
280
+ b.style.cssText = base + "background:#3247F2;padding:5px 10px;border-bottom-right-radius:4px;";
281
+ b.textContent = LABEL;
282
+ }
283
+ };
284
+ draw();
285
+ document.addEventListener("DOMContentLoaded", draw);
286
+ setInterval(draw, 1000);
234
287
  })();`;
235
288
 
236
289
  // Flag every local session as test traffic so it is excluded from analytics and
@@ -696,7 +749,7 @@ async function prepare(ws, sessionId, url) {
696
749
  ),
697
750
  });
698
751
  console.log(` attached -> ${url}`);
699
- await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
752
+ await withoutStealingFocus(() => call(ws, "Page.reload", { ignoreCache: true }, sessionId));
700
753
 
701
754
  {
702
755
  const verification = await verifyPreparedPage(ws, sessionId);
@@ -951,7 +1004,7 @@ const ver = await connect(owned.version);
951
1004
  console.log(`Attached to ${ver.Browser} on :${port}`);
952
1005
  console.log(`Mode: ${attachMode}`);
953
1006
  console.log(`Agent: ${agentApiName} (product ${productId}, ${agentMode})`);
954
- console.log(`Test: ${noTestMode ? "OFF — conversations WILL appear in the dashboard" : "on"}`);
1007
+ console.log(`Test: ${noTestMode ? "off — conversations appear in the dashboard (pass --test-mode to hide them)" : "on"}`);
955
1008
  console.log(`Hosts: ${hostPatterns.join(", ")}`);
956
1009
  console.log(`Serving: ${path.relative(root, bundlePath)}`);
957
1010
  console.log(
@@ -1011,7 +1064,7 @@ async function cleanupPreparedSession(
1011
1064
  // identity aliases alike.
1012
1065
  if (reload) {
1013
1066
  try {
1014
- await call(socket, "Page.reload", { ignoreCache: true }, sessionId);
1067
+ await withoutStealingFocus(() => call(socket, "Page.reload", { ignoreCache: true }, sessionId));
1015
1068
  } catch (error) {
1016
1069
  failures.push(`restore reload: ${error instanceof Error ? error.message : String(error)}`);
1017
1070
  }
package/bin/badge.mjs ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * foldspace badge <working|ready|label|off> [--text "<what to show>"]
4
+ *
5
+ * Sets the banner the attach badge draws across the top of the test window.
6
+ * The agent and the human share that one Chrome window, so the banner says
7
+ * whose turn it is:
8
+ * working - "Foldspace is building your agent on this page. Please leave
9
+ * this window alone until Claude says it is ready."
10
+ * ready - "Ready - your turn. Open your agent and try it." (pass --text
11
+ * with the question to try)
12
+ * label - back to the small corner tag
13
+ * off - remove it
14
+ * The state is kept in the page's sessionStorage, so it survives navigation;
15
+ * attach's badge script redraws within a second. Nothing else on the page is
16
+ * touched.
17
+ */
18
+ import { CdpPage, parseArgs, printJson } from "../src/cdp-client.mjs";
19
+ import { resolveProjectDir } from "../src/upgrade.mjs";
20
+ import { badgePayload } from "../src/badge-core.mjs";
21
+
22
+ const projectDir = resolveProjectDir();
23
+ const { flags, rest } = parseArgs(process.argv.slice(2), ["text"]);
24
+
25
+ let payload;
26
+ try {
27
+ const unknown = Object.keys(flags).filter((name) => name !== "text");
28
+ if (unknown.length) throw new Error(`unknown option '--${unknown[0]}'`);
29
+ if (rest.length > 1) throw new Error(`unexpected argument '${rest[1]}'`);
30
+ payload = badgePayload(rest[0], flags.text);
31
+ } catch (error) {
32
+ console.error(`badge: ${error instanceof Error ? error.message : String(error)}`);
33
+ process.exit(1);
34
+ }
35
+
36
+ let page;
37
+ try {
38
+ page = await CdpPage.open({ projectDir });
39
+ const shown = await page.evaluate(
40
+ `sessionStorage.setItem("__foldspace_badge__", ${JSON.stringify(JSON.stringify(payload))});
41
+ const b = document.getElementById("foldspace-dev-badge");
42
+ return { url: location.href.split(/[?#]/)[0], hadBadge: Boolean(b) };`,
43
+ );
44
+ printJson({ ok: true, state: payload.state, ...(payload.text ? { text: payload.text } : {}), ...shown });
45
+ } catch (error) {
46
+ printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
47
+ process.exit(1);
48
+ } finally {
49
+ page?.close();
50
+ }
package/bin/inject.mjs CHANGED
@@ -144,7 +144,7 @@ fs.writeFileSync(
144
144
  path.join(themeDir, "manifest.json"),
145
145
  JSON.stringify({
146
146
  manifest_version: 3,
147
- name: "Foldspace Dev Theme",
147
+ name: "Foldspace Agent Builder Theme",
148
148
  version: "1.0",
149
149
  description: "Paints the dev browser Foldspace blue.",
150
150
  theme: {
@@ -197,7 +197,7 @@ function nameProfile() {
197
197
  fs.mkdirSync(path.dirname(file), { recursive: true });
198
198
  fs.writeFileSync(file, JSON.stringify(data));
199
199
  };
200
- const label = `Foldspace Dev \u2014 ${target.name}`;
200
+ const label = `Foldspace Agent Builder \u2014 ${target.name}`;
201
201
  writeMerged(path.join(profileDir, "Local State"), (d) => {
202
202
  d.profile = d.profile || {};
203
203
  d.profile.info_cache = d.profile.info_cache || {};
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 { withoutStealingFocus } from "../src/keep-focus.mjs";
25
26
  import {
26
27
  NAV_ITEM_SELECTOR,
27
28
  NAV_SCOPE_SELECTOR,
@@ -330,7 +331,7 @@ async function runScreen(page, flags) {
330
331
  let reached;
331
332
 
332
333
  if (destination) {
333
- await page.send("Page.navigate", { url: destination.url });
334
+ await withoutStealingFocus(() => page.send("Page.navigate", { url: destination.url }));
334
335
  reached = { how: "goto", path: destination.path };
335
336
  if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
336
337
  }
@@ -360,7 +361,7 @@ async function runScreen(page, flags) {
360
361
  }
361
362
  if (!reached) {
362
363
  // Nothing to reach: reload so this screen's own requests are seen.
363
- await page.send("Page.reload");
364
+ await withoutStealingFocus(() => page.send("Page.reload"));
364
365
  reached = { how: "reload" };
365
366
  }
366
367
  await network.settle(waitMs);
@@ -456,7 +457,7 @@ async function runAuth(page, flags) {
456
457
  const waitMs = numberFlag(flags.wait, 5000, "wait");
457
458
  await page.send("Network.enable");
458
459
  const network = recordNetwork(page);
459
- await page.send("Page.reload");
460
+ await withoutStealingFocus(() => page.send("Page.reload"));
460
461
  await network.settle(waitMs);
461
462
 
462
463
  const url = await page.evaluate("return location.href;");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
package/recipes/INDEX.md CHANGED
@@ -7,9 +7,14 @@ Read this whole table, then open the recipe closest to the outcome.
7
7
  | [`who-is-the-user`](who-is-the-user/) | **L0** | Foldspace knows who is signed in — id, email, name, role, subscription — so conversations and analytics are not anonymous | Runs once at start-up. No action, no card | 4 production builds |
8
8
  | [`find-by-name`](find-by-name/) | **L2** | The agent turns a name the user said into the id the next action needs; a count when no name is given; every tie when the name is ambiguous; near-misses when nothing matches | A data action. **No card** | 3 production builds |
9
9
  | [`pick-from-a-list`](pick-from-a-list/) | **L2** | A clickable list, shown **only** when the user has to choose — with "none of these" and cancel | An action with a card that waits for the user | 1 production build |
10
+ | [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page whose starters change with the screen the user is on — configured from code, the way customers do it | Page set-up, runs once at start-up | 2 |
10
11
  | [`swap-the-login-method`](swap-the-login-method/) | any | `apiFetch` for an app that authenticates with a custom header, or with cookies, instead of a bearer token | A replacement `agent/utils.ts` | 2 production builds (3 builds, 3 different schemes) |
12
+ | [`account-overview`](account-overview/) | **L2** | One account on a card: identity, pipeline total, contacts, cases, meetings. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
13
+ | [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An action with a card that waits for the user | 1 production build |
14
+ | [`update-meeting-notes`](update-meeting-notes/) | **L4** | A note is saved on a meeting and the new note id comes back | A write. **No card** | 1 production build |
15
+ | [`prepare-for-a-meeting`](prepare-for-a-meeting/) | **L2** | Meeting facts are gathered in code, then a task agent writes the summary | A data action plus a task agent. **No card** | 1 production build |
16
+ | [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
11
17
 
12
18
  **Not here yet — no production build proves it:** the user's own plan or
13
19
  settings drawn as a card (L1). In production that data is sent to `identify`
14
- (`who-is-the-user`), not rendered. Navigation (L3), writes (L4), uploads and
15
- task agents are also still to come.
20
+ (`who-is-the-user`), not rendered. Navigation (L3) is also still to come.
@@ -0,0 +1,26 @@
1
+ # Account overview — L2
2
+
3
+ **The user says** "show me this account". **The user sees** the account card: identity, phone, revenue, a metrics bar, and tabs for opportunities, meetings, cases, and contacts.
4
+
5
+ **Proven by 1 production build.**
6
+
7
+ This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths and re-sample `brand.ts` if the product is not this dark theme.
8
+
9
+ The card is view-only. `awaitUserInput` stays off. For a component that only shows something, the model is told that the information was displayed — nothing `execute` returned and nothing the card drew. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 full of nulls is signed out when the account has no id and no name.
10
+
11
+ In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`.
12
+
13
+ ## Adapt it
14
+
15
+ | File | Change |
16
+ |---|---|
17
+ | `agent/api/accounts.ts` | Mock path and envelope. Find a real API that returns a similar shape |
18
+ | `agent/accounts.ts` | Which fields a summary carries. The pipeline total is summed here |
19
+ | `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths. Re-sample `brand.ts` when the host product is not this dark theme |
20
+
21
+ ## What that build learned the hard way
22
+
23
+ - **The card is the experience.** A rich object returned from `execute` does not reach the model when the action only draws.
24
+ - **Add the money in code.** Line items summed by the model eventually come out wrong. The card shows the total the code computed.
25
+ - **The links only work where the app listens.** "Open Account", the rows and "View All" post a message the build's single-page app routed on; other apps do not. ⚠️ Wire that message in the host app, or replace the links with a navigation route.
26
+ - **A signed-out response often looks empty.** Zeros and nulls on a 200 are not an account with no pipeline. Check for an id and a name before drawing numbers.
@@ -0,0 +1,159 @@
1
+ // Totals are computed here, from the API's own numbers. The model does not add them.
2
+
3
+ import type { OverviewPayload } from "./api/accounts";
4
+
5
+ export type AccountSummary = {
6
+ id: string;
7
+ name: string;
8
+ industry: string | null;
9
+ website: string | null;
10
+ phone: string | null;
11
+ revenue: number | null;
12
+ employeeCount: number | null;
13
+ };
14
+
15
+ export type OpportunitySummary = {
16
+ id: string;
17
+ name: string;
18
+ value: number;
19
+ stage: string | null;
20
+ closeDate: string | null;
21
+ };
22
+
23
+ export type ContactSummary = {
24
+ id: string;
25
+ name: string;
26
+ email: string | null;
27
+ phone: string | null;
28
+ title: string | null;
29
+ };
30
+
31
+ export type CaseSummary = {
32
+ id: string;
33
+ title: string;
34
+ priority: string | null;
35
+ status: string | null;
36
+ createdAt: string | null;
37
+ };
38
+
39
+ export type MeetingSummary = {
40
+ id: string;
41
+ title: string;
42
+ startTime: string | null;
43
+ attendees: number;
44
+ };
45
+
46
+ export type AccountMetrics = {
47
+ openOpportunities: number;
48
+ pipelineValue: number;
49
+ contacts: number;
50
+ openCases: number;
51
+ };
52
+
53
+ export type AccountOverview = {
54
+ account: AccountSummary;
55
+ metrics: AccountMetrics;
56
+ opportunities: OpportunitySummary[];
57
+ contacts: ContactSummary[];
58
+ openCases: CaseSummary[];
59
+ recentMeetings: MeetingSummary[];
60
+ };
61
+
62
+ /** A 200 full of nulls is not an account. Zeros are only real once an id and a name are present. */
63
+ export function belongsToSignedInUser(payload: OverviewPayload): boolean {
64
+ const id = payload.account?.id;
65
+ const name = payload.account?.name;
66
+ return typeof id === "string" && id.trim() !== "" && typeof name === "string" && name.trim() !== "";
67
+ }
68
+
69
+ export function formatMoney(value: number): string {
70
+ const rounded = Math.round(value);
71
+ const sign = rounded < 0 ? "-" : "";
72
+ const digits = String(Math.abs(rounded));
73
+ const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
74
+ return `${sign}$${grouped}`;
75
+ }
76
+
77
+ /** The metrics bar shortens the pipeline: $18K, $2.4M. Row amounts stay full. */
78
+ export function formatPipeline(value: number): string {
79
+ const amount = Math.abs(value);
80
+ const sign = value < 0 ? "-" : "";
81
+ if (amount >= 1_000_000) return `${sign}$${(amount / 1_000_000).toFixed(1)}M`;
82
+ if (amount >= 1000) return `${sign}$${(amount / 1000).toFixed(0)}K`;
83
+ return `${sign}$${amount.toFixed(0)}`;
84
+ }
85
+
86
+ export function formatWhen(iso: string | null, withTime = false): string | null {
87
+ if (!iso) return null;
88
+ const date = new Date(iso);
89
+ if (Number.isNaN(date.getTime())) return null;
90
+ const day = date.toLocaleDateString();
91
+ if (!withTime) return day;
92
+ const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
93
+ return `${day} at ${time}`;
94
+ }
95
+
96
+ export function titleCase(value: string): string {
97
+ return value
98
+ .split(/[_\s]+/)
99
+ .filter(Boolean)
100
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
101
+ .join(" ");
102
+ }
103
+
104
+ const text = (value: string | null | undefined, fallback: string) => value?.trim() || fallback;
105
+
106
+ export function toOverview(payload: OverviewPayload): AccountOverview {
107
+ const account = payload.account ?? {};
108
+ const opportunities = (payload.opportunities ?? []).map((row) => ({
109
+ id: row.id?.trim() || "",
110
+ name: text(row.name, "Untitled"),
111
+ value: typeof row.value === "number" && Number.isFinite(row.value) ? row.value : 0,
112
+ stage: row.stage?.trim() || null,
113
+ closeDate: row.closeDate?.trim() || null,
114
+ }));
115
+ const contacts = (payload.contacts ?? []).map((row) => ({
116
+ id: row.id?.trim() || "",
117
+ name: text(row.name, "Untitled"),
118
+ email: row.email?.trim() || null,
119
+ phone: row.phone?.trim() || null,
120
+ title: row.title?.trim() || null,
121
+ }));
122
+ const openCases = (payload.openCases ?? []).map((row) => ({
123
+ id: row.id?.trim() || "",
124
+ title: text(row.title, "Untitled"),
125
+ priority: row.priority?.trim() || null,
126
+ status: row.status?.trim() || null,
127
+ createdAt: row.createdAt?.trim() || null,
128
+ }));
129
+ const recentMeetings = (payload.recentMeetings ?? []).map((row) => ({
130
+ id: row.id?.trim() || "",
131
+ title: text(row.title, "Untitled"),
132
+ startTime: row.startTime?.trim() || null,
133
+ attendees: Array.isArray(row.attendees) ? row.attendees.length : 0,
134
+ }));
135
+ const revenue = account.revenue;
136
+ const employees = account.employeeCount;
137
+
138
+ return {
139
+ account: {
140
+ id: String(account.id),
141
+ name: String(account.name).trim(),
142
+ industry: account.industry?.trim() || null,
143
+ website: account.website?.trim() || null,
144
+ phone: account.phone?.trim() || null,
145
+ revenue: typeof revenue === "number" && Number.isFinite(revenue) ? revenue : null,
146
+ employeeCount: typeof employees === "number" && Number.isFinite(employees) ? employees : null,
147
+ },
148
+ metrics: {
149
+ openOpportunities: opportunities.length,
150
+ pipelineValue: opportunities.reduce((sum, row) => sum + row.value, 0),
151
+ contacts: contacts.length,
152
+ openCases: openCases.length,
153
+ },
154
+ opportunities,
155
+ contacts,
156
+ openCases,
157
+ recentMeetings,
158
+ };
159
+ }