@foldspace_npm/harness 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CLAUDE.md +402 -204
  2. package/README.md +84 -36
  3. package/bin/ask.mjs +19 -9
  4. package/bin/attach.mjs +91 -56
  5. package/bin/badge.mjs +28 -8
  6. package/bin/cli.mjs +23 -6
  7. package/bin/inject.mjs +3 -4
  8. package/bin/observe.mjs +22 -6
  9. package/bin/published.mjs +141 -0
  10. package/package.json +6 -2
  11. package/recipes/INDEX.md +13 -8
  12. package/recipes/README.md +30 -7
  13. package/recipes/account-overview/README.md +6 -6
  14. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  15. package/recipes/account-overview/agent/views/brand.ts +23 -14
  16. package/recipes/account-overview/agent/views/overview.ts +13 -17
  17. package/recipes/bottom-bar/README.md +12 -9
  18. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  19. package/recipes/create-a-record/README.md +97 -0
  20. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  21. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  22. package/recipes/create-a-record/agent/projects.ts +145 -0
  23. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  24. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  25. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  26. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  27. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  28. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  29. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  30. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  31. package/recipes/create-a-record/recipe.json +13 -0
  32. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  33. package/recipes/find-by-name/README.md +3 -1
  34. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  35. package/recipes/lookup-then-view/README.md +73 -0
  36. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  37. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  38. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  39. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  40. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  41. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  42. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  43. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  44. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  45. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  46. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  47. package/recipes/lookup-then-view/recipe.json +11 -0
  48. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  49. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  50. package/recipes/opportunities-at-risk/README.md +5 -5
  51. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  52. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  53. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  54. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  55. package/recipes/pick-from-a-list/README.md +7 -6
  56. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  57. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  58. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  59. package/recipes/prepare-for-a-meeting/README.md +4 -4
  60. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  61. package/recipes/records-list/README.md +6 -6
  62. package/recipes/records-list/agent/accounts.ts +3 -1
  63. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  64. package/recipes/records-list/agent/views/brand.ts +26 -14
  65. package/recipes/server-rendered-forms/README.md +35 -0
  66. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  67. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  68. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  69. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  70. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  71. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  72. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  73. package/recipes/server-rendered-forms/recipe.json +10 -0
  74. package/recipes/several-hosts/README.md +10 -13
  75. package/recipes/several-hosts/agent/hosts.ts +19 -136
  76. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  77. package/recipes/several-hosts/recipe.json +3 -3
  78. package/recipes/swap-the-login-method/README.md +16 -5
  79. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  80. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  81. package/recipes/update-meeting-notes/README.md +10 -4
  82. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  83. package/recipes/upload-contacts/README.md +5 -5
  84. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  85. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  86. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  87. package/recipes/who-is-the-user/README.md +7 -3
  88. package/src/ask-core.mjs +16 -4
  89. package/src/attach-daemon.mjs +9 -2
  90. package/src/attach-helpers.mjs +105 -0
  91. package/src/attach-preflight.mjs +34 -11
  92. package/src/badge-core.mjs +1 -1
  93. package/src/cdp-client.mjs +15 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +102 -47
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/keep-focus.mjs +17 -13
  100. package/src/lint/index.mjs +8 -1
  101. package/src/lint/rules.mjs +240 -43
  102. package/src/observe-core.mjs +163 -34
  103. package/src/published-core.mjs +392 -0
  104. package/src/region.mjs +1 -1
  105. package/src/runtime/agent.ts +16 -12
  106. package/src/runtime/http.ts +25 -15
  107. package/src/runtime/match.ts +1 -1
  108. package/src/runtime/render.ts +4 -4
  109. package/src/transports/README.md +2 -2
  110. package/src/upgrade.mjs +33 -10
  111. package/templates/agent-starter/CLAUDE.md +8 -5
  112. package/templates/agent-starter/README.md +32 -33
  113. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  114. package/templates/agent-starter/agent/actions/index.ts +2 -2
  115. package/templates/agent-starter/agent/constants.ts +4 -3
  116. package/templates/agent-starter/agent/utils.ts +15 -18
  117. package/templates/agent-starter/claude-settings.json +2 -1
  118. package/templates/agent-starter/docs/app-profile.md +19 -14
  119. package/templates/agent-starter/foldspace.dev.json +1 -1
  120. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
@@ -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
  }
@@ -0,0 +1,392 @@
1
+ // Pure logic behind `foldspace published <dev|prod>`, unit-tested.
2
+ //
3
+ // The command points the test window's agent at the code published to dev or
4
+ // prod, so `foldspace ask` checks that code instead of the local build. Each
5
+ // refusal here keeps that check honest: while attach runs the page serves the
6
+ // local build, and a page whose agent is not this project's would load another
7
+ // agent's code.
8
+
9
+ import { ACTION_OBSERVER_GLOBAL } from "./action-observer.mjs";
10
+ import { ATTACH_MODES, characterizePageAgents, parseAgentId } from "./attach-helpers.mjs";
11
+ import { detectedProductIds, guardAttachMode } from "./attach-preflight.mjs";
12
+ import { serializeDiagnostic } from "./diagnostics.mjs";
13
+
14
+ export const PUBLISHED_USAGE = "usage: foldspace published <dev|prod>";
15
+
16
+ // The public SDK's values for remoteActionsSettings.environment: "DEV" loads
17
+ // /agent/actions/dev/<product>/<agent>/index.js, anything else - "PROD" is
18
+ // the default - /agent/actions/prod/... (checked against script.eucerahive.io
19
+ // /web/sdk/foldspace.js, built 2026-10-04).
20
+ export const ENVIRONMENTS = Object.freeze({ dev: "DEV", prod: "PROD" });
21
+
22
+ export const ATTACH_RUNNING =
23
+ "Stop attach first: `npx foldspace attach --stop` - while it runs the page serves your local build.";
24
+
25
+ // No daemon runs, yet the page carries the local build again after a reload:
26
+ // an attach started without --daemon is connected, and `attach --stop` (which
27
+ // stops only the daemon) does not reach it.
28
+ export const FOREGROUND_ATTACH =
29
+ "This page still runs a local build after a reload, but no attach daemon runs for this project: an attach started without --daemon is still connected to the test window.";
30
+
31
+ export function foregroundAttachNext(env) {
32
+ return `Stop the attach that was started without --daemon - \`attach --stop\` stops only the daemon - then run \`npx foldspace published ${env}\` again.`;
33
+ }
34
+
35
+ export const RELOAD_NOTE = "A reload puts the page back on what the agent loads normally.";
36
+
37
+ // Every published bundle assigns its handlers here; the SDK reads and deletes it.
38
+ export const REMOTE_ACTIONS_GLOBAL = "__FOLDSPACE_REMOTE_ACTIONS__";
39
+
40
+ // Every refusal below comes after `attach --stop`, so each next step starts by
41
+ // putting the local build back. A platform gap never stops the session.
42
+ const word = (env) => (env === "prod" ? "production" : "dev");
43
+ const lower = (text) => `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
44
+
45
+ /** The page is this project's agent, but this window cannot show the published code. */
46
+ export function notHere(env) {
47
+ return `Run \`npx foldspace attach --daemon\` to put your local build back in the test window, and tell the human in one sentence that the test window cannot show ${word(env)} yet - do not invite them to try ${word(env)} there.`;
48
+ }
49
+
50
+ /** Worth one more run; then the window cannot show it. */
51
+ function again(env) {
52
+ return `Run \`npx foldspace published ${env}\` once more. If it fails again: ${lower(notHere(env))}`;
53
+ }
54
+
55
+ /**
56
+ * The page needs --bootstrap or --replace to attach, which is not a site that
57
+ * loads this agent by itself: the manual sends the rest to the Foldspace team.
58
+ */
59
+ function attachAgain(flag) {
60
+ return `Run \`npx foldspace attach --daemon ${flag}\` to put your local build back in the test window. A page that needs \`${flag}\` cannot show the published code, and production waits until their site loads this agent by itself: for the rest, give the human the Foldspace team answer (CLAUDE.md, "Done, and what you say at the end").`;
61
+ }
62
+
63
+ // How long the agent gets to load the published code. The SDK gives up on the
64
+ // script itself after 10s.
65
+ export const LOAD_TIMEOUT_MS = 20_000;
66
+
67
+ /** "dev" | "prod" (any case) -> { name, sdkValue }, or throws the usage line. */
68
+ export function publishedEnvironment(value) {
69
+ const name = String(value ?? "").trim().toLowerCase();
70
+ if (!Object.hasOwn(ENVIRONMENTS, name)) throw new Error(PUBLISHED_USAGE);
71
+ return { name, sdkValue: ENVIRONMENTS[name] };
72
+ }
73
+
74
+ /** The agent foldspace.dev.json names, as inject recorded it. */
75
+ export function projectAgent(launchTarget = {}, configTarget = {}) {
76
+ const target = { ...configTarget, ...launchTarget };
77
+ if (!target.productId || !target.agentApiName) {
78
+ throw new Error("foldspace.dev.json must define productId and agentApiName.");
79
+ }
80
+ return {
81
+ productId: String(target.productId),
82
+ apiName: String(target.agentApiName),
83
+ mode: String(target.mode || "OVERLAY").toUpperCase(),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Page body: what attach's own preflight reads (inspectSdkState), whether
89
+ * attach prepared this document, and how long ago the document loaded.
90
+ */
91
+ export function buildPageReadScript(agent) {
92
+ return `const state = await ${serializeDiagnostic("inspectSdkState", {
93
+ expectedApiName: agent.apiName,
94
+ })};
95
+ return {
96
+ state,
97
+ attachPrepared: Boolean(window[${JSON.stringify(ACTION_OBSERVER_GLOBAL)}] || window.__FOLDSPACE_DEV_REPLACE__),
98
+ ageMs: Math.round(performance.now()),
99
+ };`;
100
+ }
101
+
102
+ /**
103
+ * Enough of the page has loaded to judge it: its agent is there, and no load
104
+ * of remote actions is in flight (the SDK removes that script once it lands).
105
+ */
106
+ export function pageSettled(read) {
107
+ const state = read?.state;
108
+ if (!state) return false;
109
+ if (read.attachPrepared) return true;
110
+ const ready = state.documentReadyState === "interactive" || state.documentReadyState === "complete";
111
+ const loading = (state.scripts || []).some((source) => String(source).includes("/agent/actions/"));
112
+ return ready && state.sdkReady === true && (state.agentIds || []).length > 0 && !loading;
113
+ }
114
+
115
+ /** The mode the page created this agent in, when it has exactly one instance of it. */
116
+ export function modeOnPage(agentIds = [], apiName) {
117
+ const mine = agentIds.map(parseAgentId).filter((agent) => agent && agent.apiName === apiName);
118
+ return mine.length === 1 ? mine[0].mode : null;
119
+ }
120
+
121
+ /**
122
+ * Is the page's own Foldspace agent this project's agent - same Agent Key, same
123
+ * product? Attach's swap-mode preflight answers exactly that, so it is reused.
124
+ */
125
+ export function checkPublishedPage({ state, agent }) {
126
+ const mode = modeOnPage(state?.agentIds, agent.apiName) || agent.mode;
127
+ const characterization = characterizePageAgents({
128
+ sdkPresent: state?.sdkPresent === true,
129
+ agentIds: state?.agentIds || [],
130
+ expectedApiName: agent.apiName,
131
+ expectedMode: mode,
132
+ });
133
+ const pageProductIds = detectedProductIds(state);
134
+ const guard = guardAttachMode({
135
+ mode: ATTACH_MODES.SWAP,
136
+ characterization,
137
+ expectedProductId: agent.productId,
138
+ pageProductIds,
139
+ });
140
+ if (guard.ok) return { ok: true, mode };
141
+
142
+ const status = characterization.status;
143
+ const next = attachAgain(status === "no-sdk" ? "--bootstrap" : "--replace");
144
+ let reason;
145
+ if (status === "no-sdk") {
146
+ reason = "This page has no Foldspace agent of its own, so there is nothing to load the published code into.";
147
+ } else if (status === "sdk-without-agent") {
148
+ reason = "This page loads Foldspace but has no agent on it.";
149
+ } else if (status === "different-agent") {
150
+ reason = `This page's Foldspace agent is not ${agent.apiName}, the Agent Key in foldspace.dev.json, so foldspace ask would talk to another agent and the check would prove nothing.`;
151
+ } else if (status === "multiple-agents") {
152
+ reason = "This page has more than one Foldspace agent, so it is not clear which one foldspace ask would talk to.";
153
+ } else if (!pageProductIds.length) {
154
+ reason = `The page's agent is ${agent.apiName}, but its product cannot be read from the page, so it is not proven to be this project's agent.`;
155
+ } else {
156
+ reason = `The page's agent is ${agent.apiName} of another product (${pageProductIds.join(", ")}), not ${agent.productId} in foldspace.dev.json: it would load that product's code, and the check would prove nothing.`;
157
+ }
158
+ return {
159
+ ok: false,
160
+ reason,
161
+ next,
162
+ page: { agents: state?.agentIds || [], products: pageProductIds },
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Page body that points the agent instance at its published code and reports
168
+ * what loaded. It calls only the instance's public methods, and watches the
169
+ * one global every published bundle assigns itself to.
170
+ *
171
+ * What in the SDK shapes it:
172
+ * - A published bundle assigns its handlers to window.__FOLDSPACE_REMOTE_ACTIONS__;
173
+ * the SDK reads that global when the script loads, deletes it, and then
174
+ * reports remoteActions.loaded. Watching the global is how this knows the
175
+ * exact handlers the published code brought.
176
+ * - When remote actions load, a handler the instance already has wins over a
177
+ * loaded one of the same name - the page's own, or what its normal load
178
+ * brought, even when that load lands late. So once the published code has
179
+ * loaded, its handlers are set over every one of the same name, and the
180
+ * check counts only the names that answer from the published code.
181
+ * - An agent created late queues these calls until it is ready, and its
182
+ * getAvailableActions() reads {} until then. Nothing is taken from the page
183
+ * before the load: what it had is read only after the load reported.
184
+ * - setConfiguration loads nothing when the settings equal the current ones
185
+ * (the page may already load prod, or ran this before). Turning remote
186
+ * actions off first makes the second call a change.
187
+ * The page remembers what an earlier run brought and what it covered, so dev's
188
+ * handlers do not linger into a prod check.
189
+ */
190
+ export function buildPublishedScript({ apiName, mode, environment, timeoutMs = LOAD_TIMEOUT_MS }) {
191
+ return `
192
+ const NS = typeof window.__FOLD_SPACE__ === "string" && window.__FOLD_SPACE__ ? window.__FOLD_SPACE__ : "foldspace";
193
+ const sdk = window[NS];
194
+ if (typeof sdk?.agent !== "function") return { switched: false, reason: "no-sdk" };
195
+ const agent = sdk.agent({ apiName: ${JSON.stringify(apiName)}, mode: ${JSON.stringify(mode)} });
196
+ const needed = ["getAvailableActions", "removeAiActions", "setAiActions", "setConfiguration", "on", "off"];
197
+ const missing = needed.filter((name) => typeof agent?.[name] !== "function");
198
+ if (missing.length) return { switched: false, reason: "sdk-too-old", missing };
199
+
200
+ const ENV = ${JSON.stringify(environment.name)};
201
+ const store = window.__FOLDSPACE_PUBLISHED__ || (window.__FOLDSPACE_PUBLISHED__ = {});
202
+ const ID = ${JSON.stringify(`${mode.toLowerCase()}-${apiName}`)};
203
+ // own: the page's handler for a name the published code covered, before
204
+ // any run covered it. brought: what the last run set.
205
+ const memo = store[ID]?.brought ? store[ID] : (store[ID] = { own: {}, brought: {} });
206
+
207
+ const GLOBAL = ${JSON.stringify(REMOTE_ACTIONS_GLOBAL)};
208
+ let held = window[GLOBAL];
209
+ let lastRead;
210
+ let watching = true;
211
+ const watch = {
212
+ configurable: true,
213
+ enumerable: true,
214
+ get() {
215
+ lastRead = held;
216
+ queueMicrotask(rearm);
217
+ return held;
218
+ },
219
+ set(value) { held = value; },
220
+ };
221
+ const armed = () => Object.getOwnPropertyDescriptor(window, GLOBAL)?.get === watch.get;
222
+ // The SDK deletes the global right after reading it; watch for the next load.
223
+ function rearm() {
224
+ if (!watching || armed()) return;
225
+ held = undefined;
226
+ try { Object.defineProperty(window, GLOBAL, watch); } catch {}
227
+ }
228
+ try { Object.defineProperty(window, GLOBAL, watch); } catch {}
229
+
230
+ let settle;
231
+ const outcome = new Promise((resolve) => { settle = resolve; });
232
+ const onLoaded = (event) => {
233
+ // The read that just happened belongs to this load, whichever load it is.
234
+ const bundle = lastRead;
235
+ lastRead = undefined;
236
+ if (event?.environment === ENV) {
237
+ settle({ loaded: true, actionCount: Number(event.actionCount) || 0, source: event.source || null, bundle });
238
+ }
239
+ };
240
+ const onFailed = (event) => {
241
+ if (event?.environment === ENV) settle({ loaded: false, source: event.source || null });
242
+ };
243
+ agent.on("remoteActions.loaded", onLoaded);
244
+ agent.on("remoteActions.failed", onFailed);
245
+ let result;
246
+ let timer;
247
+ try {
248
+ agent.setConfiguration({ remoteActionsSettings: { enabled: false } });
249
+ agent.setConfiguration({
250
+ enableDebugLogs: true,
251
+ remoteActionsSettings: { enabled: true, environment: ${JSON.stringify(environment.sdkValue)} },
252
+ });
253
+ result = await Promise.race([
254
+ outcome,
255
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ loaded: null }), ${Number(timeoutMs)}); }),
256
+ ]);
257
+ } catch (error) {
258
+ result = { loaded: false, error: String(error?.message || error).slice(0, 300) };
259
+ } finally {
260
+ clearTimeout(timer);
261
+ agent.off("remoteActions.loaded", onLoaded);
262
+ agent.off("remoteActions.failed", onFailed);
263
+ watching = false;
264
+ if (armed()) {
265
+ delete window[GLOBAL];
266
+ if (held !== undefined) window[GLOBAL] = held;
267
+ }
268
+ }
269
+
270
+ const { bundle, ...report } = result;
271
+ if (result.loaded !== true) return { switched: true, ...report };
272
+ if (!bundle || typeof bundle !== "object") {
273
+ return { switched: true, ...report, published: report.actionCount ? null : [], kept: [] };
274
+ }
275
+
276
+ // The SDK takes only entries with an execute function.
277
+ const names = Object.keys(bundle).filter((name) => typeof bundle[name]?.execute === "function");
278
+ const now = agent.getAvailableActions() || {};
279
+ for (const name of names) {
280
+ const current = now[name];
281
+ if (current && current !== bundle[name] && current !== memo.brought[name] && !(name in memo.own)) {
282
+ memo.own[name] = current;
283
+ }
284
+ }
285
+ // What an earlier run brought that this code does not define goes; the
286
+ // page's own handler for that name comes back.
287
+ for (const name of Object.keys(memo.brought)) {
288
+ if (names.includes(name) || now[name] !== memo.brought[name]) continue;
289
+ if (name in memo.own) agent.setAiActions({ [name]: memo.own[name] });
290
+ else agent.removeAiActions([name]);
291
+ }
292
+ const over = {};
293
+ for (const name of names) if (now[name] !== bundle[name]) over[name] = bundle[name];
294
+ if (Object.keys(over).length) agent.setAiActions(over);
295
+ memo.brought = Object.fromEntries(names.map((name) => [name, bundle[name]]));
296
+
297
+ const after = agent.getAvailableActions() || {};
298
+ const published = names.filter((name) => after[name] === bundle[name]).sort();
299
+ const kept = names.filter((name) => after[name] !== bundle[name]).sort();
300
+ return { switched: true, ...report, published, kept };`;
301
+ }
302
+
303
+ /**
304
+ * The published code of an environment is /agent/actions/<env>/<product>/<agent>/index.js.
305
+ * Anything else the agent loaded is not what was published last: an agent
306
+ * pinned to one version loads index.<version>.js (the SDK reports that name
307
+ * even when it falls back to index.js), and a host override loads from
308
+ * elsewhere entirely.
309
+ */
310
+ export function publishedSourceProblem({ source, env, agent }) {
311
+ if (!source) return null;
312
+ const path = `/agent/actions/${env}/${agent.productId}/${agent.apiName}/`;
313
+ if (source.endsWith(`${path}index.js`)) return null;
314
+ if (!source.includes(`/agent/actions/${env}/`)) {
315
+ return `The agent loaded ${source}, not the published ${word(env)} code: its configuration names its own address for actions.`;
316
+ }
317
+ if (source.includes(path) && /\/index\.[^/]+\.js$/.test(source)) {
318
+ return `The agent loaded ${source}: it is pinned to one version of its code, so this window keeps loading that version, not what was published last.`;
319
+ }
320
+ return `The agent loaded ${source}, not the published ${word(env)} code of ${agent.apiName}.`;
321
+ }
322
+
323
+ /** The JSON the command prints, from what the page reported. */
324
+ export function publishedReport({ outcome, environment, agent, timeoutMs = LOAD_TIMEOUT_MS }) {
325
+ const env = environment.name;
326
+ const base = { environment: env, agent: agent.apiName };
327
+ if (!outcome?.switched) {
328
+ return {
329
+ ok: false,
330
+ ...base,
331
+ reason:
332
+ outcome?.reason === "sdk-too-old"
333
+ ? `This page's Foldspace SDK cannot load published code on request: its agent has no ${outcome.missing.join(", ")}.`
334
+ : "This page has no Foldspace agent to point at the published code.",
335
+ next: notHere(env),
336
+ };
337
+ }
338
+ if (outcome.loaded === null) {
339
+ return {
340
+ ok: false,
341
+ ...base,
342
+ reason: `The agent did not report loading the published ${word(env)} code within ${Math.round(timeoutMs / 1000)}s. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
343
+ next: again(env),
344
+ };
345
+ }
346
+ if (outcome.error) {
347
+ return {
348
+ ok: false,
349
+ ...base,
350
+ reason: `This page's agent failed while switching to the published ${word(env)} code: ${outcome.error}. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
351
+ next: notHere(env),
352
+ };
353
+ }
354
+ if (outcome.loaded === false) {
355
+ return {
356
+ ok: false,
357
+ ...base,
358
+ reason: `The page could not load the published ${word(env)} code${outcome.source ? ` (${outcome.source})` : ""}. Its own handlers are untouched; ${lower(RELOAD_NOTE)}`,
359
+ next:
360
+ env === "dev"
361
+ ? `If you have not published to dev with the \`deploy\` tool yet, do that, then run \`npx foldspace published dev\` again. If you have, run it again in a minute; if it fails again: ${lower(notHere(env))}`
362
+ : `If you have published to production with the \`deploy\` tool, run \`npx foldspace published prod\` again in a minute. If you have not (production gets code only with the human's yes), or it fails again: ${lower(notHere(env))}`,
363
+ };
364
+ }
365
+ const sourceProblem = publishedSourceProblem({ source: outcome.source, env, agent });
366
+ if (sourceProblem) return { ok: false, ...base, reason: sourceProblem, next: notHere(env) };
367
+ if (!Array.isArray(outcome.published)) {
368
+ return {
369
+ ok: false,
370
+ ...base,
371
+ reason: `The agent loaded the published ${word(env)} code, but the page did not show which handlers it brought, so it is not proven that foldspace ask would test that code.`,
372
+ next: notHere(env),
373
+ };
374
+ }
375
+ const kept = outcome.kept || [];
376
+ if (kept.length) {
377
+ const count = outcome.published.length + kept.length;
378
+ return {
379
+ ok: false,
380
+ ...base,
381
+ reason: `The published ${word(env)} code has ${count} ${count === 1 ? "action" : "actions"}, but the page kept its own handler for ${kept.length} of them (${kept.join(", ")}), so foldspace ask would not test that code.`,
382
+ next: again(env),
383
+ };
384
+ }
385
+ return {
386
+ ok: true,
387
+ ...base,
388
+ actions: outcome.published,
389
+ ...(outcome.source ? { source: outcome.source } : {}),
390
+ note: RELOAD_NOTE,
391
+ };
392
+ }