@foldspace_npm/harness 0.1.33 → 0.1.35

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.
package/bin/record.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  checkRecordingMatches,
38
38
  closeAllHeadless,
39
39
  loadRecording,
40
+ wideWindowNote,
40
41
  needsChrome,
41
42
  probeEncoder,
42
43
  renderInChrome,
@@ -337,6 +338,7 @@ async function compose(config, recordDir, chromePath) {
337
338
  note: `${Math.round(plan.fit.totalS)} s, longer than the ${TIMING.maxTotalS} s it should be at most, with the waits already shortened: drop a scene, or pick questions with shorter answers.`,
338
339
  }
339
340
  : {}),
341
+ ...(wideWindowNote(recording) ? { wideWindow: wideWindowNote(recording) } : {}),
340
342
  sceneSeconds: plan.clips.filter((clip) => clip.kind === "scene").map((clip) => Math.round(clip.durationS * 10) / 10),
341
343
  lastFrames: lastFrames(recording),
342
344
  };
package/bin/sync.mjs ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * foldspace sync <check|apply|saved> [options]
4
+ *
5
+ * Keeps this folder's agent/ up to date with the agent's saved code, which
6
+ * several builders can work on. The coding agent reads the saved code with the
7
+ * Foldspace tools and passes the heads it read; this command remembers what
8
+ * this folder last pulled (.foldspace-dev/saved-code/) and merges.
9
+ *
10
+ * check --develop <sha> [--line <sha>]
11
+ * apply
12
+ * saved --head <commit_sha> --paths <a,b,...>
13
+ *
14
+ * Saving and publishing follow the code project's own instructions.
15
+ */
16
+ import { resolveProjectDir } from "../src/upgrade.mjs";
17
+ import { syncApply, syncCheck, syncSaved } from "../src/sync-core.mjs";
18
+
19
+ const VALUE_FLAGS = new Set(["--develop", "--line", "--head", "--paths"]);
20
+
21
+ function parse(argv) {
22
+ const flags = {};
23
+ const rest = [];
24
+ for (let index = 0; index < argv.length; index++) {
25
+ const arg = argv[index];
26
+ if (VALUE_FLAGS.has(arg)) flags[arg.slice(2)] = argv[++index];
27
+ else if (arg.startsWith("--")) throw new Error(`unknown option '${arg}'`);
28
+ else rest.push(arg);
29
+ }
30
+ return { flags, rest };
31
+ }
32
+
33
+ try {
34
+ const projectDir = resolveProjectDir();
35
+ const { flags, rest } = parse(process.argv.slice(2));
36
+ const [verb, extra] = rest;
37
+ if (extra) throw new Error(`unexpected argument '${extra}'`);
38
+ const allowed = { check: ["develop", "line"], apply: [], saved: ["head", "paths"] }[verb];
39
+ if (!allowed) throw new Error("usage: foldspace sync <check|apply|saved> - see foldspace help sync");
40
+ const stray = Object.keys(flags).find((name) => !allowed.includes(name));
41
+ if (stray) throw new Error(`option '--${stray}' does not go with sync ${verb}`);
42
+ const report =
43
+ verb === "check" ? syncCheck(projectDir, flags) : verb === "apply" ? syncApply(projectDir) : syncSaved(projectDir, flags);
44
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
45
+ } catch (error) {
46
+ console.error(`sync: ${error instanceof Error ? error.message : String(error)}`);
47
+ process.exitCode = 1;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
package/recipes/INDEX.md CHANGED
@@ -8,7 +8,7 @@ Read this whole table, then open the recipe closest to the outcome.
8
8
  | [`find-by-name`](find-by-name/) | **L2** | The agent turns a name the user said into the id the next action needs; a count when no name is given; every tie when the name is ambiguous; near-misses when nothing matches | A data action. **No card** | 3 production builds |
9
9
  | [`lookup-then-view`](lookup-then-view/) | **L2** | Show one account the user names - **start here**. One clear match: its card, no question. Several or none: the **pick-one** list - search, pages, one click - then the card: who it is, the money, open deals, people, open cases. The card action's **required** `accountId`, described as coming from the lookup, makes the agent call the lookup first. The app already hands over the id → `account-overview` | Two actions - an input card (`find_account`) and a view card (`show_account_overview`) - with their Agent Studio definitions word for word | 1 production build |
10
10
  | [`pick-from-a-list`](pick-from-a-list/) | **L2** | A clickable list, shown **only** when the user has to choose — with "none of these" and cancel | An input card: the agent waits for the user's answer | 1 production build |
11
- | [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page whose starters change with the screen the user is on - configured from code, the way customers do it | Page set-up, runs once at start-up | 2 production builds |
11
+ | [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page (Foldspace turns it on) whose starters - at most 3 - change with the screen the user is on, set from code | Page set-up, runs once at start-up | 2 production builds |
12
12
  | [`swap-the-login-method`](swap-the-login-method/) | any | `apiFetch` for an app that authenticates with a custom header, or with cookies, instead of a bearer token | A replacement `agent/utils.ts` | 2 production builds (a third uses the default bearer token) |
13
13
  | [`account-overview`](account-overview/) | **L2** | One account on a card, when the app already hands over its id: identity, pipeline total, contacts, cases, meetings. The agent gets what the card shows. An account the user names → `lookup-then-view` | A view card: no `callback`, no `awaitUserInput` | 1 production build |
14
14
  | [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An input card: the agent waits for the user's answer | 1 production build |
@@ -5,17 +5,19 @@ page that shows a few starters and opens the agent when one is clicked. Its
5
5
  starters change with the screen the user is on, so an agent with two actions
6
6
  still looks useful everywhere.
7
7
 
8
- It is configured **from code** — Agent Studio has no UI for it today, and this
9
- is how customers set it. **Proven by 2 production builds** (one in the
10
- product's own source, one from the agent project behind a feature flag).
8
+ **Foldspace turns the bar on** from its own settings - this code does not.
9
+ What the code does is pick each page's starters.
10
+ **Proven by 2 production builds** (one in the product's own source, one from
11
+ the agent project behind a feature flag).
11
12
 
12
13
  ## Adapt it
13
14
 
14
15
  | In `agent/bottomBar.ts` | Change |
15
16
  |---|---|
16
- | `PAGE_STARTERS` | This product's screens (path prefixes) and, for each, two or three questions **its actions can answer**. A starter for something the agent cannot do is worse than none |
17
- | `DEFAULT_STARTERS` | What to show on any other page. It replaces the agent-wide starters there |
18
- | `BOTTOM_BAR_SETTINGS` | The docs' settings, except two: `enabled: true` (the docs default it to `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the docs default 5). Change any of them only if the human asks |
17
+ | `PAGE_STARTERS` | The product's main-menu screens (`npx foldspace observe menu`: path prefixes) and, for each, **at most 3 starters** the agent can answer today. A starter for something the agent cannot do is worse than none: leave that page out |
18
+ | `ACTION` or `KNOWLEDGE` | `ACTION` when clicking it runs one of the agent's actions - the bar shows the action icon. `KNOWLEDGE` when the answer comes from what the agent knows (its knowledge base) |
19
+
20
+ A page that is not listed shows Agent Studio's starters (`setConversationStarters(null)`).
19
21
 
20
22
  Then call it from the bundle's entry point:
21
23
 
@@ -30,14 +32,25 @@ installBottomBar();
30
32
  - **`setConversationStarters` replaces the whole set for the page** — both
31
33
  groups. `null` restores Agent Studio's. So always pass the full list you
32
34
  want shown.
35
+ - **It also sets the agent panel's opening screen**, not only the bar. One
36
+ customer's list had 7 there. At most 3 per page, both groups together:
37
+ `npx foldspace lint` refuses more. `maxVisibleStarters` caps the bar only.
38
+ - **Route changes in a single-page app don't reload**, so the starters must be
39
+ re-applied on navigation. The recipe uses the browser's own page-change
40
+ event (`navigation`'s `currententrychange`); where a browser has no
41
+ Navigation API (⚠️ some Safari and Firefox versions, not tried live) it
42
+ wraps `pushState` / `replaceState` and listens to `popstate`. It brings the
43
+ bar back to full only when the starters change; a navigation that keeps the
44
+ same starters leaves a bar that has folded to compact as it is.
45
+ - **An older SDK may lack these calls.** The recipe then logs once in the
46
+ console and leaves Agent Studio's starters.
47
+ - **No `bottomBarSettings`.** Send one only if the human asks for a setting
48
+ that differs from the default (the fields are on the Bottom Bar page), and
49
+ send `enabled: true` in the same group. ⚠️ Whether a partial group merges
50
+ with Foldspace's own settings is not verified.
33
51
  - **In embedded mode the bar needs `openEmbeddedCallback`**, or the bar does
34
52
  not render at all. This recipe is for the overlay agent; see the docs for
35
53
  embedded.
36
- - **Route changes in a single-page app don't reload**, so the starters must be
37
- re-applied on navigation - this recipe wraps `pushState` / `replaceState`
38
- and listens to `popstate`. It brings the bar back to full only when the
39
- starters change; a navigation that keeps the same starters leaves a bar
40
- that has folded to compact (after `idleTimeoutMs`) as it is.
41
54
  - **Dark mode**: the SDK's default colours do not follow a dark theme yet
42
55
  (PLG-5891, in the backlog); a product in dark mode may need `theme` /
43
56
  `darkModeSettings` set.
@@ -1,52 +1,64 @@
1
1
  // The bottom bar: the agent's resting entry point at the foot of the page,
2
- // cycling starters that fit the screen the user is on. Configured from code -
3
- // Agent Studio has no UI for it today, and this is how customers set it.
2
+ // showing starters that fit the screen the user is on.
4
3
  // https://docs.foldspace.ai/customize/bottom-bar/
5
4
  // https://docs.foldspace.ai/customize/conversation-starters/
6
5
  //
6
+ // Foldspace turns the bar on from its own settings, so this code never sends
7
+ // `bottomBarSettings`: it only picks each page's starters.
8
+ //
7
9
  // Product-neutral: the paths and starters below are placeholders. Replace them
8
- // with this product's own screens and with questions its actions can answer.
10
+ // with this product's main-menu screens (`npx foldspace observe menu`) and,
11
+ // for each, starters the agent can answer today.
9
12
 
10
13
  import { getAgent } from "./utils";
11
14
 
12
- /** Starters per screen. Paths are matched as prefixes, first match wins. */
13
- export const PAGE_STARTERS: Array<{ match: RegExp; starters: string[] }> = [
14
- { match: /^\/dashboard/, starters: ["<A question about what this screen shows>", "<Another one>"] },
15
- { match: /^\/<records>/, starters: ["Find <a record> by name", "Show me <a record>'s details"] },
16
- ];
15
+ /**
16
+ * The most starters one page shows, both groups together. The same call sets
17
+ * the agent panel's opening screen, and a long list there buries the one
18
+ * that matters. `npx foldspace lint` refuses more.
19
+ */
20
+ export const MAX_STARTERS_PER_PAGE = 3;
17
21
 
18
- /** Shown on any page not listed above. */
19
- export const DEFAULT_STARTERS: string[] = ["What can you do?", "<A question one of its actions can answer>"];
22
+ /**
23
+ * Starters per screen. Paths are matched as prefixes, first match wins.
24
+ * - `ACTION`: clicking it runs one of the agent's actions (the bar shows the
25
+ * action icon), e.g. "Show my open invoices".
26
+ * - `KNOWLEDGE`: a question answered from what the agent knows (its knowledge
27
+ * base), e.g. "How do I add a teammate?".
28
+ * A page that is not listed shows Agent Studio's starters.
29
+ */
30
+ export const PAGE_STARTERS: Array<{ match: RegExp; KNOWLEDGE: string[]; ACTION: string[] }> = [
31
+ { match: /^\/dashboard/, KNOWLEDGE: [], ACTION: ["<Something an action shows about this screen>"] },
32
+ { match: /^\/<records>/, KNOWLEDGE: ["<A how-to question about this screen>"], ACTION: ["Find <a record> by name"] },
33
+ ];
20
34
 
21
- // The docs' settings, except two: `enabled: true` (the docs default it to
22
- // `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the
23
- // docs default 5). Change any of them only if the human asks: every field is
24
- // documented on the Bottom Bar page.
25
- export const BOTTOM_BAR_SETTINGS = {
26
- enabled: true,
27
- maxVisibleStarters: 3,
28
- reopenFrequency: "EVERY_LOAD",
29
- starterClickBehavior: "OPEN_AGENT",
30
- idleTimeoutMs: 5000,
31
- initialBehaviorMode: "FULL",
32
- } as const;
35
+ export type PageStarters = (typeof PAGE_STARTERS)[number];
33
36
 
34
- export function startersForPath(pathname: string): string[] {
35
- const hit = PAGE_STARTERS.find((entry) => entry.match.test(pathname));
36
- return hit ? hit.starters : DEFAULT_STARTERS;
37
+ /** This page's starters, or null for a page that keeps Agent Studio's. */
38
+ export function startersForPath(pathname: string): PageStarters | null {
39
+ return PAGE_STARTERS.find((entry) => entry.match.test(pathname)) ?? null;
37
40
  }
38
41
 
39
42
  // On window, not in module scope: a bundle evaluated twice on one page must
40
43
  // not install two route listeners.
41
44
  const INSTALLED_FLAG = "__foldspace_bottom_bar__";
42
45
 
46
+ const warned = new Set<string>();
47
+ function warnOnce(method: string): void {
48
+ if (warned.has(method)) return;
49
+ warned.add(method);
50
+ console.warn(
51
+ `[foldspace] This page's Foldspace SDK has no ${method}(), so the bottom bar keeps Agent Studio's starters. Load a newer SDK to get page starters.`,
52
+ );
53
+ }
54
+
43
55
  /**
44
- * Set up the bar and keep its starters matched to the page. Call once from
45
- * the bundle's entry point; safe to call again.
56
+ * Keep the bar's starters matched to the page. Call once from the bundle's
57
+ * entry point; safe to call again.
46
58
  *
47
59
  * `setConversationStarters` REPLACES the whole set for this page view (both
48
- * groups); passing `null` restores what Agent Studio has. Starters go in the
49
- * KNOWLEDGE group so the bar shows them as plain questions.
60
+ * groups) and also sets the panel's opening screen; `null` restores what
61
+ * Agent Studio has.
50
62
  */
51
63
  export function installBottomBar(): void {
52
64
  const foldspace = (window as any).foldspace;
@@ -57,7 +69,12 @@ export function installBottomBar(): void {
57
69
  foldspace("when", "ready", () => {
58
70
  const agent = getAgent();
59
71
  if (!agent) return;
60
- let current: string[] = [];
72
+ if (typeof agent.setConversationStarters !== "function") {
73
+ warnOnce("setConversationStarters");
74
+ return;
75
+ }
76
+ // Agent Studio's starters are what the page starts with.
77
+ let current: PageStarters | null = null;
61
78
 
62
79
  // On a navigation, the bar is brought back to full only when its starters
63
80
  // changed, so the new ones are seen. Same starters: nothing is sent, and a
@@ -66,19 +83,40 @@ export function installBottomBar(): void {
66
83
  const next = startersForPath(window.location.pathname);
67
84
  if (next === current) return;
68
85
  current = next;
69
- agent.setConversationStarters?.(
70
- { KNOWLEDGE: next.map((title) => ({ title })), ACTION: [] },
71
- "KNOWLEDGE",
72
- );
73
- if (reopen) agent.openBottomBar?.({ mode: "FULL" });
86
+ if (next) {
87
+ agent.setConversationStarters(
88
+ {
89
+ KNOWLEDGE: next.KNOWLEDGE.map((title) => ({ title })),
90
+ ACTION: next.ACTION.map((title) => ({ title })),
91
+ },
92
+ // The group shown first. Not documented: which group the bar itself
93
+ // shows when a page has both - check it on the page.
94
+ next.ACTION.length ? "ACTION" : "KNOWLEDGE",
95
+ );
96
+ } else {
97
+ agent.setConversationStarters(null);
98
+ }
99
+ if (!reopen) return;
100
+ if (typeof agent.openBottomBar === "function") agent.openBottomBar({ mode: "FULL" });
101
+ else warnOnce("openBottomBar");
74
102
  };
75
103
 
76
- // One whole settings group per call, as the docs do.
77
- agent.setConfiguration?.({ bottomBarSettings: BOTTOM_BAR_SETTINGS });
78
104
  apply(false);
79
105
 
80
- // Single-page apps change the URL without a load: re-apply on navigation.
106
+ // Single-page apps change the address without a page load: re-apply on
107
+ // each navigation. The browser's own page-change event where it has one:
108
+ // `currententrychange` fires once the address has changed - router links,
109
+ // pushState, replaceState, back and forward. (`navigate` fires before the
110
+ // address changes, so the page it would read is the old one.)
81
111
  const onRouteChange = () => apply(true);
112
+ const navigation = (window as any).navigation;
113
+ if (navigation && typeof navigation.addEventListener === "function") {
114
+ navigation.addEventListener("currententrychange", onRouteChange);
115
+ return;
116
+ }
117
+ // Browsers without the Navigation API (some Safari and Firefox versions;
118
+ // not tried live): listen for back/forward and wrap pushState /
119
+ // replaceState.
82
120
  window.addEventListener("popstate", onRouteChange);
83
121
  const history = window.history as any;
84
122
  for (const method of ["pushState", "replaceState"] as const) {
@@ -1,17 +1,18 @@
1
1
  {
2
- "note": "This recipe makes no network calls. What it sends to the SDK on /dashboard:",
3
- "setConfiguration": {
4
- "bottomBarSettings": {
5
- "enabled": true,
6
- "maxVisibleStarters": 3,
7
- "reopenFrequency": "EVERY_LOAD",
8
- "starterClickBehavior": "OPEN_AGENT",
9
- "idleTimeoutMs": 5000,
10
- "initialBehaviorMode": "FULL"
2
+ "note": "This recipe makes no network calls and sends no bottomBarSettings (Foldspace turns the bar on). What it sends to the SDK on each page:",
3
+ "/dashboard": {
4
+ "setConversationStarters": {
5
+ "starters": { "KNOWLEDGE": [], "ACTION": [{ "title": "<Something an action shows about this screen>" }] },
6
+ "defaultStarterType": "ACTION"
11
7
  }
12
8
  },
13
- "setConversationStarters": {
14
- "starters": { "KNOWLEDGE": [{ "title": "<A question about what this screen shows>" }, { "title": "<Another one>" }], "ACTION": [] },
15
- "defaultStarterType": "KNOWLEDGE"
9
+ "/<records>/42": {
10
+ "setConversationStarters": {
11
+ "starters": { "KNOWLEDGE": [{ "title": "<A how-to question about this screen>" }], "ACTION": [{ "title": "Find <a record> by name" }] },
12
+ "defaultStarterType": "ACTION"
13
+ }
14
+ },
15
+ "a page with no starters of its own, after one that had them": {
16
+ "setConversationStarters": { "starters": null }
16
17
  }
17
18
  }
@@ -31,7 +31,7 @@ says). If you observed neither, leave the user anonymous.
31
31
  |---|---|
32
32
  | `SESSION_STORAGE_KEY`, `SESSION_HEADER` | The key and header name you **observed** |
33
33
  | `EXTRA_HEADERS` in the cookies variant | What you **observed** the app sending besides the cookies; most apps send none |
34
- | `API_BASE` in `constants.ts` | For a same-origin app, the app's own origin. Empty stays a failure on purpose, so a guessed host cannot look like success |
34
+ | `API_BASE` in `constants.ts` | For a same-origin app, `"/api"` (the path), so each user's page calls its own host - on a product with a subdomain per customer, never the address you observed (the manual's **Each customer on their own subdomain**). Empty stays a failure on purpose, so a guessed host cannot look like success. Both files build their URLs with `apiBaseUrl()`, never `API_BASE` directly |
35
35
 
36
36
  ## What those builds learned the hard way
37
37
 
@@ -4,7 +4,7 @@
4
4
  // Only the transport is replaced. What a 401, a 404 or a 429 means still comes
5
5
  // from the harness, so actions and renderFailure behave exactly the same.
6
6
 
7
- import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
7
+ import { apiBaseUrl, configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
8
8
  import { AGENT_API_NAME, API_BASE } from "./constants";
9
9
 
10
10
  configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
@@ -27,13 +27,13 @@ export async function apiFetch<T = unknown>(
27
27
  // Empty API_BASE is a failure on purpose: a guessed host cannot look like
28
28
  // success. `error` is drawn on the card and read by the agent - it says what
29
29
  // is true; the builder's hint goes in `detail`, which only the console sees.
30
- if (!API_BASE) {
30
+ if (!apiBaseUrl()) {
31
31
  return {
32
32
  ok: false,
33
33
  status: 0,
34
34
  reason: "config",
35
35
  error: "This is not set up yet.",
36
- detail: "API_BASE is not set - capture the app's XHR first.",
36
+ detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
37
37
  };
38
38
  }
39
39
  const session = readSession();
@@ -42,7 +42,7 @@ export async function apiFetch<T = unknown>(
42
42
  const controller = new AbortController();
43
43
  const timer = setTimeout(() => controller.abort(), timeoutMs);
44
44
  try {
45
- const res = await fetch(`${API_BASE}${path}`, {
45
+ const res = await fetch(`${apiBaseUrl()}${path}`, {
46
46
  ...init,
47
47
  signal: controller.signal,
48
48
  headers: { "Content-Type": "application/json", [SESSION_HEADER]: session, ...(init.headers ?? {}) },
@@ -69,13 +69,13 @@ export async function apiFetchBinary(
69
69
  init: RequestInit = {},
70
70
  timeoutMs = 15_000,
71
71
  ): Promise<ApiResult<Uint8Array>> {
72
- if (!API_BASE) {
72
+ if (!apiBaseUrl()) {
73
73
  return {
74
74
  ok: false,
75
75
  status: 0,
76
76
  reason: "config",
77
77
  error: "This is not set up yet.",
78
- detail: "API_BASE is not set - capture the app's XHR first.",
78
+ detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
79
79
  };
80
80
  }
81
81
  const session = readSession();
@@ -84,7 +84,7 @@ export async function apiFetchBinary(
84
84
  const controller = new AbortController();
85
85
  const timer = setTimeout(() => controller.abort(), timeoutMs);
86
86
  try {
87
- const res = await fetch(`${API_BASE}${path}`, {
87
+ const res = await fetch(`${apiBaseUrl()}${path}`, {
88
88
  ...init,
89
89
  signal: controller.signal,
90
90
  headers: { [SESSION_HEADER]: session, ...(init.headers ?? {}) },
@@ -103,6 +103,7 @@ export async function apiFetchBinary(
103
103
  // the harness's returns a config failure here, because this file sets no
104
104
  // `authSource`. For the same reason getAuthToken returns null (see the README).
105
105
  export {
106
+ apiBaseUrl,
106
107
  getAgent,
107
108
  armAllInstances,
108
109
  rankBy,
@@ -4,7 +4,7 @@
4
4
  // There is nothing to check before the request, so "signed out" is only known
5
5
  // from the 401. It comes back as `signed_out` like any other.
6
6
 
7
- import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
7
+ import { apiBaseUrl, configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
8
8
  import { AGENT_API_NAME, API_BASE } from "./constants";
9
9
 
10
10
  configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
@@ -24,20 +24,20 @@ export async function apiFetch<T = unknown>(
24
24
  // Empty API_BASE is a failure on purpose: a guessed host cannot look like
25
25
  // success. `error` is drawn on the card and read by the agent - it says what
26
26
  // is true; the builder's hint goes in `detail`, which only the console sees.
27
- if (!API_BASE) {
27
+ if (!apiBaseUrl()) {
28
28
  return {
29
29
  ok: false,
30
30
  status: 0,
31
31
  reason: "config",
32
32
  error: "This is not set up yet.",
33
- detail: "API_BASE is not set - capture the app's XHR first.",
33
+ detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
34
34
  };
35
35
  }
36
36
 
37
37
  const controller = new AbortController();
38
38
  const timer = setTimeout(() => controller.abort(), timeoutMs);
39
39
  try {
40
- const res = await fetch(`${API_BASE}${path}`, {
40
+ const res = await fetch(`${apiBaseUrl()}${path}`, {
41
41
  ...init,
42
42
  credentials: "include",
43
43
  signal: controller.signal,
@@ -65,20 +65,20 @@ export async function apiFetchBinary(
65
65
  init: RequestInit = {},
66
66
  timeoutMs = 15_000,
67
67
  ): Promise<ApiResult<Uint8Array>> {
68
- if (!API_BASE) {
68
+ if (!apiBaseUrl()) {
69
69
  return {
70
70
  ok: false,
71
71
  status: 0,
72
72
  reason: "config",
73
73
  error: "This is not set up yet.",
74
- detail: "API_BASE is not set - capture the app's XHR first.",
74
+ detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
75
75
  };
76
76
  }
77
77
 
78
78
  const controller = new AbortController();
79
79
  const timer = setTimeout(() => controller.abort(), timeoutMs);
80
80
  try {
81
- const res = await fetch(`${API_BASE}${path}`, {
81
+ const res = await fetch(`${apiBaseUrl()}${path}`, {
82
82
  ...init,
83
83
  credentials: "include",
84
84
  signal: controller.signal,
@@ -98,6 +98,7 @@ export async function apiFetchBinary(
98
98
  // the harness's returns a config failure here, because this file sets no
99
99
  // `authSource`. For the same reason getAuthToken returns null (see the README).
100
100
  export {
101
+ apiBaseUrl,
101
102
  getAgent,
102
103
  armAllInstances,
103
104
  rankBy,
@@ -26,3 +26,36 @@ export function chromeCandidates(platform = os.platform(), env = process.env) {
26
26
  export function findChrome({ env = process.env, platform = os.platform(), exists = fs.existsSync } = {}) {
27
27
  return env.CHROME_PATH || chromeCandidates(platform, env).find((candidate) => exists(candidate)) || null;
28
28
  }
29
+
30
+ // Chrome slows a page whose window is hidden, covered or minimized - timers
31
+ // throttled, the renderer moved to the background - and the build in it stalls.
32
+ // These keep the test window working while the builder looks at another app
33
+ // or another macOS user's session (⚠️ not tried live with a user switch).
34
+ // The hidden Chrome that makes the video uses them too.
35
+ export const KEEP_RUNNING_ARGS = Object.freeze([
36
+ "--disable-background-timer-throttling",
37
+ "--disable-renderer-backgrounding",
38
+ "--disable-backgrounding-occluded-windows",
39
+ ]);
40
+
41
+ /** How `inject` starts the test window. */
42
+ export function testWindowArgs({ profileDir, debugPort, themeDir, startUrl }) {
43
+ return [
44
+ `--user-data-dir=${profileDir}`,
45
+ `--remote-debugging-port=${debugPort}`,
46
+ // The theme only paints the isolated window Foldspace blue. SDK and action
47
+ // code are loaded later by attach over CDP.
48
+ `--load-extension=${themeDir}`,
49
+ `--disable-extensions-except=${themeDir}`,
50
+ // Chrome 136+ dropped --load-extension. Keep the legacy switch for older
51
+ // builds, and enable the CDP Extensions domain so we can load it at runtime.
52
+ "--disable-features=DisableLoadExtensionCommandLineSwitch",
53
+ "--enable-unsafe-extension-debugging",
54
+ "--no-first-run",
55
+ "--no-default-browser-check",
56
+ "--test-type",
57
+ "--remote-allow-origins=*",
58
+ ...KEEP_RUNNING_ARGS,
59
+ startUrl,
60
+ ];
61
+ }
@@ -76,7 +76,7 @@ export const CLI_COMMANDS = Object.freeze([
76
76
  name: "upgrade",
77
77
  entry: null,
78
78
  group: "start",
79
- summary: "Check for a newer harness and, after asking, update the version this project uses",
79
+ summary: "Check for a newer harness and update the version this project uses",
80
80
  usage:
81
81
  "foldspace upgrade [--check | --yes] [--refresh-instructions]",
82
82
  risk: "local-write",
@@ -91,7 +91,7 @@ export const CLI_COMMANDS = Object.freeze([
91
91
  flag("--check", "Compare versions and print JSON; never install"),
92
92
  flag(
93
93
  "--yes",
94
- "Install the latest version after the human has agreed",
94
+ "Install the latest version: within the same line (update.autoUpgrade) without asking; across a line, after the human has agreed",
95
95
  ),
96
96
  flag(
97
97
  "--refresh-instructions",
@@ -111,11 +111,56 @@ export const CLI_COMMANDS = Object.freeze([
111
111
  "Any foldspace command writes a harness skill the project is missing, so the first upgrade from a harness older than the skill still gets it",
112
112
  ],
113
113
  next: [
114
- "If outdated, ask the human, then run npx foldspace upgrade --yes",
114
+ "If update.autoUpgrade (a newer version in the same line, such as 0.1.x): run npx foldspace upgrade --yes without asking, and tell the human in one plain line",
115
+ "If outdated across a line (0.1 to 0.2): ask the human, then run npx foldspace upgrade --yes",
115
116
  "Run npm run build after an upgrade",
116
117
  "If attach is running: `npx foldspace attach --stop`, then `npx foldspace attach --daemon` with the flags it ran with, to load the rebuild",
117
118
  ],
118
119
  }),
120
+ Object.freeze({
121
+ name: "sync",
122
+ entry: "sync.mjs",
123
+ group: "start",
124
+ summary: "Keep agent/ up to date with the agent's saved code: what to pull, then merge it in",
125
+ usage:
126
+ "foldspace sync <check|apply|saved> [--develop <sha>] [--line <sha>] [--head <sha>] [--paths <a,b>]",
127
+ risk: "local-write",
128
+ environment: "node",
129
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
130
+ capabilities: ["project.sync"],
131
+ positionals: [
132
+ Object.freeze({
133
+ name: "verb",
134
+ required: true,
135
+ description:
136
+ "check: compare the heads you just read with what this folder last pulled, and say what to pull | apply: merge what you pulled into agent/ | saved: remember a save this folder made",
137
+ }),
138
+ ],
139
+ options: [
140
+ value("--develop", "sha", "check: develop_head from repo_read status"),
141
+ value("--line", "sha", "check: the head of foldspace/<Agent Key>, the line of work saves go to (the sha repo_read tree prints for that ref); leave it out while that line does not exist"),
142
+ value("--head", "sha", "saved: the commit_sha the save returned"),
143
+ value("--paths", "a,b", "saved: the committed_paths the save returned, comma-separated"),
144
+ ],
145
+ prerequisites: [
146
+ "foldspace.dev.json",
147
+ "A code project (repo_read status), read with the Foldspace tools: this command never reaches Foldspace itself",
148
+ ],
149
+ effects: [
150
+ "Writes only under .foldspace-dev/ (saved-code/ and agent-kept/) and, for apply, agent/",
151
+ "Changes nothing about saving or publishing: those follow repo_read command instructions",
152
+ "check names each ref to pull and the folder to write it into (pull[].into); it pulls nothing itself",
153
+ "apply copies agent/ to .foldspace-dev/agent-kept/<time>/ first, then merges file by file against what this folder last pulled: a file only one side changed takes that side; one both changed takes the saved version and is listed in conflicts",
154
+ "On a first pull (nothing remembered), the saved code wins every difference, and of the files it lacks only agent/actions/, agent/api/ and agent/views/ go back",
155
+ "agent/runtime/ is the build's: differences there are never conflicts",
156
+ ],
157
+ next: [
158
+ "check with pull: read agent/ at each ref into its into folder (repo_read tree and file), then npx foldspace sync apply",
159
+ "check up to date: save as repo_read command instructions says, with base_sha = baseSha",
160
+ "apply: follow its next - conflicts mean putting this session's change back on top; then npm run build",
161
+ "After each save: npx foldspace sync saved with the commit_sha and committed_paths it returned",
162
+ ],
163
+ }),
119
164
  Object.freeze({
120
165
  name: "build",
121
166
  entry: "build-cli.mjs",
@@ -326,6 +371,7 @@ export const CLI_COMMANDS = Object.freeze([
326
371
  ],
327
372
  options: [
328
373
  value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
374
+ flag("--human-said-yes", "screen --click: the human said yes in chat to clicking this one plain button; a label that reads like an action is still refused"),
329
375
  value("--goto", "path", "screen: a path on this host, or a full https address on a host this project names"),
330
376
  value("--match", "words", "screen: rank the requests seen by these words"),
331
377
  value("--wait", "ms", "screen/auth: how long to keep listening"),
@@ -577,6 +623,7 @@ export const CLI_COMMANDS = Object.freeze([
577
623
  export const CAPABILITY_CATALOGUE = Object.freeze([
578
624
  ["project.scaffold", "Create a new configured project on local disk"],
579
625
  ["project.upgrade", "Update the harness version the consuming project uses"],
626
+ ["project.sync", "Merge the agent's saved code, read with the Foldspace tools, into agent/"],
580
627
  ["artifact.build", "Build the portable dist/index.js action artifact"],
581
628
  ["browser.launch", "Launch an isolated local Chrome profile"],
582
629
  ["browser.cdp", "Connect to local Chrome through CDP"],
@@ -656,9 +703,10 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
656
703
  "The harness bin alias is equivalent to foldspace.",
657
704
  "attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
658
705
  "Coding agents run attach --daemon; foreground attach is for someone watching the terminal.",
659
- "If update.outdated is true, ask the human before npx foldspace upgrade --yes.",
660
- "If update.broken is true, this version was withdrawn and build and attach refuse to run. Ask the human to agree to the upgrade; set FOLDSPACE_SKIP_UPDATE_CHECK=1 only if they decline.",
706
+ "If update.autoUpgrade is true (a newer version in the same line), run npx foldspace upgrade --yes without asking and tell the human in one plain line; if update.outdated is true across a line, ask the human first.",
707
+ "If update.broken is true, this version was withdrawn and build and attach refuse to run. Within the same line, upgrade without asking; across a line, ask the human to agree to the upgrade, and set FOLDSPACE_SKIP_UPDATE_CHECK=1 only if they decline.",
661
708
  "observe looks at the app, and ask tests the agent through its panel; both work while attach runs.",
709
+ "With a code project, pull the saved code with `npx foldspace sync` at session start, before each new experience and before each save or publish (manual: \"Several builders, one agent\"); saving and publishing follow repo_read command instructions.",
662
710
  "`foldspace deploy` is for Foldspace staff only: never run or offer it - it is not the `deploy` tool. With a code project, putting the agent's code in front of their users is yours, as the manual's \"Done, and what you say at the end\" says: save with `repo_write`; publish to dev with the `deploy` tool (say you are doing it, then do it - unless its dev preview comes back with no warnings, which means this agent loads dev for its users: then ask first, as for production); check dev with `npx foldspace attach --stop`, `npx foldspace published dev` and `npx foldspace ask \"<question>\" --retry 2`; and publish to production only once that check passed and the human says yes. The dev check and production need a site that loads the agent by itself: after `attach --bootstrap` or `--replace`, publish to dev only, keep attach running, and the rest is a step the Foldspace team does with the human. If `published` says this window cannot show dev, say so in one sentence, attach again, and stop after dev. Without a code project, or when a Foldspace tool refuses (except a save refused because someone saved after you read: redo it as `repo_read` command `instructions` says), the Foldspace team does it with the human (https://foldspace.ai/demo, support@foldspace.ai).",
663
711
  ],
664
712
  };