setup-agent 0.1.2 → 0.2.0

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/src/guide.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The guide: every text the CLI shows the coding agent and the order of its
3
+ * intro, fetched from the setup server at `<setup-url>/guide` so a wording
4
+ * or flow fix ships with a server deploy. Setup is online-only, and the CLI
5
+ * keeps no copy of the text.
6
+ *
7
+ * The guide is data. The CLI prints its texts after stripping control
8
+ * characters, fills `{{name}}` placeholders with values it computes itself,
9
+ * and runs intro steps only from `LOCAL_ACTIONS`. Commands it runs, files it
10
+ * writes and URLs it opens never come from the guide.
11
+ */
12
+
13
+ /** The guide format the CLI reads. Bump it only for a change an older CLI cannot read. */
14
+ export const GUIDE_PROTOCOL = 1;
15
+
16
+ /** Every text the CLI prints from the guide. */
17
+ export const GUIDE_TEXT_KEYS = [
18
+ "briefing",
19
+ "intro.status-heading",
20
+ "stream.briefing",
21
+ "stream.briefing-footer",
22
+ "stream.stay-connected",
23
+ "stream.opened.input",
24
+ "stream.opened.plan",
25
+ "stream.opened.finish",
26
+ "stream.ask.recently-opened",
27
+ "stream.ask.browser-closed",
28
+ "stream.ask.no-browser",
29
+ "wait.waiting",
30
+ "done.next",
31
+ "need.plan.question",
32
+ "need.plan.approve",
33
+ "need.plan.revise",
34
+ "need.plan.format",
35
+ "need.finish.question",
36
+ "need.finish.question-preview",
37
+ "need.finish.close",
38
+ "need.finish.format",
39
+ "need.input.empty-prompt",
40
+ "need.input.optional",
41
+ "need.input.model",
42
+ "need.product.add",
43
+ "need.product.decline",
44
+ "need.format.text",
45
+ "need.format.product",
46
+ "need.format.model",
47
+ "need.format.entry-point",
48
+ "need.format.choice",
49
+ "need.format.multiple",
50
+ ] as const;
51
+ export type GuideTextKey = (typeof GUIDE_TEXT_KEYS)[number];
52
+
53
+ /** All a guide can make the CLI do: print one of its texts, print the setup's state, or announce the agent. */
54
+ const LOCAL_ACTIONS = ["print", "print-status", "announce-agent"] as const;
55
+
56
+ export type GuideStep =
57
+ | { action: "print"; text: GuideTextKey }
58
+ | { action: "print-status" }
59
+ | { action: "announce-agent" };
60
+
61
+ export type Guide = {
62
+ protocol: typeof GUIDE_PROTOCOL;
63
+ texts: Record<GuideTextKey, string>;
64
+ intro: GuideStep[];
65
+ };
66
+
67
+ /** Who started the setup (the user's browser, `assistant-ui setup`, or that command as the stream) and whether a browser opens. */
68
+ export type GuideContext = {
69
+ started: "user" | "agent" | "agent-stream";
70
+ opened: boolean;
71
+ };
72
+
73
+ const MAX_GUIDE_BYTES = 256 * 1024;
74
+ const TIMEOUT_MS = 5_000;
75
+
76
+ /**
77
+ * `text` without control characters other than tab and line feed (an escape
78
+ * sequence could rewrite the terminal) and bidirectional overrides.
79
+ */
80
+ const sanitizeTerminalText = (text: string) =>
81
+ // oxlint-disable-next-line no-control-regex
82
+ text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g, "");
83
+
84
+ /** A guide text with its placeholders filled once; a value is never read for placeholders itself. */
85
+ export const renderText = (
86
+ guide: Pick<Guide, "texts">,
87
+ key: GuideTextKey,
88
+ values: Record<string, string | number> = {},
89
+ ) =>
90
+ guide.texts[key].replace(/\{\{([A-Za-z]+)\}\}/g, (_, name: string) =>
91
+ String(values[name] ?? ""),
92
+ );
93
+
94
+ const isTextKey = (value: unknown): value is GuideTextKey =>
95
+ (GUIDE_TEXT_KEYS as readonly unknown[]).includes(value);
96
+
97
+ /** The guide the server sent, or an error saying what this CLI cannot use. */
98
+ export const parseGuide = (value: unknown): Guide => {
99
+ const guide = value as Partial<Record<keyof Guide, unknown>> | null;
100
+ if (guide?.protocol !== GUIDE_PROTOCOL)
101
+ throw new Error(`the guide is not protocol ${GUIDE_PROTOCOL}`);
102
+ const texts = guide.texts as Record<string, unknown> | undefined;
103
+ const parsed = {} as Record<GuideTextKey, string>;
104
+ for (const key of GUIDE_TEXT_KEYS) {
105
+ const text = texts?.[key];
106
+ if (typeof text !== "string") throw new Error(`the guide has no "${key}"`);
107
+ parsed[key] = sanitizeTerminalText(text);
108
+ }
109
+ if (!Array.isArray(guide.intro)) throw new Error("the guide has no intro");
110
+ const intro = guide.intro.map((step: unknown): GuideStep => {
111
+ const { action, text, ...rest } = (step ?? {}) as Record<string, unknown>;
112
+ const allowed =
113
+ (LOCAL_ACTIONS as readonly unknown[]).includes(action) &&
114
+ Object.keys(rest).length === 0 &&
115
+ (action === "print" ? isTextKey(text) : text === undefined);
116
+ if (!allowed)
117
+ throw new Error(
118
+ `the guide asks for an action this CLI does not run: ${sanitizeTerminalText(JSON.stringify(step) ?? "").slice(0, 80)}`,
119
+ );
120
+ return action === "print"
121
+ ? { action, text: text as GuideTextKey }
122
+ : { action: action as "print-status" | "announce-agent" };
123
+ });
124
+ return { protocol: GUIDE_PROTOCOL, texts: parsed, intro };
125
+ };
126
+
127
+ /**
128
+ * The guide for this setup, from `<setupUrl>/guide`. Setup is online-only:
129
+ * without the guide the command fails. Redirects are refused, so the guide
130
+ * only ever comes from the setup URL `parseSetupUrl` accepted.
131
+ */
132
+ export const loadGuide = async (
133
+ setupUrl: string,
134
+ { started, opened }: GuideContext,
135
+ ): Promise<Guide> => {
136
+ const query = new URLSearchParams({
137
+ protocol: String(GUIDE_PROTOCOL),
138
+ started,
139
+ opened: opened ? "1" : "0",
140
+ });
141
+ const offline = `Setup needs internet access to ${setupUrl}`;
142
+ let response: Response;
143
+ try {
144
+ response = await fetch(`${setupUrl.replace(/\/$/, "")}/guide?${query}`, {
145
+ redirect: "error",
146
+ signal: AbortSignal.timeout(TIMEOUT_MS),
147
+ });
148
+ } catch {
149
+ throw new Error(offline);
150
+ }
151
+ if (response.status === 426) {
152
+ const { supported = [] } = (await response.json().catch(() => ({}))) as {
153
+ supported?: unknown[];
154
+ };
155
+ throw new Error(
156
+ supported.some((v) => typeof v === "number" && v > GUIDE_PROTOCOL)
157
+ ? "this setup-agent is older than the setup server supports; run the latest setup-agent"
158
+ : "this setup-agent is newer than the setup server; try again once the server updates",
159
+ );
160
+ }
161
+ if (!response.ok) throw new Error(`${offline} (HTTP ${response.status})`);
162
+ try {
163
+ let body = "";
164
+ let bytes = 0;
165
+ const decoder = new TextDecoder();
166
+ for await (const chunk of response.body ?? []) {
167
+ bytes += chunk.byteLength;
168
+ if (bytes > MAX_GUIDE_BYTES) throw new Error("it is too large");
169
+ body += decoder.decode(chunk, { stream: true });
170
+ }
171
+ return parseGuide(JSON.parse(body + decoder.decode()));
172
+ } catch (error) {
173
+ throw new Error(
174
+ `the setup server sent a guide this setup-agent cannot use (${error instanceof Error ? error.message : String(error)})`,
175
+ );
176
+ }
177
+ };
package/src/host.ts CHANGED
@@ -50,7 +50,7 @@ const useCheckoutHost = (restored: unknown) => {
50
50
  if (state.status === "waiting") {
51
51
  throw reject(
52
52
  "planning-not-started",
53
- 'ask the user to return to their browser and click "Begin plan"; wait with "wait start"',
53
+ 'the setup page starts planning on its own once the user opens it; wait with "wait start"',
54
54
  );
55
55
  }
56
56
  };
package/src/index.ts CHANGED
@@ -28,3 +28,5 @@ export { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
28
28
  export type { PresetId, PresetOverrides } from "./presets";
29
29
  export { connectCheckout } from "./client";
30
30
  export type { CheckoutClient } from "./client";
31
+ export { GUIDE_PROTOCOL, parseGuide } from "./guide";
32
+ export type { Guide, GuideContext, GuideStep, GuideTextKey } from "./guide";
package/src/needs.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { testGuide } from "./tests/guide-fixture";
1
2
  import { describe, expect, it } from "vitest";
2
3
  import {
3
4
  answerNeed,
@@ -70,7 +71,7 @@ describe("terminalQuestion", () => {
70
71
  presetInput("framework") as Checkout.Input,
71
72
  presetInput("llm-provider") as Checkout.Input,
72
73
  ),
73
- ).map(terminalQuestion);
74
+ ).map((need) => terminalQuestion(need, testGuide));
74
75
  expect(framework!.options?.map((option) => option.id)).toEqual([
75
76
  "ai-sdk:typescript",
76
77
  "mastra:typescript",
@@ -80,10 +81,8 @@ describe("terminalQuestion", () => {
80
81
  expect(formatTerminalQuestion(framework!)).toMatch(
81
82
  /^Which agent framework runs your backend\? Options: ai-sdk:typescript \(Vercel AI SDK \(TypeScript\): Shortest path/,
82
83
  );
83
- expect(model!.question).toContain("The API key is not typed here");
84
- expect(model!.answerFormat).toBe(
85
- "<provider>:<model>, such as openai:gpt-5",
86
- );
84
+ expect(model!.question).toContain("[need.input.model]");
85
+ expect(model!.answerFormat).toBe("[need.format.model]");
87
86
  });
88
87
  });
89
88
 
package/src/needs.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  parseMultipleAnswer,
12
12
  } from "./protocol";
13
13
  import type { Checkout } from "./protocol";
14
+ import { renderText } from "./guide";
15
+ import type { Guide, GuideTextKey } from "./guide";
14
16
 
15
17
  /**
16
18
  * Something the setup waits on the user for: an open question, a proposed
@@ -86,49 +88,62 @@ const optionList = (input: Checkout.Input): NeedOption[] =>
86
88
  : [entry(option.id, option.label)];
87
89
  });
88
90
 
89
- const ANSWER_FORMATS: Record<Checkout.InputKind, string> = {
90
- text: "the user's text, as one shell word",
91
- product: "add or decline",
92
- model: "<provider>:<model>, such as openai:gpt-5",
93
- "entry-point": "one option id",
94
- choice: "one option id, or the user's own text",
95
- };
91
+ const ANSWER_FORMATS = {
92
+ text: "need.format.text",
93
+ product: "need.format.product",
94
+ model: "need.format.model",
95
+ "entry-point": "need.format.entry-point",
96
+ choice: "need.format.choice",
97
+ } as const satisfies Record<Checkout.InputKind, GuideTextKey>;
96
98
 
97
- export const terminalQuestion = (need: SetupNeed): TerminalQuestion => {
99
+ /** A need as a question for the agent's own conversation, worded by the setup server's `guide`. */
100
+ export const terminalQuestion = (
101
+ need: SetupNeed,
102
+ guide: Pick<Guide, "texts">,
103
+ ): TerminalQuestion => {
104
+ const text = (key: GuideTextKey, values?: Record<string, string>) =>
105
+ renderText(guide, key, values);
98
106
  if (need.kind === "plan")
99
107
  return {
100
- question:
101
- "Approve the setup plan? Show the user the plan you proposed and ask whether to go ahead or what to change.",
108
+ question: text("need.plan.question"),
102
109
  options: [
103
- { id: "approve", label: "Approve the plan" },
104
- { id: "revise", label: "Ask for changes (pass --feedback)" },
110
+ { id: "approve", label: text("need.plan.approve") },
111
+ { id: "revise", label: text("need.plan.revise") },
105
112
  ],
106
- answerFormat: 'approve, or revise --feedback "<what to change>"',
113
+ answerFormat: text("need.plan.format"),
107
114
  };
108
115
  if (need.kind === "finish")
109
116
  return {
110
- question: `Everything is installed${need.completion.preview ? ` and running at ${need.completion.preview}` : ""}. Close the setup, or keep going?`,
111
- options: [{ id: "close", label: "Close the setup" }],
112
- answerFormat:
113
- "close; if the user wants more, do it and run done again instead",
117
+ question: need.completion.preview
118
+ ? text("need.finish.question-preview", {
119
+ preview: need.completion.preview,
120
+ })
121
+ : text("need.finish.question"),
122
+ options: [{ id: "close", label: text("need.finish.close") }],
123
+ answerFormat: text("need.finish.format"),
114
124
  };
115
125
  const { input } = need;
116
126
  const options =
117
127
  input.kind === "product"
118
128
  ? [
119
- { id: "add", label: `Add ${input.product ?? "the product"}` },
120
- { id: "decline", label: "Leave it out" },
129
+ {
130
+ id: "add",
131
+ label: text("need.product.add", {
132
+ product: input.product ?? "the product",
133
+ }),
134
+ },
135
+ { id: "decline", label: text("need.product.decline") },
121
136
  ]
122
137
  : input.kind === "text"
123
138
  ? undefined
124
139
  : optionList(input);
125
140
  return {
126
- question: `${input.prompt.trim() || "Your agent needs an answer."}${input.optional ? " (optional; --dismiss skips it)" : ""}${input.kind === "model" ? " The API key is not typed here: the user adds it to the project's env file themselves." : ""}`,
141
+ question: `${input.prompt.trim() || text("need.input.empty-prompt")}${input.optional ? text("need.input.optional") : ""}${input.kind === "model" ? text("need.input.model") : ""}`,
127
142
  ...(options && { options }),
128
143
  ...(input.multiple && { multiple: true as const }),
129
- answerFormat: input.multiple
130
- ? "option ids separated by commas, or the user's own text"
131
- : ANSWER_FORMATS[input.kind],
144
+ answerFormat: text(
145
+ input.multiple ? "need.format.multiple" : ANSWER_FORMATS[input.kind],
146
+ ),
132
147
  };
133
148
  };
134
149
 
package/src/sdk.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import * as sdk from "./sdk";
3
- import { initialCheckoutState } from "./protocol";
3
+ import { OPTION_ICONS, initialCheckoutState } from "./protocol";
4
4
  import type { Checkout } from "./protocol";
5
5
  import type { CheckoutClient } from "./client";
6
6
 
@@ -62,20 +62,14 @@ describe("setup-agent/sdk", () => {
62
62
  agent: { cwd: "/app" },
63
63
  },
64
64
  });
65
- expect(out).toMatch(/^my-tool setup is running\./);
66
- expect(out).toContain(
67
- `npx my-tool setup https://checkout.test/${session.linkId} stream`,
65
+ // The briefing is the server's guide, with this command's values filled in.
66
+ const url = `https://checkout.test/${session.linkId}`;
67
+ expect(out).toBe(
68
+ `[stream.briefing] my-tool setup npx my-tool setup ${url} ${session.browserUrl}[briefing] npx my-tool setup ${url} ${OPTION_ICONS.join(", ")}\n[stream.briefing-footer] ${session.browserUrl}${JSON.stringify({ status: "planning", products: [{ slug: "assistant-ui", name: "assistant-ui" }] })}\n`,
69
+ );
70
+ expect(String(vi.mocked(fetch).mock.calls[0]?.[0])).toBe(
71
+ `${url}/guide?protocol=1&started=agent-stream&opened=1`,
68
72
  );
69
- expect(out).toContain("run_in_background");
70
- expect(out).toContain("never end your turn to wait for it");
71
- expect(out).not.toContain("Read its new output as it arrives");
72
- expect(out).toContain('"input.needed"');
73
- expect(out).toContain('"browser.opened"');
74
- expect(out).not.toContain("start a persistent Monitor");
75
- expect(out).toContain("opens the user's browser by itself");
76
- expect(out).not.toContain("npx setup-agent");
77
- expect(out).not.toContain("click Begin plan");
78
- expect(out).toContain(session.browserUrl);
79
73
  expect(open).not.toHaveBeenCalled();
80
74
  expect(calls.map(([name]) => name)).toEqual([
81
75
  "checkout/create",
package/src/sdk.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import { randomInt } from "node:crypto";
7
7
  import { detectAgentKind } from "./agents";
8
- import { agentInstructions } from "./cli";
8
+ import { OPTION_ICONS } from "./protocol";
9
+ import { loadGuide, renderText } from "./guide";
10
+ import type { Guide } from "./guide";
9
11
  import { connectCheckout } from "./client";
10
12
  import type { CheckoutClient } from "./client";
11
13
  import type { Checkout } from "./protocol";
@@ -57,37 +59,19 @@ export type RunAgentSetupOptions = {
57
59
  /** What the agent reads first in the output of a setup command that keeps running. */
58
60
  const setupBriefing = (
59
61
  session: SetupSession,
60
- {
61
- command,
62
- name = "Setup",
63
- canOpen = true,
64
- }: Pick<RunAgentSetupOptions, "command" | "name" | "canOpen">,
62
+ guide: Guide,
63
+ { command, name = "Setup" }: Pick<RunAgentSetupOptions, "command" | "name">,
65
64
  ) => {
66
- const url = shellWord(session.agentUrl);
67
- return `${name} is running. This command keeps running: it is the setup's stream.
68
-
69
- 1. Keep it running in the background until the setup ends. If you started it in the foreground, stop it and resume it in the background (Claude Code: Bash with run_in_background; Codex and other agents: add > "\${TMPDIR:-/tmp}/setup-${session.linkId}.log" 2>&1 & and read that file):
70
- ${command} ${url} stream${canOpen ? "" : " --no-open"}
71
- 2. Its new output does not wake you, so never end your turn to wait for it. Read the new lines between your other commands; each line after this briefing is a JSON array of events. Answers reach you through the blocking "wait" commands below.
72
- 3. Run every other command in another shell: ${command} ${url} <command>.
73
- 4. ${
74
- canOpen
75
- ? "Setup opens the user's browser by itself when you ask the user something; do not open it yourself."
76
- : `Setup will not open a browser (--no-open). Give the user ${session.browserUrl} once, or ask in your own conversation.`
77
- } On an "input.needed" event, do what its message says; to answer from here, run its "answer" command with the user's reply.
78
-
79
- ${agentInstructions(session.agentUrl, {
80
- command,
81
- startedByAgent: {
65
+ const values = {
66
+ name,
67
+ cmd: command,
68
+ url: shellWord(session.agentUrl),
69
+ linkId: session.linkId,
82
70
  browserUrl: session.browserUrl,
83
- streaming: true,
84
- opened: canOpen,
85
- },
86
- })}
87
- Setup page, if the user asks for it: ${session.browserUrl}
88
-
89
- Current setup:
90
- ${JSON.stringify({ status: "planning", products: session.products })}
71
+ icons: OPTION_ICONS.join(", "),
72
+ };
73
+ return `${renderText(guide, "stream.briefing", values)}${renderText(guide, "briefing", values)}
74
+ ${renderText(guide, "stream.briefing-footer", values)}${JSON.stringify({ status: "planning", products: session.products })}
91
75
  `;
92
76
  };
93
77
 
@@ -103,6 +87,11 @@ export const runAgentSetup = async (options: RunAgentSetupOptions) => {
103
87
  const linkId = createSetupId();
104
88
  const setupId = createSetupId();
105
89
  const agentUrl = `${options.server.replace(/\/$/, "")}/${linkId}`;
90
+ // Online-only: the briefing and the stream's wording come from the setup server.
91
+ const guide = await loadGuide(agentUrl, {
92
+ started: "agent-stream",
93
+ opened: options.canOpen !== false,
94
+ });
106
95
  const client = await connect(agentUrl);
107
96
  try {
108
97
  const kind = detectAgentKind(options.env);
@@ -124,9 +113,10 @@ export const runAgentSetup = async (options: RunAgentSetupOptions) => {
124
113
  };
125
114
  options.onSession?.(session);
126
115
  (options.write ?? ((text) => process.stdout.write(text)))(
127
- setupBriefing(session, options),
116
+ setupBriefing(session, guide, options),
128
117
  );
129
118
  const stream = await followSetup(client, {
119
+ guide,
130
120
  ...options.stream,
131
121
  agent: { ...(kind !== undefined && { kind }), ...options.stream?.agent },
132
122
  browser: {
@@ -1,3 +1,4 @@
1
+ import { testGuide } from "./tests/guide-fixture";
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import { answerNeed } from "./needs";
3
4
  import { initialCheckoutState } from "./protocol";
@@ -92,6 +93,7 @@ describe("followSetup", () => {
92
93
  const lines: StreamEvent[][] = [];
93
94
  const open = vi.fn();
94
95
  const stream = await followSetup(fake.client, {
96
+ guide: testGuide,
95
97
  print: (events) => lines.push(events),
96
98
  agent: { cwd: "/app", kind: "claude" },
97
99
  browser: {
@@ -130,7 +132,7 @@ describe("followSetup", () => {
130
132
  event: "browser.opened",
131
133
  id: "q1",
132
134
  url: PAGE,
133
- message: `Opened the setup in the user's browser for "Which package manager?". Tell the user once that it is waiting there, and give them https://setup.test/#link=abc&setup=s1 in case it did not open. Then keep working in the same turn; when you need the answer, block on it with "${WAIT} q1". Do not end your turn to wait: nothing on this stream wakes you.`,
135
+ message: `[stream.opened.input] Which package manager? ${PAGE} with "${WAIT} q1"`,
134
136
  },
135
137
  ]);
136
138
 
@@ -160,14 +162,14 @@ describe("followSetup", () => {
160
162
  event: "input.needed",
161
163
  id: "q3",
162
164
  reason: "recently-opened",
163
- message: `A browser tab for setup was opened 10s ago. Wait for the user there with "${WAIT} q3", or ask the user directly in the terminal: Which registry? Options: pnpm (pnpm), npm (npm).`,
165
+ message: `[stream.ask.recently-opened] 10 with "${WAIT} q3" Which registry? Options: pnpm (pnpm), npm (npm).`,
164
166
  question: "Which registry?",
165
167
  options: [
166
168
  { id: "pnpm", label: "pnpm" },
167
169
  { id: "npm", label: "npm" },
168
170
  ],
169
- answerFormat: "one option id, or the user's own text",
170
- answer: `${ANSWER} q3 <one option id, or the user's own text>`,
171
+ answerFormat: "[need.format.choice]",
172
+ answer: `${ANSWER} q3 <[need.format.choice]>`,
171
173
  });
172
174
 
173
175
  // The agent asked in its own conversation and answers for the user.
@@ -195,7 +197,7 @@ describe("followSetup", () => {
195
197
  id: "q1",
196
198
  reason: "browser-closed",
197
199
  message:
198
- "The user closed the setup tab. Ask the user directly in the terminal: Which package manager? Options: pnpm (pnpm), npm (npm).",
200
+ "[stream.ask.browser-closed] Which package manager? Options: pnpm (pnpm), npm (npm).",
199
201
  });
200
202
  // A question right after: inside the 30s window, and the tab is known to be gone.
201
203
  t.ask("Which registry?");
@@ -211,6 +213,7 @@ describe("followSetup", () => {
211
213
  const fake = fakeClient();
212
214
  const lines: StreamEvent[][] = [];
213
215
  const stream = await followSetup(fake.client, {
216
+ guide: testGuide,
214
217
  print: (events) => lines.push(events),
215
218
  agent: { cwd: "/app" },
216
219
  });
package/src/stream.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  parseMultipleAnswer,
12
12
  } from "./protocol";
13
13
  import type { Checkout } from "./protocol";
14
+ import { renderText } from "./guide";
15
+ import type { Guide } from "./guide";
14
16
 
15
17
  export type StreamEvent =
16
18
  | { event: "planning.started" }
@@ -47,14 +49,14 @@ export type StreamEvent =
47
49
  answer: string;
48
50
  } & TerminalQuestion);
49
51
 
50
- const STAY_CONNECTED =
51
- "This setup is over, not your connection. Keep the stream running, tell the user you are connected and waiting for their next setup, and run wait start.";
52
-
52
+ /** The events between two states, with hints worded by the setup server's `guide`. */
53
53
  export const diffEvents = (
54
54
  before: Checkout.State,
55
55
  after: Checkout.State,
56
+ guide: Pick<Guide, "texts">,
56
57
  ): StreamEvent[] => {
57
58
  const events: StreamEvent[] = [];
59
+ const stayConnected = renderText(guide, "stream.stay-connected");
58
60
  if (before.status === "waiting" && after.status === "planning") {
59
61
  events.push({ event: "planning.started" });
60
62
  }
@@ -110,10 +112,10 @@ export const diffEvents = (
110
112
  }
111
113
  }
112
114
  if (before.status !== "done" && after.status === "done") {
113
- events.push({ event: "finished", next: STAY_CONNECTED });
115
+ events.push({ event: "finished", next: stayConnected });
114
116
  }
115
117
  if (before.status !== "cancelled" && after.status === "cancelled") {
116
- events.push({ event: "cancelled", next: STAY_CONNECTED });
118
+ events.push({ event: "cancelled", next: stayConnected });
117
119
  }
118
120
  if (after.id !== null && after.id !== before.id) {
119
121
  events.push({
@@ -165,20 +167,11 @@ export const shellWord = (value: string) =>
165
167
  ? value
166
168
  : `'${value.replaceAll("'", `'\\''`)}'`;
167
169
 
168
- const ASK_MESSAGES: Record<
169
- AskReason,
170
- (
171
- question: string,
172
- context: { agoSeconds: number; page: string; wait: string },
173
- ) => string
174
- > = {
175
- "recently-opened": (question, { agoSeconds, wait }) =>
176
- `A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there${wait}, or ask the user directly in the terminal: ${question}`,
177
- "browser-closed": (question) =>
178
- `The user closed the setup tab. Ask the user directly in the terminal: ${question}`,
179
- "no-browser": (question, { page }) =>
180
- `No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`,
181
- };
170
+ const ASK_TEXTS = {
171
+ "recently-opened": "stream.ask.recently-opened",
172
+ "browser-closed": "stream.ask.browser-closed",
173
+ "no-browser": "stream.ask.no-browser",
174
+ } as const satisfies Record<AskReason, string>;
182
175
 
183
176
  /** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */
184
177
  const waitCommandFor = (answerCommand: string) =>
@@ -191,7 +184,8 @@ const gateEvent = (
191
184
  action: BrowserGateAction,
192
185
  answerCommand: string,
193
186
  page: string,
194
- waitCommand?: string,
187
+ waitCommand: string | undefined,
188
+ guide: Pick<Guide, "texts">,
195
189
  ): StreamEvent => {
196
190
  const { need } = action;
197
191
  // Closing the setup is the user's call after "done"; there is nothing to block on.
@@ -204,14 +198,23 @@ const gateEvent = (
204
198
  event: "browser.opened",
205
199
  id: need.id,
206
200
  url: action.url,
207
- message: `Opened the setup in the user's browser for ${need.kind === "input" ? `"${need.input.prompt.trim() || "Your agent needs an answer."}"` : need.kind === "plan" ? "the plan review" : "closing the setup"}. Tell the user once that it is waiting there, and give them ${action.url} in case it did not open.${need.kind === "finish" ? " The answer arrives on this stream." : ` Then keep working in the same turn; when you need the answer, block on it${wait || ' with "wait"'}. Do not end your turn to wait: nothing on this stream wakes you.`}`,
201
+ message: renderText(guide, `stream.opened.${need.kind}`, {
202
+ url: action.url,
203
+ wait: wait || ' with "wait"',
204
+ prompt:
205
+ need.kind === "input"
206
+ ? need.input.prompt.trim() ||
207
+ renderText(guide, "need.input.empty-prompt")
208
+ : "",
209
+ }),
208
210
  };
209
- const question = terminalQuestion(need);
211
+ const question = terminalQuestion(need, guide);
210
212
  return {
211
213
  event: "input.needed",
212
214
  id: need.id,
213
215
  reason: action.reason,
214
- message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {
216
+ message: renderText(guide, ASK_TEXTS[action.reason], {
217
+ question: formatTerminalQuestion(question),
215
218
  agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),
216
219
  page,
217
220
  wait,
@@ -222,6 +225,8 @@ const gateEvent = (
222
225
  };
223
226
 
224
227
  export type FollowSetupOptions = {
228
+ /** The setup server's wording for the stream's messages. */
229
+ guide: Pick<Guide, "texts">;
225
230
  /** Writes one line of events; the default prints a JSON array to stdout. */
226
231
  print?: (events: StreamEvent[]) => void;
227
232
  /** The agent the stream reports in its hello; detected by default. */
@@ -247,7 +252,7 @@ export type FollowSetupOptions = {
247
252
  */
248
253
  export const followSetup = async (
249
254
  client: Pick<CheckoutClient, "state" | "subscribe" | "commands">,
250
- { print, agent, browser }: FollowSetupOptions = {},
255
+ { print, agent, browser, guide }: FollowSetupOptions,
251
256
  ) => {
252
257
  const write =
253
258
  print ??
@@ -267,7 +272,7 @@ export const followSetup = async (
267
272
  });
268
273
  let previous = client.state;
269
274
  if (previous === undefined) throw new Error("the setup has no state");
270
- let pending = diffEvents({ ...previous, log: [] }, previous);
275
+ let pending = diffEvents({ ...previous, log: [] }, previous, guide);
271
276
  let timer: ReturnType<typeof setTimeout> | undefined;
272
277
  const flush = () => {
273
278
  clearTimeout(timer);
@@ -284,6 +289,7 @@ export const followSetup = async (
284
289
  browser!.answerCommand,
285
290
  page(state) ?? "",
286
291
  waitCommandFor(browser!.answerCommand),
292
+ guide,
287
293
  ),
288
294
  );
289
295
  }
@@ -291,7 +297,7 @@ export const followSetup = async (
291
297
  const unsubscribe = client.subscribe(() => {
292
298
  const next = client.state;
293
299
  if (next === undefined) return;
294
- pending.push(...diffEvents(previous!, next));
300
+ pending.push(...diffEvents(previous!, next, guide));
295
301
  previous = next;
296
302
  runGate(next);
297
303
  if (pending.length > 0 && timer === undefined)