@foldspace_npm/harness 0.1.27 → 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 (118) hide show
  1. package/CLAUDE.md +301 -188
  2. package/README.md +54 -36
  3. package/bin/ask.mjs +9 -7
  4. package/bin/attach.mjs +80 -51
  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 +1 -1
  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 +3 -3
  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 +14 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +65 -46
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/lint/index.mjs +8 -1
  100. package/src/lint/rules.mjs +240 -43
  101. package/src/observe-core.mjs +163 -34
  102. package/src/region.mjs +1 -1
  103. package/src/runtime/agent.ts +16 -12
  104. package/src/runtime/http.ts +25 -15
  105. package/src/runtime/match.ts +1 -1
  106. package/src/runtime/render.ts +4 -4
  107. package/src/transports/README.md +2 -2
  108. package/src/upgrade.mjs +33 -10
  109. package/templates/agent-starter/CLAUDE.md +8 -5
  110. package/templates/agent-starter/README.md +32 -33
  111. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  112. package/templates/agent-starter/agent/actions/index.ts +2 -2
  113. package/templates/agent-starter/agent/constants.ts +4 -3
  114. package/templates/agent-starter/agent/utils.ts +15 -18
  115. package/templates/agent-starter/claude-settings.json +1 -1
  116. package/templates/agent-starter/docs/app-profile.md +19 -14
  117. package/templates/agent-starter/foldspace.dev.json +1 -1
  118. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
@@ -57,7 +57,7 @@ const IMPERATIVE_PATTERNS = [
57
57
  const EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
58
58
 
59
59
  const PROMPT_FIX =
60
- "Put the wording in the action or agent instructions via MCP or Agent Studio; return data only.";
60
+ "Put the wording in the action's instructions (with the Foldspace tools or in Agent Studio) or in the agent's instructions (Agent Studio only); return data only.";
61
61
 
62
62
  function mergeBindings(fn, sourceFile) {
63
63
  return new Map([
@@ -98,7 +98,7 @@ function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, or
98
98
  sourceFile,
99
99
  node: at,
100
100
  file: relative,
101
- message: `${origin} sends the copilot operating instructions ('${text.slice(0, 80)}').`,
101
+ message: `${origin} sends the agent operating instructions ('${text.slice(0, 80)}').`,
102
102
  fix: PROMPT_FIX,
103
103
  }),
104
104
  );
@@ -128,19 +128,29 @@ export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
128
128
  }
129
129
 
130
130
  if (name === "render") {
131
- const cbName = callbackParamName(fn);
132
- forEachCallNamed(fn, cbName, (call) => {
133
- const arg = call.arguments[0];
134
- if (!arg) return;
135
- inspectReturnedObject(
136
- resolveNode(arg, bindings),
137
- bindings,
138
- sourceFile,
139
- relative,
140
- findings,
141
- "callback",
142
- );
143
- });
131
+ // What a card hands to callback, and to readyState (render's 6th
132
+ // parameter), reaches the agent as the card's result - check both.
133
+ const params = fn.parameters || [];
134
+ const readyName =
135
+ params.length >= 6 && ts.isIdentifier(params[5].name) ? params[5].name.text : null;
136
+ for (const [paramName, origin] of [
137
+ [callbackParamName(fn), "callback"],
138
+ [readyName, "readyState"],
139
+ ]) {
140
+ if (!paramName) continue;
141
+ forEachCallNamed(fn, paramName, (call) => {
142
+ const arg = call.arguments[0];
143
+ if (!arg) return;
144
+ inspectReturnedObject(
145
+ resolveNode(arg, bindings),
146
+ bindings,
147
+ sourceFile,
148
+ relative,
149
+ findings,
150
+ origin,
151
+ );
152
+ });
153
+ }
144
154
  }
145
155
  });
146
156
  }
@@ -208,8 +218,8 @@ function reportEmoji(node, sourceFile, relative, findings) {
208
218
  sourceFile,
209
219
  node,
210
220
  file: relative,
211
- message: "Emoji in handler or widget copy reads as off-brand in a product UI that has none.",
212
- fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the MCP cannot write that field, so each new action's Studio instructions must carry the line too.",
221
+ message: "Emoji in handler or card copy reads as off-brand in a product UI that has none.",
222
+ fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the Foldspace tools cannot write that field, so each new action's Studio instructions must carry the line too.",
213
223
  }),
214
224
  );
215
225
  }
@@ -450,30 +460,218 @@ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, pro
450
460
  }
451
461
  }
452
462
 
453
- // The panel saves every rendered card to the conversation history as an image
454
- // drawn with html2canvas, which cannot parse modern colour functions: one such
455
- // value and the saved card is blank ("data:," where the card was - seen on a
456
- // CRM, 2026-09-22). Hex and rgba survive.
457
- const UNSNAPSHOTTABLE_COLOR = /\b(oklch|oklab|lch|lab|color-mix|light-dark)\s*\(/i;
458
-
459
- export function ruleNoUnsnapshottableColor(sourceFile, relative, findings) {
460
- forEachStringLiteral(sourceFile, (node) => {
461
- if (ts.isImportDeclaration(node.parent) || ts.isExportDeclaration(node.parent)) return;
462
- const text = node.getText(sourceFile);
463
- const match = UNSNAPSHOTTABLE_COLOR.exec(text);
464
- if (!match) return;
465
- findings.push(
466
- finding({
467
- rule: "no-unsnapshottable-color",
468
- severity: "error",
469
- sourceFile,
470
- node,
471
- file: relative,
472
- message: `${match[1]}() in a card: the panel saves cards to history as an image drawn with html2canvas, which cannot read it - the saved card comes out blank.`,
473
- fix: "Use plain hex or rgba. Do not read the app's CSS variables either: on modern apps they resolve to oklch.",
474
- }),
475
- );
476
- });
463
+ // The card contract of the page SDK. A card is a view card or an input card.
464
+ // An input card's render uses one of its 4th to 6th parameters - callback,
465
+ // cancel, readyState - and the agent waits until callback or cancel runs, or
466
+ // until readyState reports. A view card uses none of them: as soon as render
467
+ // returns, the agent is told it was shown, with everything execute returned.
468
+ // awaitUserInput is never needed. The chat panel ignores it; in headless mode
469
+ // awaitUserInput: true forces the wait, so on a view card the agent waits for
470
+ // a callback that never comes.
471
+ //
472
+ // "Uses" is read the way the SDK reads render in the built bundle, where
473
+ // parameters are renamed and types are erased: the parameter's name anywhere
474
+ // in the body, nested functions included, but not as a property name
475
+ // (`host.callback`, `{ callback: 1 }`) and not inside a type. A destructured
476
+ // parameter, a `...rest` parameter or `arguments` count as used. A TypeScript
477
+ // `this` parameter is erased, so it takes no position.
478
+ const CARD_PARAMS = ["callback", "cancel", "readyState"];
479
+
480
+ function mentions(fn, test) {
481
+ let found = false;
482
+ function walk(node) {
483
+ if (found) return;
484
+ if (ts.isIdentifier(node) && test(node.text)) {
485
+ const parent = node.parent;
486
+ const isName =
487
+ parent &&
488
+ (ts.isPropertyAccessExpression(parent) ||
489
+ ts.isPropertyAssignment(parent) ||
490
+ ts.isMethodDeclaration(parent) ||
491
+ ts.isPropertyDeclaration(parent) ||
492
+ ts.isGetAccessorDeclaration(parent) ||
493
+ ts.isSetAccessorDeclaration(parent)) &&
494
+ parent.name === node;
495
+ const isKey = parent && ts.isBindingElement(parent) && parent.propertyName === node;
496
+ let inType = false;
497
+ for (let up = parent; up && up !== fn; up = up.parent) {
498
+ if (ts.isTypeNode(up) && !ts.isExpressionWithTypeArguments(up)) {
499
+ inType = true;
500
+ break;
501
+ }
502
+ }
503
+ if (!isName && !isKey && !inType) {
504
+ found = true;
505
+ return;
506
+ }
507
+ }
508
+ ts.forEachChild(node, walk);
509
+ }
510
+ if (fn.body) walk(fn.body);
511
+ return found;
512
+ }
513
+
514
+ /** What the SDK concludes about a render function: does the agent wait, and which card parameters are declared but unused. */
515
+ function readRender(fn) {
516
+ const params = fn.parameters.filter((param) => !(ts.isIdentifier(param.name) && param.name.text === "this"));
517
+ const cardParams = params.slice(3, 3 + CARD_PARAMS.length);
518
+ const restOrArguments = params.some((param) => param.dotDotDotToken) || mentions(fn, (name) => name === "arguments");
519
+ const destructured = cardParams.some((param) => !ts.isIdentifier(param.name));
520
+ const names = cardParams.filter((param) => ts.isIdentifier(param.name)).map((param) => param.name.text);
521
+ const used = names.filter((name) => mentions(fn, (text) => text === name));
522
+ const waits = restOrArguments || destructured || used.length > 0;
523
+ return { waits, declaredUnused: !waits && names.length > 0 ? names : [] };
524
+ }
525
+
526
+ // The function `render` names: inline, a method, or - for `render` and
527
+ // `render: renderCard` - a `function` or `const` of that name in the same file.
528
+ function declaredFunction(sourceFile, name) {
529
+ let found = null;
530
+ function walk(node) {
531
+ if (found) return;
532
+ if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
533
+ found = node;
534
+ return;
535
+ }
536
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && isFunctionLike(node.initializer)) {
537
+ found = node.initializer;
538
+ return;
539
+ }
540
+ ts.forEachChild(node, walk);
541
+ }
542
+ walk(sourceFile);
543
+ return found;
544
+ }
545
+
546
+ function renderFunction(sourceFile, member) {
547
+ if (ts.isMethodDeclaration(member)) return member;
548
+ if (ts.isShorthandPropertyAssignment(member)) return declaredFunction(sourceFile, member.name.text);
549
+ let value = member.initializer;
550
+ while (
551
+ value &&
552
+ (ts.isParenthesizedExpression(value) ||
553
+ ts.isAsExpression(value) ||
554
+ ts.isSatisfiesExpression(value) ||
555
+ ts.isNonNullExpression(value) ||
556
+ ts.isTypeAssertionExpression(value))
557
+ )
558
+ value = value.expression;
559
+ if (isFunctionLike(value)) return value;
560
+ if (value && ts.isIdentifier(value)) return declaredFunction(sourceFile, value.text);
561
+ return null;
562
+ }
563
+
564
+ export function ruleCardContract(sourceFile, relative, findings) {
565
+ function walk(node) {
566
+ if (ts.isObjectLiteralExpression(node)) {
567
+ const members = node.properties;
568
+ const render = members.find(
569
+ (member) =>
570
+ propertyName(member) === "render" &&
571
+ (ts.isPropertyAssignment(member) || ts.isMethodDeclaration(member) || ts.isShorthandPropertyAssignment(member)),
572
+ );
573
+ const fn = render ? renderFunction(sourceFile, render) : null;
574
+ if (fn) {
575
+ const { waits, declaredUnused } = readRender(fn);
576
+ const flag = members.find((member) => propertyName(member) === "awaitUserInput");
577
+ const flagTrue = flag && ts.isPropertyAssignment(flag) && flag.initializer.kind === ts.SyntaxKind.TrueKeyword;
578
+ if (flagTrue && !waits) {
579
+ findings.push(
580
+ finding({
581
+ rule: "card-contract",
582
+ severity: "error",
583
+ sourceFile,
584
+ node: flag.name,
585
+ file: relative,
586
+ message:
587
+ "A view card (render uses neither callback nor cancel) sets awaitUserInput: true. The chat panel ignores it, but in headless mode the agent then waits for a callback that never comes.",
588
+ fix: "Remove awaitUserInput. A view card is reported to the agent as shown, with what execute returned.",
589
+ }),
590
+ );
591
+ }
592
+ if (flag && !waits && !flagTrue) {
593
+ findings.push(
594
+ finding({
595
+ rule: "card-contract",
596
+ severity: "warning",
597
+ sourceFile,
598
+ node: flag.name,
599
+ file: relative,
600
+ message:
601
+ "A view card does not need awaitUserInput: render uses neither callback nor cancel, so the agent is told the card was shown, with what execute returned.",
602
+ fix: "Remove awaitUserInput.",
603
+ }),
604
+ );
605
+ }
606
+ if (flag && waits) {
607
+ findings.push(
608
+ finding({
609
+ rule: "card-contract",
610
+ severity: "warning",
611
+ sourceFile,
612
+ node: flag.name,
613
+ file: relative,
614
+ message:
615
+ "An input card does not need awaitUserInput: render uses callback, cancel or readyState, and that is what makes the agent wait.",
616
+ fix: 'Remove awaitUserInput. Then check that every path calls callback or cancel - choosing, "none of these", closing the card, failing. When the conversation should go on while the user decides, call readyState(value) once the card is drawn.',
617
+ }),
618
+ );
619
+ }
620
+ if (declaredUnused.length > 0) {
621
+ const list =
622
+ declaredUnused.length === 1
623
+ ? declaredUnused[0]
624
+ : `${declaredUnused.slice(0, -1).join(", ")} and ${declaredUnused.at(-1)}`;
625
+ const unused = declaredUnused.length === 1 ? "never uses it" : "uses none of them";
626
+ findings.push(
627
+ finding({
628
+ rule: "card-contract",
629
+ severity: "warning",
630
+ sourceFile,
631
+ node: render.name,
632
+ file: relative,
633
+ message: `render declares ${list} but ${unused} - the SDK treats the card as a view card: the agent is told it was shown, with what execute returned.`,
634
+ fix: `For a view card, remove ${list} from render. For an input card, call callback or cancel on every path, or readyState(value) once the card is drawn.`,
635
+ }),
636
+ );
637
+ }
638
+ }
639
+ }
640
+ ts.forEachChild(node, walk);
641
+ }
642
+ walk(sourceFile);
643
+ }
644
+
645
+ // Test mode belongs to `attach --test-mode`, which never touches the bundle.
646
+ // A call in agent code that turns it on can ship to production and mark real
647
+ // users' conversations as test traffic. A call to `setTestMode` by name is
648
+ // refused unless its first argument is the literal `false`. A call to
649
+ // `armAllInstances` by name is always refused: even with `false` it sends a
650
+ // partial `setConfiguration` to every instance.
651
+ export function ruleNoTestModeInBundle(sourceFile, relative, findings) {
652
+ const walk = (node) => {
653
+ if (ts.isCallExpression(node)) {
654
+ const callee = node.expression;
655
+ const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : "";
656
+ const first = node.arguments[0];
657
+ const setTestModeOff = name === "setTestMode" && first && first.kind === ts.SyntaxKind.FalseKeyword;
658
+ if ((name === "setTestMode" || name === "armAllInstances") && !setTestModeOff) {
659
+ findings.push(
660
+ finding({
661
+ rule: "no-test-mode-in-bundle",
662
+ severity: "error",
663
+ sourceFile,
664
+ node,
665
+ file: relative,
666
+ message: `\`${name}\` does not belong in agent code: any file in \`agent/\` can ship to production, where test mode would stop real users' conversations being tracked.`,
667
+ fix: "Remove it. Test mode is only for an agent that already has real users, and only through `npx foldspace attach --test-mode`, which never touches the bundle. For an agent with no real users yet, leave test mode off.",
668
+ }),
669
+ );
670
+ }
671
+ }
672
+ ts.forEachChild(node, walk);
673
+ };
674
+ walk(sourceFile);
477
675
  }
478
676
 
479
677
  export function lintActionFile(filePath, relative, findings) {
@@ -482,11 +680,10 @@ export function lintActionFile(filePath, relative, findings) {
482
680
  ruleNoStaticAgentPrompt(sourceFile, relative, findings);
483
681
  ruleNoRunTaskPrompt(sourceFile, relative, findings);
484
682
  ruleNoEmoji(sourceFile, relative, findings);
485
- ruleNoUnsnapshottableColor(sourceFile, relative, findings);
683
+ ruleCardContract(sourceFile, relative, findings);
486
684
  }
487
685
 
488
686
  export function lintViewsFile(filePath, relative, findings) {
489
687
  const sourceFile = parseFile(filePath);
490
688
  ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
491
- ruleNoUnsnapshottableColor(sourceFile, relative, findings);
492
689
  }
@@ -44,7 +44,7 @@ export function isSameSite(url, pageUrl, hosts = []) {
44
44
  }
45
45
 
46
46
  /**
47
- * One product often lives on several hosts (www., analyze., admin. ...), and a
47
+ * One product often lives on several hosts (www., reports., admin. ...), and a
48
48
  * project only works on the hosts it names. Given addresses seen from a page,
49
49
  * say which hosts look like the same product but are NOT covered, and the one
50
50
  * pattern that would cover them. The site is an approximation (see siteOf), so
@@ -81,7 +81,7 @@ export function uncoveredSiblings(urls, pageUrl, hosts = []) {
81
81
  hint:
82
82
  `This product also lives on ${found.slice(0, 4).map((entry) => entry.host).join(", ")}` +
83
83
  `${found.length > 4 ? ` and ${found.length - 4} more` : ""}, which this project does not cover. ` +
84
- `To work there, add "${pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
84
+ `To work there, add "${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.`,
85
85
  };
86
86
  }
87
87
 
@@ -132,7 +132,7 @@ export function gotoDestination(input, pageUrl, hosts = []) {
132
132
  throw new Error(
133
133
  `--goto: ${target.hostname} is not one of this project's hosts (${hosts.join(", ") || "none named"}). ` +
134
134
  (coverage.pattern
135
- ? `If it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`
135
+ ? `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 --daemon\` with the flags it ran with. If it is not, --goto a screen on this project's hosts.`
136
136
  : "observe only opens screens of the product this project is for."),
137
137
  );
138
138
  }
@@ -398,6 +398,11 @@ export function rankCandidates(candidates, words = [], max = 8) {
398
398
 
399
399
  const WRITE_FLAGS = ["method", "data", "body", "header", "headers", "json", "post", "put", "delete"];
400
400
 
401
+ /** True for an option that would make `observe read` write (`--method`, `--body`, ...). */
402
+ export function isWriteFlag(option) {
403
+ return WRITE_FLAGS.includes(String(option || "").replace(/^--/, ""));
404
+ }
405
+
401
406
  /**
402
407
  * `observe read` is GET only. There is deliberately no way to pick a method,
403
408
  * add a header or send a body: anything else belongs in an action handler the
@@ -407,7 +412,7 @@ export function refuseWriteFlags(flags = {}) {
407
412
  const asked = WRITE_FLAGS.filter((name) => name in flags);
408
413
  if (asked.length) {
409
414
  throw new Error(
410
- `observe read is GET only and never sends a body (refused: ${asked
415
+ `observe read is GET only - it never sets a method, a header or a body (refused: ${asked
411
416
  .map((name) => `--${name}`)
412
417
  .join(", ")}).`,
413
418
  );
@@ -706,8 +711,9 @@ export function looksLoggedIn({ url, hasPasswordField = false, hosts = [] }) {
706
711
 
707
712
  /** Computed colours come back as rgb()/rgba(); anything else passes through. */
708
713
  // oklch / oklab, as modern apps write their palettes and as Chrome reports
709
- // them back, converted to sRGB. Cards must be plain hex: the panel saves them
710
- // to history as an image drawn by a library that cannot read these.
714
+ // them back, converted to sRGB hex like rgb() and rgba(): brandFile checks
715
+ // contrast and works out its other keys from hex. lab(), lch() and color()
716
+ // pass through; brandFile writes a default in their place.
711
717
  function okToHex(text) {
712
718
  const match = /^(oklch|oklab)\(\s*([\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i.exec(text);
713
719
  if (!match) return null;
@@ -770,57 +776,180 @@ function luminance(hex) {
770
776
 
771
777
  const isHex = (value) => /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(String(value || ""));
772
778
 
779
+ /** `share` of the way from one #RRGGBB to another, channel by channel. */
780
+ function mixHex(from, to, share) {
781
+ const channels = (hex) => [1, 3, 5].map((start) => parseInt(hex.slice(start, start + 2), 16));
782
+ const target = channels(to);
783
+ return `#${channels(from)
784
+ .map((value, i) => Math.round(value + (target[i] - value) * share).toString(16).padStart(2, "0"))
785
+ .join("")
786
+ .toUpperCase()}`;
787
+ }
788
+
773
789
  /**
774
790
  * `agent/views/brand.ts` from what `observe styles` sampled: the app's own
775
- * accent, text, border, font and radius, on the panel's white surface. A dark
776
- * app keeps its accent, font and radius; its light-on-dark text and borders
791
+ * accent, text, muted text, border, font, radius and text size, on white (the
792
+ * panel's default background), plus every other key a recipe card reads - a
793
+ * fixed colour (surface2 and the status colours), one worked out from the
794
+ * sampled ones, or another name for one of them. A see-through text, muted or
795
+ * border colour is flattened onto the white; a see-through accent keeps its
796
+ * colour at full strength, so a tinted button still gives the app's own accent.
797
+ * A text, muted, border or accent colour still too light to read on white falls
798
+ * back to its default and says so. A dark app keeps its font, radius, text size
799
+ * and (unless it is too light) its accent; its light-on-dark text and borders
777
800
  * would vanish on white, so those fall back to neutral greys and say so.
778
801
  */
779
- export function brandFile(styles, { url, date, agentPrimary } = {}) {
802
+ export function brandFile(styles, { url, date } = {}) {
780
803
  const notes = [];
781
804
  const pick = (value, fallback, name) => {
782
805
  const hex = value ? toHex(value) : null; // rgb / oklch / oklab -> hex; anything else stays as it was
783
806
  if (isHex(hex)) return hex.slice(0, 7).toUpperCase();
784
- if (value) notes.push(`${name}: sampled "${value}" is not a plain colour - using ${fallback}`);
807
+ if (value) notes.push(`${name}: sampled "${value}" could not be converted to hex - using ${fallback}`);
785
808
  return fallback;
786
809
  };
810
+ // A see-through colour (rgba, or hex with alpha) is drawn on the panel's
811
+ // white, so flatten it onto white: dropping the alpha would turn an
812
+ // rgba(0, 0, 0, 0.12) border, and every key worked out from it, black.
813
+ const onPanel = (value, fallback, name) => {
814
+ const hex = value ? toHex(value) : null;
815
+ const alpha = isHex(hex) && hex.length === 9 ? parseInt(hex.slice(7), 16) / 255 : 1;
816
+ return mixHex("#FFFFFF", pick(value, fallback, name), alpha);
817
+ };
787
818
  const background = pick(styles.background, "#FFFFFF", "background");
788
819
  const darkApp = (luminance(background) ?? 1) < 0.4;
789
- if (darkApp) notes.push(`the app is dark (${background}); cards sit in the white panel, so text, muted and border are neutral greys`);
790
- const text = darkApp ? "#111827" : pick(styles.text, "#111827", "text");
791
- const muted = darkApp ? "#6B7280" : pick(styles.textMuted, "#6B7280", "textMuted");
792
- const border = darkApp ? "#E5E7EB" : pick(styles.border, "#E5E7EB", "border");
820
+ if (darkApp) notes.push(`the app is dark (${background}); cards sit in the panel, white by default, so text, muted and border are neutral greys`);
821
+ // A colour too light to read on white - white nav text on a light app, a
822
+ // white button on a dark one - falls back to its default, and says so.
823
+ const readable = (resolve, value, fallback, name, limit) => {
824
+ const hex = resolve(value, fallback, name);
825
+ if ((luminance(hex) ?? 0) <= limit) return hex;
826
+ notes.push(`${name}: sampled "${value}" would not read on white - using ${fallback}`);
827
+ return fallback;
828
+ };
829
+ const text = darkApp ? "#111827" : readable(onPanel, styles.text, "#111827", "text", 0.6);
830
+ const muted = darkApp ? "#6B7280" : readable(onPanel, styles.textMuted, "#6B7280", "textMuted", 0.75);
831
+ const border = darkApp ? "#E5E7EB" : readable(onPanel, styles.border, "#E5E7EB", "border", 0.97);
793
832
  const buttonBackground = styles.primaryButton?.background;
794
- const accent = isHex(agentPrimary)
795
- ? agentPrimary.slice(0, 7).toUpperCase()
796
- : pick(buttonBackground || styles.link, "#3248F2", "accent");
797
- const accentSource = isHex(agentPrimary) ? "the agent's own primary colour" : buttonBackground ? "the app's primary button" : styles.link ? "the app's links" : "Foldspace blue (nothing sampled)";
798
- const accentText = pick(styles.primaryButton?.color, (luminance(accent) ?? 0) > 0.6 ? "#111827" : "#FFFFFF", "accentText");
833
+ const accentSample = buttonBackground || styles.link;
834
+ // The accent keeps its colour at full strength: a tinted button such as
835
+ // rgba(50, 72, 242, 0.08) still gives the app's own blue, which the cards
836
+ // draw white text on.
837
+ const accent = readable(pick, accentSample, "#3248F2", "accent", 0.85);
838
+ // The accent is the app's only when the sample could be converted to hex
839
+ // and reads on white; otherwise a NOTE says why it is the default.
840
+ const accentFromApp = Boolean(accentSample) && !notes.some((note) => note.startsWith("accent:"));
841
+ const accentSource = !accentSample
842
+ ? "a default blue (nothing sampled)"
843
+ : !accentFromApp
844
+ ? "a default blue (see the NOTE on accent)"
845
+ : buttonBackground
846
+ ? "the app's primary button"
847
+ : "the app's links";
848
+ // Only a solid button's text is kept, and only when the accent is that
849
+ // button's: a see-through button's text was chosen for its tint.
850
+ const buttonHex = toHex(buttonBackground);
851
+ const buttonText = accentFromApp && isHex(buttonHex) && buttonHex.length === 7 ? styles.primaryButton?.color : null;
852
+ const accentText = pick(buttonText, (luminance(accent) ?? 0) > 0.6 ? "#111827" : "#FFFFFF", "accentText");
799
853
  const font = String(styles.fontFamily || "").replace(/["'\\]/g, "").trim() || "Roboto, sans-serif";
800
854
  const radius = /^\d+(\.\d+)?px$/.test(String(styles.boxRadius || "")) ? styles.boxRadius : "8px";
855
+ // The app's most used text size, kept to 12-16px so a card fits the panel.
856
+ const sampledSize = /^(\d+(?:\.\d+)?)px$/.exec(String(styles.fontSize || ""));
857
+ const base = sampledSize ? Math.min(16, Math.max(12, Math.round(Number(sampledSize[1])))) : 14;
858
+ const white = "#FFFFFF";
859
+ const black = "#000000";
860
+ const good = "#047857";
861
+ const warn = "#B45309";
862
+ const derived = {
863
+ textStrong: mixHex(text, black, 0.5),
864
+ faint: mixHex(muted, white, 0.35),
865
+ row: mixHex(border, white, 0.75),
866
+ tableHead: mixHex(border, white, 0.5),
867
+ accentDeep: mixHex(accent, black, 0.15),
868
+ accentSoft: mixHex(accent, white, 0.9),
869
+ headerStart: mixHex(accent, white, 0.92),
870
+ };
871
+ // One shape for every card: each recipe's brand.ts is a placeholder with
872
+ // these keys, in this order.
873
+ const values = {
874
+ surface: white,
875
+ surface2: "#F5F6FA",
876
+ text,
877
+ muted,
878
+ border,
879
+ accent,
880
+ accentText,
881
+ font,
882
+ radius,
883
+ size: { base: `${base}px`, small: `${base - 2}px` },
884
+ ...derived,
885
+ good,
886
+ warn,
887
+ warnInk: "#FEF3C7",
888
+ bad: "#B91C1C",
889
+ primary: accent,
890
+ tint: derived.tableHead,
891
+ disabled: derived.faint,
892
+ money: good,
893
+ ok: good,
894
+ deals: accent,
895
+ contacts: derived.accentDeep,
896
+ cases: warn,
897
+ };
898
+ const groups = {
899
+ textStrong: "Worked out from the values above.",
900
+ good: "Status colours: fixed, so green, amber and red mean the same in every app.",
901
+ primary: "Other names some cards use for the colours above; a new card uses the original key.",
902
+ };
903
+ const comments = {
904
+ surface2: "a second background: a fixed light grey",
905
+ accentText: isHex(toHex(buttonText))
906
+ ? "text on accent: the app's button text"
907
+ : "text on accent: white, or near-black when accent is light",
908
+ size: sampledSize
909
+ ? "base: the app's most used text size, kept to 12-16px; small: 2px less"
910
+ : "base: 14px (no text size was sampled); small: 2px less",
911
+ textStrong: "names and headings: text, halfway to black",
912
+ faint: "hints and empty states: muted, 35% toward white",
913
+ row: "row backgrounds and alternate rows: border, 75% toward white",
914
+ tableHead: "table heads and summary boxes: border, halfway to white",
915
+ accentDeep: "gradients and hover: accent, 15% toward black",
916
+ accentSoft: "chips, selected rows and focus rings: accent, 90% toward white",
917
+ headerStart: "card headers and stage tags: accent, 92% toward white",
918
+ good: "done, valid, amounts",
919
+ warn: "late, at risk, needs a look",
920
+ warnInk: "the light amber behind a warning tag",
921
+ bad: "failed, not valid, critical",
922
+ primary: "same as accent",
923
+ tint: "pick-from-a-list's head and selected row: same as tableHead",
924
+ disabled: "a button that cannot be pressed: same as faint",
925
+ money: "amounts: same as good",
926
+ ok: "same as good",
927
+ deals: "the open-deals count: same as accent",
928
+ contacts: "the contacts count: same as accentDeep",
929
+ cases: "the open-cases count: same as warn",
930
+ };
931
+ const literal = (key, value) =>
932
+ key === "size" ? `{ base: "${value.base}", small: "${value.small}" }` : key === "font" ? `'${value}'` : `"${value}"`;
801
933
  const stamp = `${url || "the app"}${date ? ` on ${date}` : ""}`;
802
934
  const lines = [
803
935
  `// Brand reference for this product's cards. Sampled from ${stamp} with`,
804
- "// `foldspace observe styles --write`. Cards live in the agent's panel, which is",
805
- "// white, so the surface is the panel's; accent, text, border, font and radius",
806
- "// are the app's own. Re-run the command to re-sample; do not edit by eye.",
807
- "// Plain hex only: the panel saves cards to history as an image drawn by a",
808
- "// library that cannot read oklch()/lab().",
936
+ "// `npx foldspace observe styles --write`. Cards live in the agent's panel,",
937
+ "// which is white by default, so surface is white. text, muted, border, accent,",
938
+ "// font, radius and size are the app's own, or a default where they were not",
939
+ "// sampled or would not read on white; every other key's comment, or the",
940
+ "// heading above it, says where it comes from. Keep every key, so any recipe",
941
+ "// card can use this file.",
942
+ "// Re-run the command to re-sample; do not edit by eye.",
809
943
  `// accent: ${accentSource}.`,
810
944
  ...notes.map((note) => `// NOTE ${note}.`),
811
945
  "",
812
946
  "export const brand = {",
813
- ` surface: "#FFFFFF",`,
814
- ` surface2: "#F5F6FA",`,
815
- ` text: "${text}",`,
816
- ` muted: "${muted}",`,
817
- ` border: "${border}",`,
818
- ` accent: "${accent}",`,
819
- ` accentText: "${accentText}",`,
820
- ` font: '${font}',`,
821
- ` radius: "${radius}",`,
947
+ ...Object.entries(values).flatMap(([key, value]) => [
948
+ ...(groups[key] ? [` // ${groups[key]}`] : []),
949
+ ` ${key}: ${literal(key, value)},${comments[key] ? ` // ${comments[key]}` : ""}`,
950
+ ]),
822
951
  "} as const;",
823
952
  "",
824
953
  ];
825
- return { content: lines.join("\n"), notes, values: { text, muted, border, accent, accentText, font, radius } };
954
+ return { content: lines.join("\n"), notes, values };
826
955
  }
package/src/region.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // points at that region's agent, recording and tracking hosts - the US SDK on
8
8
  // an EU page talks to a US backend that does not have the EU agents.
9
9
  //
10
- // `server`: the Product MCP's name in the editor, as the region's playbook
10
+ // `server`: the Foldspace tools' server name in the editor, as the region's playbook
11
11
  // installs it. Distinct per region, so both can sit side by side.
12
12
  export const REGIONS = Object.freeze({
13
13
  us: Object.freeze({
@@ -6,7 +6,7 @@ let cached: any | null = null;
6
6
  * Foldspace SDK handle for this agent's **overlay** instance.
7
7
  *
8
8
  * Takes no `mode` on purpose: `foldspace.agent({ apiName })` returns the overlay
9
- * handle. On an app that embeds the copilot in-page that is not the instance
9
+ * handle. On an app that embeds the agent in-page that is not the instance
10
10
  * serving the chat, so arming only it succeeds and leaves real conversations
11
11
  * untagged. For a specific instance, enumerate `window.foldspace.agentIds()`
12
12
  * (`"<mode>-<apiName>"`) and call `foldspace.agent({ apiName, mode })`.
@@ -25,20 +25,24 @@ export function getAgent(): any | null {
25
25
  }
26
26
 
27
27
  /**
28
- * Arm every Foldspace instance on the page for test mode and remote actions.
28
+ * Turns test mode on or off (`testMode`) on every Foldspace instance on the
29
+ * page (`window.foldspace.agentIds()`) and turns on their remote actions: SDK
30
+ * `setTestMode(testMode)` and `setConfiguration({ remoteActionsSettings })`.
29
31
  *
30
- * Calls SDK `setTestMode` and `setConfiguration({ remoteActionsSettings })`.
31
- * Local `foldspace attach` already injects test-mode arming without putting it
32
- * in `dist/index.js`. Prefer that. Importing this helper from an action can
33
- * ship those calls into the production CDN bundle.
32
+ * It does not belong in agent code: any file in `agent/` can ship to
33
+ * production, where test mode would stop real users' conversations being
34
+ * tracked. Leave it out even with `false`, which lint refuses too: it still
35
+ * calls `setConfiguration` on every instance, and a partial `setConfiguration`
36
+ * can shrink the agent's panel to 0×0. Test mode is only for an agent that
37
+ * already has real users, and only through `npx foldspace attach --test-mode`,
38
+ * which never touches the bundle. For an agent with no real users yet, leave
39
+ * test mode off.
34
40
  *
35
- * Do not call from `execute`. Partial `setConfiguration` has collapsed the
36
- * widget to 0×0 in a real build.
37
- *
38
- * @param testMode - When true, mark conversations as test traffic
39
- * @returns Instance ids that armed vs failed
41
+ * @param testMode - `true` marks new conversations on this page as test
42
+ * traffic; `false` (the default) turns that off.
43
+ * @returns The instance ids it set (`armed`) and the ones that threw (`failed`)
40
44
  */
41
- export function armAllInstances(testMode = true): {
45
+ export function armAllInstances(testMode = false): {
42
46
  armed: string[];
43
47
  failed: string[];
44
48
  } {