@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
@@ -0,0 +1,394 @@
1
+ // The import card from the production build. textContent only — never innerHTML
2
+ // with file data. Styles go in `header`. The write runs only after Import.
3
+ // Done is what finishes the card.
4
+
5
+ import { importContacts } from "../api/contacts";
6
+ import { contactName, outcomeFrom, parseContactsCsv, toDraft, type ImportOutcome, type ParsedContact } from "../contacts";
7
+ import { renderFailure, type ApiFailure } from "../utils";
8
+ import { brand } from "./brand";
9
+
10
+ const CSS = `
11
+ .fs-up{background:${brand.surface};border-radius:${brand.radius};padding:16px;font-family:${brand.font};
12
+ color:${brand.text};border:1px solid ${brand.border};width:100%;box-sizing:border-box}
13
+ .fs-up *{box-sizing:border-box;margin:0}
14
+ .fs-up-head{margin-bottom:16px}
15
+ .fs-up-title{font-size:16px;font-weight:600;color:${brand.textStrong};margin-bottom:6px;display:flex;align-items:center;gap:6px}
16
+ .fs-up-sub{font-size:12px;color:${brand.muted};line-height:1.4}
17
+ .fs-up-icon{width:20px;height:20px;color:${brand.accent};flex-shrink:0}
18
+ .fs-up-icon.is-ok{color:${brand.ok}}
19
+ .fs-up-zone{border:2px dashed ${brand.border};border-radius:6px;padding:24px 16px;text-align:center;cursor:pointer;
20
+ background:rgba(96,165,250,.05)}
21
+ .fs-up-zone:hover,.fs-up-zone.is-drag{border-color:${brand.accent};background:rgba(96,165,250,.1)}
22
+ .fs-up-zone svg{width:40px;height:40px;margin:0 auto 12px;color:${brand.accent};display:block}
23
+ .fs-up-zone-text{font-size:13px;color:${brand.text};margin-bottom:6px}
24
+ .fs-up-hint{font-size:11px;color:${brand.faint}}
25
+ .fs-up-file{display:none}
26
+ .fs-up-sum{display:flex;gap:12px;margin-bottom:12px;padding:12px;background:rgba(30,41,59,.5);border-radius:6px;
27
+ flex-wrap:wrap;justify-content:center}
28
+ .fs-up-stat{display:flex;flex-direction:column;gap:2px;flex:1;min-width:80px;align-items:center;text-align:center}
29
+ .fs-up-num{font-size:24px;font-weight:600;color:${brand.textStrong}}
30
+ .fs-up-num.is-ok{color:${brand.ok}}
31
+ .fs-up-num.is-warn{color:${brand.warn}}
32
+ .fs-up-label{font-size:11px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px}
33
+ .fs-up-scroll{max-height:250px;overflow:auto;border:1px solid ${brand.border};border-radius:6px;margin-bottom:12px}
34
+ .fs-up-table{width:100%;border-collapse:collapse;font-size:12px}
35
+ .fs-up-table th{background:${brand.tableHead};padding:8px 10px;text-align:left;font-weight:600;color:${brand.muted};
36
+ text-transform:uppercase;font-size:10px;letter-spacing:.5px;border-bottom:1px solid ${brand.border}}
37
+ .fs-up-table td{padding:8px 10px;border-bottom:1px solid ${brand.border};color:#E2E8F0}
38
+ .fs-up-ready,.fs-up-invalid{display:inline-flex;align-items:center;padding:4px 8px;border-radius:12px;font-size:11px;font-weight:500}
39
+ .fs-up-ready{background:rgba(52,211,153,.15);color:${brand.ok}}
40
+ .fs-up-invalid{background:rgba(248,113,113,.15);color:${brand.bad}}
41
+ .fs-up-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px;flex-wrap:wrap}
42
+ .fs-up-btn{padding:8px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;font-family:inherit}
43
+ .fs-up-primary{background:${brand.accent};color:${brand.textStrong};border:none}
44
+ .fs-up-primary:hover{background:${brand.accentDeep}}
45
+ .fs-up-primary:disabled{background:${brand.disabled};cursor:not-allowed}
46
+ .fs-up-secondary{background:transparent;border:1px solid ${brand.border};color:${brand.text}}
47
+ .fs-up-spin{display:inline-block;width:16px;height:16px;border:2px solid ${brand.border};border-top-color:${brand.accent};
48
+ border-radius:50%;animation:fs-up-spin .8s linear infinite}
49
+ .fs-up-spin.is-lg{width:32px;height:32px;border-width:2px}
50
+ @keyframes fs-up-spin{to{transform:rotate(360deg)}}
51
+ .fs-up-wait{text-align:center;padding:40px}
52
+ .fs-up-wait-text{margin-top:16px;color:${brand.muted};font-size:14px}
53
+ .fs-up-section{margin-top:12px}
54
+ .fs-up-section-title{font-size:13px;font-weight:600;color:${brand.text};margin-bottom:8px}
55
+ .fs-up-list{max-height:200px;overflow:auto;font-size:12px;line-height:1.5;padding:8px;background:rgba(30,41,59,.3);border-radius:6px}
56
+ .fs-up-item{padding:6px 0;border-bottom:1px solid rgba(52,66,86,.5);display:flex;flex-direction:column;gap:4px}
57
+ .fs-up-name{color:#E2E8F0;font-weight:500}
58
+ .fs-up-email{color:${brand.faint};font-size:11px}
59
+ .fs-up-reason{color:${brand.warn};font-size:10px;font-style:italic}
60
+ .fs-up-tag{background:rgba(96,165,250,.15);color:${brand.accent};padding:3px 8px;border-radius:10px;font-size:10px;font-weight:500}
61
+ .fs-up-rule{height:1px;background:${brand.border};margin:12px 0}
62
+ .fs-up-error{padding:10px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);border-radius:6px;
63
+ color:${brand.bad};font-size:12px;line-height:1.4}
64
+ `;
65
+
66
+ const UPLOAD_PATH = "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12";
67
+ const FILE_PATH = "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z";
68
+ const REVIEW_PATH = "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4";
69
+ const DONE_PATH = "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z";
70
+ const UPLOAD_COPY = "Upload a CSV file with your contacts. The file should include columns for firstName, lastName, and email (required). Optional columns: phone, title, accountName.";
71
+
72
+ export type UploadHandlers = {
73
+ onCancel: () => void;
74
+ onEmpty: () => void;
75
+ onDone: (outcome: ImportOutcome) => void;
76
+ onFailed: (failure: ApiFailure) => void;
77
+ };
78
+
79
+ type CsvFile = { text: () => Promise<string> };
80
+
81
+ function el(tag: string, className: string, text?: string): HTMLElement {
82
+ const node = document.createElement(tag);
83
+ node.className = className;
84
+ if (text !== undefined) node.textContent = text;
85
+ return node;
86
+ }
87
+
88
+ function button(className: string, text: string): HTMLButtonElement {
89
+ const node = el("button", className, text) as HTMLButtonElement;
90
+ node.type = "button";
91
+ return node;
92
+ }
93
+
94
+ function icon(className: string, pathD: string, strokeWidth = "2"): SVGElement | null {
95
+ if (typeof document.createElementNS !== "function") return null;
96
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
97
+ svg.setAttribute("class", className);
98
+ svg.setAttribute("fill", "none");
99
+ svg.setAttribute("stroke", "currentColor");
100
+ svg.setAttribute("viewBox", "0 0 24 24");
101
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
102
+ path.setAttribute("stroke-linecap", "round");
103
+ path.setAttribute("stroke-linejoin", "round");
104
+ path.setAttribute("stroke-width", strokeWidth);
105
+ path.setAttribute("d", pathD);
106
+ svg.appendChild(path);
107
+ return svg;
108
+ }
109
+
110
+ function toggleClass(node: HTMLElement, name: string, on: boolean): void {
111
+ const names = node.className.split(/\s+/).filter(Boolean);
112
+ const has = names.includes(name);
113
+ if (on && !has) node.className = [...names, name].join(" ");
114
+ if (!on && has) node.className = names.filter((item) => item !== name).join(" ");
115
+ }
116
+
117
+ // The production build's single-page app listened for this message and routed
118
+ // on it. No other app does — on any other product this click does nothing.
119
+ // Open a record with a navigation route instead (CLAUDE.md, "Product defaults").
120
+ function go(path: string): void {
121
+ const top = window.top;
122
+ if (top && typeof top.postMessage === "function") {
123
+ top.postMessage({ type: "NAVIGATE", path }, "*");
124
+ }
125
+ }
126
+
127
+ function dash(value: string | null | undefined): string {
128
+ const text = value?.trim();
129
+ return text ? text : "-";
130
+ }
131
+
132
+ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handlers: UploadHandlers): void {
133
+ const style = el("style", "");
134
+ style.textContent = CSS;
135
+ header.append(style);
136
+
137
+ let settled = false;
138
+ let busy = false;
139
+ let parsed: ParsedContact[] = [];
140
+
141
+ const report = (draw: () => void, send: () => void) => {
142
+ if (settled) return;
143
+ settled = true;
144
+ draw();
145
+ send();
146
+ };
147
+
148
+ const mount = (card: HTMLElement) => {
149
+ host.textContent = "";
150
+ host.append(card);
151
+ };
152
+
153
+ const heading = (title: string, subtitle: string, mark: SVGElement | null): HTMLElement => {
154
+ const head = el("div", "fs-up-head");
155
+ const row = el("div", "fs-up-title");
156
+ if (mark) row.append(mark);
157
+ row.append(el("span", "", title));
158
+ head.append(row, el("div", "fs-up-sub", subtitle));
159
+ return head;
160
+ };
161
+
162
+ const actions = (...nodes: HTMLElement[]): HTMLElement => {
163
+ const row = el("div", "fs-up-actions");
164
+ row.append(...nodes);
165
+ return row;
166
+ };
167
+
168
+ const stat = (label: string, value: string, tone?: "ok" | "warn"): HTMLElement => {
169
+ const node = el("div", "fs-up-stat");
170
+ node.append(el("div", "fs-up-label", label), el("div", tone ? `fs-up-num is-${tone}` : "fs-up-num", value));
171
+ return node;
172
+ };
173
+
174
+ const showUpload = () => {
175
+ const card = el("div", "fs-up");
176
+ card.append(heading("Import Contacts from CSV", UPLOAD_COPY, icon("fs-up-icon", UPLOAD_PATH)));
177
+
178
+ const zone = el("div", "fs-up-zone");
179
+ const mark = icon("", FILE_PATH, "1.5");
180
+ if (mark) zone.append(mark);
181
+ zone.append(el("div", "fs-up-zone-text", "Click to select a CSV file or drag and drop"));
182
+ zone.append(el("div", "fs-up-hint", "Supports: .csv files"));
183
+
184
+ const input = document.createElement("input");
185
+ input.type = "file";
186
+ input.className = "fs-up-file";
187
+ input.accept = ".csv";
188
+ zone.append(input);
189
+
190
+ const readFile = async (file: CsvFile | undefined) => {
191
+ if (settled || busy || !file) return;
192
+ const csv = await file.text();
193
+ parsed = parseContactsCsv(csv);
194
+ if (parsed.length === 0) {
195
+ report(
196
+ () => {
197
+ const empty = el("div", "fs-up");
198
+ empty.append(heading("Import Contacts from CSV", UPLOAD_COPY, icon("fs-up-icon", UPLOAD_PATH)));
199
+ empty.append(el("div", "fs-up-error", "The file had no contacts."));
200
+ mount(empty);
201
+ },
202
+ () => handlers.onEmpty(),
203
+ );
204
+ return;
205
+ }
206
+ showPreview();
207
+ };
208
+
209
+ zone.addEventListener("click", () => input.click());
210
+ input.addEventListener("click", (event) => {
211
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
212
+ });
213
+ input.addEventListener("change", () => {
214
+ const file = input.files?.[0] as CsvFile | undefined;
215
+ void readFile(file);
216
+ });
217
+ zone.addEventListener("dragover", (event) => {
218
+ if (typeof event.preventDefault === "function") event.preventDefault();
219
+ toggleClass(zone, "is-drag", true);
220
+ });
221
+ zone.addEventListener("dragleave", () => toggleClass(zone, "is-drag", false));
222
+ zone.addEventListener("drop", (event) => {
223
+ if (typeof event.preventDefault === "function") event.preventDefault();
224
+ toggleClass(zone, "is-drag", false);
225
+ const transfer = "dataTransfer" in event ? event.dataTransfer : null;
226
+ const file = transfer?.files?.[0] as CsvFile | undefined;
227
+ void readFile(file);
228
+ });
229
+
230
+ const cancel = button("fs-up-btn fs-up-secondary", "Cancel");
231
+ cancel.addEventListener("click", () => {
232
+ report(() => mount(el("div", "fs-up")), () => handlers.onCancel());
233
+ });
234
+
235
+ card.append(zone, actions(cancel));
236
+ mount(card);
237
+ };
238
+
239
+ const showPreview = () => {
240
+ const ready = parsed.filter((row) => row.valid);
241
+ const skipped = parsed.length - ready.length;
242
+ const accounts = new Set(parsed.map((row) => row.accountName?.trim()).filter((name): name is string => Boolean(name)));
243
+
244
+ const card = el("div", "fs-up");
245
+ card.append(
246
+ heading(
247
+ "Review Contacts",
248
+ "Review the contacts below before importing. Invalid contacts will be skipped.",
249
+ icon("fs-up-icon", REVIEW_PATH),
250
+ ),
251
+ );
252
+
253
+ const summary = el("div", "fs-up-sum");
254
+ summary.append(stat("Total Contacts", String(parsed.length)));
255
+ summary.append(stat("Ready to Import", String(ready.length), "ok"));
256
+ if (skipped > 0) summary.append(stat("Will Be Skipped", String(skipped), "warn"));
257
+ summary.append(stat("Accounts", String(accounts.size)));
258
+ card.append(summary);
259
+
260
+ const table = el("table", "fs-up-table");
261
+ const head = document.createElement("thead");
262
+ const headRow = document.createElement("tr");
263
+ for (const label of ["Name", "Email", "Title", "Account", "Status"]) {
264
+ headRow.append(el("th", "", label));
265
+ }
266
+ head.append(headRow);
267
+ const body = document.createElement("tbody");
268
+ for (const row of parsed) {
269
+ const line = document.createElement("tr");
270
+ line.append(
271
+ el("td", "", contactName(row) || "-"),
272
+ el("td", "", dash(row.email)),
273
+ el("td", "", dash(row.title)),
274
+ el("td", "", dash(row.accountName)),
275
+ );
276
+ const status = document.createElement("td");
277
+ const badge = el("span", row.valid ? "fs-up-ready" : "fs-up-invalid", row.valid ? "Ready" : "Invalid");
278
+ if (!row.valid && row.reason) badge.title = row.reason;
279
+ status.append(badge);
280
+ line.append(status);
281
+ body.append(line);
282
+ }
283
+ table.append(head, body);
284
+ const scroll = el("div", "fs-up-scroll");
285
+ scroll.append(table);
286
+ card.append(scroll);
287
+
288
+ const back = button("fs-up-btn fs-up-secondary", "Back");
289
+ back.addEventListener("click", () => {
290
+ if (settled || busy) return;
291
+ parsed = [];
292
+ showUpload();
293
+ });
294
+ const confirm = button("fs-up-btn fs-up-primary", `Import ${ready.length} Contacts`);
295
+ confirm.disabled = ready.length === 0;
296
+ confirm.addEventListener("click", async () => {
297
+ if (settled || busy || ready.length === 0) return;
298
+ busy = true;
299
+ showImporting(ready.length);
300
+ const res = await importContacts(ready.map(toDraft));
301
+ if (!res.ok) {
302
+ report(
303
+ () => renderFailure(host, res, { notFound: "No import endpoint answered." }),
304
+ () => handlers.onFailed(res),
305
+ );
306
+ return;
307
+ }
308
+ showResults(outcomeFrom(parsed, res.data));
309
+ });
310
+ card.append(actions(back, confirm));
311
+ mount(card);
312
+ };
313
+
314
+ const showImporting = (count: number) => {
315
+ const card = el("div", "fs-up");
316
+ const row = el("div", "fs-up-title");
317
+ row.append(el("span", "fs-up-spin"), el("span", "", "Importing Contacts..."));
318
+ const head = el("div", "fs-up-head");
319
+ head.append(row, el("div", "fs-up-sub", "Please wait while we import your contacts. This may take a moment for large files."));
320
+ const wait = el("div", "fs-up-wait");
321
+ wait.append(el("div", "fs-up-spin is-lg"), el("div", "fs-up-wait-text", `Processing ${count} contacts...`));
322
+ card.append(head, wait);
323
+ mount(card);
324
+ };
325
+
326
+ const showResults = (outcome: ImportOutcome) => {
327
+ const created = outcome.rows.filter((row) => !row.reason);
328
+ const skipped = outcome.rows.filter((row) => row.reason);
329
+ const card = el("div", "fs-up");
330
+
331
+ if (outcome.created > 0) {
332
+ const noun = outcome.created === 1 ? "contact" : "contacts";
333
+ card.append(
334
+ heading(
335
+ "Import Complete",
336
+ `Successfully imported ${outcome.created} ${noun}.`,
337
+ icon("fs-up-icon is-ok", DONE_PATH),
338
+ ),
339
+ );
340
+ }
341
+
342
+ const summary = el("div", "fs-up-sum");
343
+ summary.append(stat("Imported", String(outcome.created), "ok"));
344
+ summary.append(stat("Skipped", String(outcome.skipped), "warn"));
345
+ card.append(summary);
346
+
347
+ if (created.length > 0) {
348
+ const section = el("div", "fs-up-section");
349
+ section.append(el("div", "fs-up-section-title", `Imported Contacts (${created.length})`));
350
+ const list = el("div", "fs-up-list");
351
+ for (const row of created) {
352
+ const item = el("div", "fs-up-item");
353
+ const who = el("div", "");
354
+ who.append(el("span", "fs-up-name", row.name), el("span", "fs-up-email", ` - ${dash(row.email)}`));
355
+ item.append(who);
356
+ const account = parsed.find((source) => source.email === row.email)?.accountName;
357
+ if (account) item.append(el("span", "fs-up-tag", account));
358
+ list.append(item);
359
+ }
360
+ section.append(list);
361
+ card.append(section);
362
+ }
363
+
364
+ if (skipped.length > 0) {
365
+ card.append(el("div", "fs-up-rule"));
366
+ const section = el("div", "fs-up-section");
367
+ section.append(el("div", "fs-up-section-title", `Skipped Contacts (${skipped.length})`));
368
+ const list = el("div", "fs-up-list");
369
+ for (const row of skipped) {
370
+ const item = el("div", "fs-up-item");
371
+ const who = el("div", "");
372
+ who.append(el("span", "fs-up-name", row.name), el("span", "fs-up-email", ` - ${dash(row.email)}`));
373
+ item.append(who, el("span", "fs-up-reason", row.reason ?? ""));
374
+ list.append(item);
375
+ }
376
+ section.append(list);
377
+ card.append(section);
378
+ }
379
+
380
+ const finish = () => report(() => {}, () => handlers.onDone(outcome));
381
+ const view = button("fs-up-btn fs-up-secondary", "View Contacts");
382
+ view.addEventListener("click", () => {
383
+ // callback first: a navigation that unloads the page must not lose it.
384
+ finish();
385
+ go("/__observe_me/contacts");
386
+ });
387
+ const done = button("fs-up-btn fs-up-primary", "Done");
388
+ done.addEventListener("click", finish);
389
+ card.append(actions(view, done));
390
+ mount(card);
391
+ };
392
+
393
+ showUpload();
394
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "created": 2,
3
+ "skipped": 0,
4
+ "createdContacts": [
5
+ { "name": "Dana Reyes", "email": "dana@example.com" },
6
+ { "name": "Sam Lee", "email": "sam@example.com" }
7
+ ],
8
+ "skippedContacts": []
9
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "title": "Upload contacts",
3
+ "level": "L4",
4
+ "family": "upload",
5
+ "kind": "action",
6
+ "action": "upload_contacts",
7
+ "entry": "agent/actions/upload_contacts.ts",
8
+ "outcome": "A CSV is previewed, then written only after the user confirms",
9
+ "provenBy": 1
10
+ }
@@ -39,6 +39,15 @@ export function guardAttachMode({
39
39
  if (characterization.status === "no-sdk") {
40
40
  return { ok: true, mode };
41
41
  }
42
+ // The bootstrap script is added on every new document, so after the tab
43
+ // navigates within the app the SDK on the page is OUR bootstrap, carrying
44
+ // the configured agent. That is the expected state, not a foreign SDK:
45
+ // refusing it made the agent vanish on every in-app navigation
46
+ // (Buildium, 2026-09-22). Only a page whose SDK belongs to someone else
47
+ // is a real mismatch.
48
+ if (characterization.status === "same-agent") {
49
+ return { ok: true, mode, reattached: true };
50
+ }
42
51
  return {
43
52
  ok: false,
44
53
  mode,
@@ -0,0 +1,22 @@
1
+ // Pure logic behind `foldspace badge`, unit-tested.
2
+
3
+ export const BADGE_STATES = ["working", "ready", "label", "off"];
4
+
5
+ /** What gets written into the page for a badge state, or throws on bad input. */
6
+ export function badgePayload(state, text) {
7
+ const wanted = String(state || "").trim().toLowerCase();
8
+ if (!BADGE_STATES.includes(wanted)) {
9
+ throw new Error(`usage: foldspace badge <${BADGE_STATES.join("|")}> [--text "<what to show>"]`);
10
+ }
11
+ const payload = { state: wanted };
12
+ if (text !== undefined) {
13
+ if (wanted !== "working" && wanted !== "ready") {
14
+ throw new Error(`--text only applies to working or ready (got ${wanted}).`);
15
+ }
16
+ const clean = String(text).replace(/\s+/g, " ").trim();
17
+ if (!clean) throw new Error("--text is empty.");
18
+ if (clean.length > 160) throw new Error("--text is longer than 160 characters; keep it to one line.");
19
+ payload.text = clean;
20
+ }
21
+ return payload;
22
+ }
@@ -241,9 +241,10 @@ export const CLI_COMMANDS = Object.freeze([
241
241
  ),
242
242
  value("--agent", "api-name", "Override the configured agent API name"),
243
243
  flag(
244
- "--no-test-mode",
245
- "Do not mark conversations as test traffic; use deliberately",
244
+ "--test-mode",
245
+ "Mark this session's conversations as test traffic, so they stay out of the dashboard; for an agent that already has real users",
246
246
  ),
247
+ flag("--no-test-mode", "Accepted for older instructions; test mode is already off by default"),
247
248
  flag("--no-badge", "Hide the visible Foldspace development badge"),
248
249
  flag(
249
250
  "--daemon",
@@ -261,7 +262,7 @@ export const CLI_COMMANDS = Object.freeze([
261
262
  effects: [
262
263
  "May reload and instrument matching target pages",
263
264
  "Refuses a Chrome that is not the profile inject launched",
264
- "Test mode is enabled unless --no-test-mode is passed",
265
+ "Test mode is off unless --test-mode is passed: conversations appear in the dashboard",
265
266
  "Never directly invokes an action handler",
266
267
  "An empty local action registry is valid; named actions are not required",
267
268
  "Restores prepared pages when detached cleanly",
@@ -316,6 +317,34 @@ export const CLI_COMMANDS = Object.freeze([
316
317
  ],
317
318
  next: ["Record what you established in docs/app-profile.md", "Write the handler"],
318
319
  }),
320
+ Object.freeze({
321
+ name: "badge",
322
+ entry: "badge.mjs",
323
+ group: "verify",
324
+ summary: "Set the banner across the top of the test window: working, ready, label or off",
325
+ usage: 'foldspace badge <working|ready|label|off> [--text "<what to show>"]',
326
+ risk: "browser-session",
327
+ environment: "local-chrome",
328
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT"],
329
+ capabilities: ["browser.cdp", "page.evaluate"],
330
+ positionals: [
331
+ Object.freeze({
332
+ name: "state",
333
+ required: true,
334
+ description:
335
+ "working: the agent is building here, leave the window alone | ready: the human's turn | label: the small corner tag | off: remove it",
336
+ }),
337
+ ],
338
+ options: [
339
+ value("--text", "text", "working/ready: the line to show, e.g. the question to try (one line, up to 160 characters)"),
340
+ ],
341
+ prerequisites: ["foldspace attach is running (it draws the badge)"],
342
+ effects: [
343
+ "Writes one key to the test window's sessionStorage; touches nothing else on the page",
344
+ "The banner survives navigation inside the test window",
345
+ ],
346
+ next: ["Tell the human in chat what the banner says"],
347
+ }),
319
348
  Object.freeze({
320
349
  name: "deploy",
321
350
  // Foldspace-internal: it uploads to Foldspace's own storage, which no
@@ -0,0 +1,49 @@
1
+ // The test Chrome must never steal the keyboard. Chrome raises its window when
2
+ // a page navigates or reloads over CDP, and a customer typing somewhere else
3
+ // then types into their live product (seen 2026-09-22). Nothing in CDP stops
4
+ // that; on macOS the fix is to hand focus straight back to whatever app the
5
+ // human was using. Elsewhere this is a no-op.
6
+
7
+ import { execFile } from "node:child_process";
8
+
9
+ const FRONT_APP_SCRIPT =
10
+ 'tell application "System Events" to get bundle identifier of first application process whose frontmost is true';
11
+
12
+ function osascript(script) {
13
+ return new Promise((resolve) => {
14
+ // 4s, not less: the first call wakes System Events, which alone can take
15
+ // over a second, and a timeout here silently leaves focus on Chrome.
16
+ execFile("osascript", ["-e", script], { timeout: 4000 }, (error, stdout) => {
17
+ resolve(error ? null : String(stdout).trim());
18
+ });
19
+ });
20
+ }
21
+
22
+ /** Bundle id of the app in front right now, or null (not macOS, or unknown). */
23
+ export async function frontApp() {
24
+ if (process.platform !== "darwin") return null;
25
+ return osascript(FRONT_APP_SCRIPT);
26
+ }
27
+
28
+ /**
29
+ * Run `action` (which will make Chrome raise its window), then give focus back
30
+ * to the app that had it. If Chrome itself was in front, nothing to restore.
31
+ */
32
+ export async function withoutStealingFocus(action, { front = frontApp, restore = restoreFront } = {}) {
33
+ const before = await front();
34
+ try {
35
+ return await action();
36
+ } finally {
37
+ if (before && !/google\.chrome|chromium/i.test(before)) await restore(before);
38
+ }
39
+ }
40
+
41
+ export async function restoreFront(bundleId) {
42
+ if (process.platform !== "darwin" || !bundleId) return;
43
+ // Twice, a beat apart: Chrome's activation lands after the navigation
44
+ // settles, so one immediate restore can be overtaken.
45
+ const script = `tell application id "${bundleId.replace(/"/g, "")}" to activate`;
46
+ await osascript(script);
47
+ await new Promise((resolve) => setTimeout(resolve, 400));
48
+ await osascript(script);
49
+ }