setup-agent 0.0.8 → 0.1.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.
Files changed (52) hide show
  1. package/README.md +31 -0
  2. package/dist/agents.d.ts +33 -0
  3. package/dist/agents.d.ts.map +1 -0
  4. package/dist/agents.js +221 -0
  5. package/dist/agents.js.map +1 -0
  6. package/dist/browser.d.ts +40 -0
  7. package/dist/browser.d.ts.map +1 -0
  8. package/dist/browser.js +91 -0
  9. package/dist/browser.js.map +1 -0
  10. package/dist/cli.d.ts +38 -49
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +89 -180
  13. package/dist/cli.js.map +1 -1
  14. package/dist/host.d.ts.map +1 -1
  15. package/dist/host.js +13 -0
  16. package/dist/host.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/needs.d.ts +62 -0
  21. package/dist/needs.d.ts.map +1 -0
  22. package/dist/needs.js +184 -0
  23. package/dist/needs.js.map +1 -0
  24. package/dist/protocol.d.ts +16 -0
  25. package/dist/protocol.d.ts.map +1 -1
  26. package/dist/protocol.js +8 -1
  27. package/dist/protocol.js.map +1 -1
  28. package/dist/sdk.d.ts +48 -0
  29. package/dist/sdk.d.ts.map +1 -0
  30. package/dist/sdk.js +97 -0
  31. package/dist/sdk.js.map +1 -0
  32. package/dist/stream.d.ts +93 -0
  33. package/dist/stream.d.ts.map +1 -0
  34. package/dist/stream.js +184 -0
  35. package/dist/stream.js.map +1 -0
  36. package/package.json +16 -2
  37. package/src/agents.test.ts +137 -0
  38. package/src/agents.ts +243 -0
  39. package/src/browser.test.ts +192 -0
  40. package/src/browser.ts +146 -0
  41. package/src/cli.test.ts +78 -0
  42. package/src/cli.ts +189 -258
  43. package/src/host.test.ts +25 -0
  44. package/src/host.ts +7 -0
  45. package/src/index.ts +1 -0
  46. package/src/needs.test.ts +180 -0
  47. package/src/needs.ts +273 -0
  48. package/src/protocol.ts +17 -0
  49. package/src/sdk.test.ts +111 -0
  50. package/src/sdk.ts +145 -0
  51. package/src/stream.test.ts +223 -0
  52. package/src/stream.ts +314 -0
package/src/sdk.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `<tool> setup` for coding agents, for a CLI to embed. `runAgentSetup` is
3
+ * the whole command; route `<tool> setup <url> <step> ...` to
4
+ * `runSetupCommand` from `setup-agent/cli`. Node only.
5
+ */
6
+ import { randomInt } from "node:crypto";
7
+ import { detectAgentKind } from "./agents";
8
+ import { agentInstructions } from "./cli";
9
+ import { connectCheckout } from "./client";
10
+ import type { CheckoutClient } from "./client";
11
+ import type { Checkout } from "./protocol";
12
+ import { followSetup, setupPageUrl, shellWord } from "./stream";
13
+ import type { FollowSetupOptions } from "./stream";
14
+
15
+ /** A random id in the alphabet and length the setup page uses for its own. */
16
+ const createSetupId = () =>
17
+ Array.from(
18
+ { length: 12 },
19
+ () =>
20
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[
21
+ randomInt(62)
22
+ ]!,
23
+ ).join("");
24
+
25
+ export type SetupSession = {
26
+ linkId: string;
27
+ setupId: string;
28
+ /** The URL every setup command takes. */
29
+ agentUrl: string;
30
+ /** The page the user follows the setup on. */
31
+ browserUrl: string;
32
+ products: Checkout.ProductSeed[];
33
+ };
34
+
35
+ export type RunAgentSetupOptions = {
36
+ /** The setup server, such as https://checkout.assistant-ui.com. */
37
+ server: string;
38
+ /** The setup app the browser opens, such as https://setup.assistant-ui.com. */
39
+ setupAppUrl: string;
40
+ products: Checkout.ProductSeed[];
41
+ /** How the agent invokes setup commands, before the URL: `npx assistant-ui@latest setup`. */
42
+ command: string;
43
+ /** What the product calls this, for the briefing's first line. */
44
+ name?: string;
45
+ /** False for `--no-open`: the agent asks the user itself. */
46
+ canOpen?: boolean;
47
+ /** Opens the setup page; the default browser by default. */
48
+ open?: (url: string) => void;
49
+ /** Called once the session exists, before the briefing prints. */
50
+ onSession?: (session: SetupSession) => void;
51
+ write?: (text: string) => void;
52
+ env?: NodeJS.ProcessEnv;
53
+ connect?: (url: string) => Promise<CheckoutClient>;
54
+ stream?: Pick<FollowSetupOptions, "print" | "agent">;
55
+ };
56
+
57
+ /** What the agent reads first in the output of a setup command that keeps running. */
58
+ const setupBriefing = (
59
+ session: SetupSession,
60
+ {
61
+ command,
62
+ name = "Setup",
63
+ canOpen = true,
64
+ }: Pick<RunAgentSetupOptions, "command" | "name" | "canOpen">,
65
+ ) => {
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. Read its new output as it arrives. Each line after this briefing is a JSON array of events.
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: {
82
+ 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 })}
91
+ `;
92
+ };
93
+
94
+ /**
95
+ * `<tool> setup` for a coding agent, in one call: opens a new link on the
96
+ * setup server, creates a setup there and starts planning, prints the
97
+ * briefing, then streams the setup and opens the browser only when it waits
98
+ * on the user. Resolves with the running stream; the caller stops it (on
99
+ * SIGINT, say) and disposes the client.
100
+ */
101
+ export const runAgentSetup = async (options: RunAgentSetupOptions) => {
102
+ const connect = options.connect ?? connectCheckout;
103
+ const linkId = createSetupId();
104
+ const setupId = createSetupId();
105
+ const agentUrl = `${options.server.replace(/\/$/, "")}/${linkId}`;
106
+ const client = await connect(agentUrl);
107
+ try {
108
+ const kind = detectAgentKind(options.env);
109
+ await client.commands["checkout/create"]({
110
+ id: setupId,
111
+ products: options.products,
112
+ });
113
+ await client.commands["checkout/begin-plan"]();
114
+ // The agent shows as connected once the stream says hello with the cwd.
115
+ await client.commands["agent/intro"]({
116
+ ...(kind !== undefined && { kind }),
117
+ });
118
+ const session: SetupSession = {
119
+ linkId,
120
+ setupId,
121
+ agentUrl,
122
+ browserUrl: setupPageUrl(options.setupAppUrl, linkId, setupId),
123
+ products: options.products,
124
+ };
125
+ options.onSession?.(session);
126
+ (options.write ?? ((text) => process.stdout.write(text)))(
127
+ setupBriefing(session, options),
128
+ );
129
+ const stream = await followSetup(client, {
130
+ ...options.stream,
131
+ agent: { ...(kind !== undefined && { kind }), ...options.stream?.agent },
132
+ browser: {
133
+ setupAppUrl: options.setupAppUrl,
134
+ linkId,
135
+ answerCommand: `${options.command} ${shellWord(agentUrl)} answer`,
136
+ ...(options.open && { open: options.open }),
137
+ ...(options.canOpen !== undefined && { canOpen: options.canOpen }),
138
+ },
139
+ });
140
+ return { session, stream, client };
141
+ } catch (error) {
142
+ client.dispose();
143
+ throw error;
144
+ }
145
+ };
@@ -0,0 +1,223 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { answerNeed } from "./needs";
3
+ import { initialCheckoutState } from "./protocol";
4
+ import type { Checkout } from "./protocol";
5
+ import { followSetup } from "./stream";
6
+ import type { StreamEvent } from "./stream";
7
+
8
+ const PAGE = "https://setup.test/#link=abc&setup=s1";
9
+ const ANSWER = "npx assistant-ui@latest setup https://checkout.test/abc answer";
10
+
11
+ /** A client whose state the test changes, notifying like a statewire client. */
12
+ const fakeClient = () => {
13
+ let state: Checkout.State = {
14
+ ...initialCheckoutState(),
15
+ id: "s1",
16
+ createdAt: 1,
17
+ status: "planning",
18
+ };
19
+ const listeners = new Set<() => void>();
20
+ const calls: string[] = [];
21
+ const change = (mutate: (draft: Checkout.State) => void) => {
22
+ const draft = structuredClone(state);
23
+ mutate(draft);
24
+ state = draft;
25
+ for (const listener of listeners) listener();
26
+ };
27
+ const commands = new Proxy(
28
+ {},
29
+ {
30
+ get:
31
+ (_, name: string) =>
32
+ async (params: { inputId: string; answer: string }) => {
33
+ calls.push(name);
34
+ if (name === "checkout/answer")
35
+ change((draft) => {
36
+ const input = draft.inputs.find(
37
+ (entry) => entry.id === params.inputId,
38
+ )!;
39
+ input.status = "answered";
40
+ input.answer = params.answer;
41
+ });
42
+ },
43
+ },
44
+ );
45
+ return {
46
+ client: {
47
+ get state() {
48
+ return state;
49
+ },
50
+ subscribe: (listener: () => void) => {
51
+ listeners.add(listener);
52
+ return () => listeners.delete(listener);
53
+ },
54
+ commands,
55
+ } as unknown as Parameters<typeof followSetup>[0],
56
+ calls,
57
+ change,
58
+ ask: (prompt: string) =>
59
+ change((draft) => {
60
+ draft.inputs.push({
61
+ id: `q${draft.inputs.length + 1}`,
62
+ phase: "planning",
63
+ kind: "choice",
64
+ prompt,
65
+ options: [
66
+ { id: "pnpm", label: "pnpm" },
67
+ { id: "npm", label: "npm" },
68
+ ],
69
+ optional: false,
70
+ status: "open",
71
+ createdAt: Date.now(),
72
+ });
73
+ }),
74
+ heartbeat: () =>
75
+ change((draft) => {
76
+ draft.browser = { lastSeenAt: Date.now(), connected: true };
77
+ }),
78
+ };
79
+ };
80
+
81
+ describe("followSetup", () => {
82
+ beforeEach(() => {
83
+ vi.useFakeTimers({ now: 1_000_000 });
84
+ });
85
+ afterEach(() => {
86
+ vi.useRealTimers();
87
+ });
88
+
89
+ const start = async () => {
90
+ const fake = fakeClient();
91
+ const lines: StreamEvent[][] = [];
92
+ const open = vi.fn();
93
+ const stream = await followSetup(fake.client, {
94
+ print: (events) => lines.push(events),
95
+ agent: { cwd: "/app", kind: "claude" },
96
+ browser: {
97
+ setupAppUrl: "https://setup.test",
98
+ linkId: "abc",
99
+ answerCommand: ANSWER,
100
+ open,
101
+ },
102
+ });
103
+ const events = () => lines.flat();
104
+ return { ...fake, stream, open, events };
105
+ };
106
+
107
+ it("says hello, keeps a heartbeat, and opens no browser before a question", async () => {
108
+ const t = await start();
109
+ expect(t.calls).toEqual(["agent/hello"]);
110
+ await vi.advanceTimersByTimeAsync(60_000);
111
+ expect(t.open).not.toHaveBeenCalled();
112
+ expect(t.calls.filter((name) => name === "agent/heartbeat")).toHaveLength(
113
+ 12,
114
+ );
115
+ expect(t.events()).toEqual([]);
116
+ await t.stream.stop();
117
+ expect(t.calls.at(-1)).toBe("agent/bye");
118
+ });
119
+
120
+ it("opens the page for the first question, reopens after a close, guards within 30s, and takes an answer from the agent", async () => {
121
+ const t = await start();
122
+
123
+ t.ask("Which package manager?");
124
+ await vi.advanceTimersByTimeAsync(300);
125
+ expect(t.open).toHaveBeenCalledTimes(1);
126
+ expect(t.open).toHaveBeenCalledWith(PAGE);
127
+ expect(t.events()).toEqual([
128
+ {
129
+ event: "browser.opened",
130
+ id: "q1",
131
+ url: PAGE,
132
+ message:
133
+ '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. The answer arrives on this stream.',
134
+ },
135
+ ]);
136
+
137
+ // The page joins, the user answers there and closes the tab.
138
+ await vi.advanceTimersByTimeAsync(2_000);
139
+ t.heartbeat();
140
+ t.change((draft) => {
141
+ draft.inputs[0]!.status = "answered";
142
+ draft.inputs[0]!.answer = "pnpm";
143
+ });
144
+ await vi.advanceTimersByTimeAsync(40_000);
145
+ expect(t.events().at(-1)).toMatchObject({
146
+ event: "input.answered",
147
+ inputId: "q1",
148
+ });
149
+
150
+ t.ask("Which lockfile?");
151
+ await vi.advanceTimersByTimeAsync(300);
152
+ expect(t.open).toHaveBeenCalledTimes(2);
153
+
154
+ await vi.advanceTimersByTimeAsync(9_700);
155
+ t.ask("Which registry?");
156
+ await vi.advanceTimersByTimeAsync(300);
157
+ expect(t.open).toHaveBeenCalledTimes(2);
158
+ const needed = t.events().at(-1);
159
+ expect(needed).toEqual({
160
+ event: "input.needed",
161
+ id: "q3",
162
+ reason: "recently-opened",
163
+ message:
164
+ "A browser tab for setup was opened 10s ago. Wait for the user there, or ask the user directly in the terminal: Which registry? Options: pnpm (pnpm), npm (npm).",
165
+ question: "Which registry?",
166
+ options: [
167
+ { id: "pnpm", label: "pnpm" },
168
+ { id: "npm", label: "npm" },
169
+ ],
170
+ answerFormat: "one option id, or the user's own text",
171
+ answer: `${ANSWER} q3 <one option id, or the user's own text>`,
172
+ });
173
+
174
+ // The agent asked in its own conversation and answers for the user.
175
+ await answerNeed(t.client, "q3", "npm");
176
+ await vi.advanceTimersByTimeAsync(300);
177
+ expect(t.events().at(-1)).toMatchObject({
178
+ event: "input.answered",
179
+ inputId: "q3",
180
+ answer: "npm",
181
+ });
182
+ await t.stream.stop();
183
+ });
184
+
185
+ it("tells the agent when the tab closed with a question waiting", async () => {
186
+ const t = await start();
187
+ t.ask("Which package manager?");
188
+ await vi.advanceTimersByTimeAsync(1_000);
189
+ t.heartbeat();
190
+ t.change((draft) => {
191
+ draft.browser!.connected = false;
192
+ });
193
+ await vi.advanceTimersByTimeAsync(8_000);
194
+ expect(t.events().at(-1)).toMatchObject({
195
+ event: "input.needed",
196
+ id: "q1",
197
+ reason: "browser-closed",
198
+ message:
199
+ "The user closed the setup tab. Ask the user directly in the terminal: Which package manager? Options: pnpm (pnpm), npm (npm).",
200
+ });
201
+ // A question right after: inside the 30s window, and the tab is known to be gone.
202
+ t.ask("Which registry?");
203
+ await vi.advanceTimersByTimeAsync(300);
204
+ expect(t.events().at(-1)).toMatchObject({
205
+ id: "q2",
206
+ reason: "browser-closed",
207
+ });
208
+ await t.stream.stop();
209
+ });
210
+
211
+ it("without browser options, streams the way setup-agent always has", async () => {
212
+ const fake = fakeClient();
213
+ const lines: StreamEvent[][] = [];
214
+ const stream = await followSetup(fake.client, {
215
+ print: (events) => lines.push(events),
216
+ agent: { cwd: "/app" },
217
+ });
218
+ fake.ask("Which package manager?");
219
+ await vi.advanceTimersByTimeAsync(60_000);
220
+ expect(lines).toEqual([]);
221
+ await stream.stop();
222
+ });
223
+ });
package/src/stream.ts ADDED
@@ -0,0 +1,314 @@
1
+ import spawn from "cross-spawn";
2
+ import { detectAgentKind } from "./agents";
3
+ import { createBrowserGate } from "./browser";
4
+ import type { AskReason, BrowserGateAction } from "./browser";
5
+ import type { CheckoutClient } from "./client";
6
+ import { formatTerminalQuestion, terminalQuestion } from "./needs";
7
+ import type { TerminalQuestion } from "./needs";
8
+ import {
9
+ AGENT_HEARTBEAT_MS,
10
+ classifyChoiceAnswer,
11
+ parseMultipleAnswer,
12
+ } from "./protocol";
13
+ import type { Checkout } from "./protocol";
14
+
15
+ export type StreamEvent =
16
+ | { event: "planning.started" }
17
+ | { event: "message.created"; messageId: string; text: string }
18
+ | {
19
+ event: "input.answered";
20
+ inputId: string;
21
+ prompt: string;
22
+ /** The entries of a multiple choice answer, else the answer text. */
23
+ answer: string | string[];
24
+ note?: string;
25
+ /** True when a choice answer is, or a multiple one contains, the user's own text rather than an option. */
26
+ custom?: true;
27
+ }
28
+ | { event: "input.dismissed"; inputId: string; prompt: string }
29
+ | { event: "plan.approved"; revision: number }
30
+ | { event: "plan.changes-requested"; revision: number; feedback: string }
31
+ | { event: "finished"; next: string }
32
+ | { event: "cancelled"; next: string }
33
+ | { event: "setup.created"; products: string[] }
34
+ | {
35
+ event: "browser.opened";
36
+ /** The need the tab opened for: an input id, plan or finish. */
37
+ id: string;
38
+ url: string;
39
+ message: string;
40
+ }
41
+ | ({
42
+ event: "input.needed";
43
+ id: string;
44
+ reason: AskReason;
45
+ message: string;
46
+ /** The command that answers it, with the answer's shape in angle brackets. */
47
+ answer: string;
48
+ } & TerminalQuestion);
49
+
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
+
53
+ export const diffEvents = (
54
+ before: Checkout.State,
55
+ after: Checkout.State,
56
+ ): StreamEvent[] => {
57
+ const events: StreamEvent[] = [];
58
+ if (before.status === "waiting" && after.status === "planning") {
59
+ events.push({ event: "planning.started" });
60
+ }
61
+ for (const entry of after.log) {
62
+ if (
63
+ entry.role === "user" &&
64
+ entry.acknowledgedAt === undefined &&
65
+ !before.log.some((previous) => previous.id === entry.id)
66
+ ) {
67
+ events.push({
68
+ event: "message.created",
69
+ messageId: entry.id,
70
+ text: entry.text,
71
+ });
72
+ }
73
+ }
74
+ for (const input of after.inputs) {
75
+ const previous = before.inputs.find(
76
+ (candidate) => candidate.id === input.id,
77
+ );
78
+ if (previous?.status === input.status) continue;
79
+ if (input.status === "answered") {
80
+ const answer = input.answer ?? "";
81
+ events.push({
82
+ event: "input.answered",
83
+ inputId: input.id,
84
+ prompt: input.prompt,
85
+ ...answerFields(input, answer),
86
+ });
87
+ } else if (input.status === "dismissed" && after.status !== "cancelled") {
88
+ events.push({
89
+ event: "input.dismissed",
90
+ inputId: input.id,
91
+ prompt: input.prompt,
92
+ });
93
+ }
94
+ }
95
+ for (const plan of after.plans) {
96
+ const previous = before.plans.find(
97
+ (candidate) => candidate.revision === plan.revision,
98
+ );
99
+ if (previous?.status === plan.status || plan.status === "proposed") {
100
+ continue;
101
+ }
102
+ if (plan.status === "approved") {
103
+ events.push({ event: "plan.approved", revision: plan.revision });
104
+ } else {
105
+ events.push({
106
+ event: "plan.changes-requested",
107
+ revision: plan.revision,
108
+ feedback: plan.feedback ?? "",
109
+ });
110
+ }
111
+ }
112
+ if (before.status !== "done" && after.status === "done") {
113
+ events.push({ event: "finished", next: STAY_CONNECTED });
114
+ }
115
+ if (before.status !== "cancelled" && after.status === "cancelled") {
116
+ events.push({ event: "cancelled", next: STAY_CONNECTED });
117
+ }
118
+ if (after.id !== null && after.id !== before.id) {
119
+ events.push({
120
+ event: "setup.created",
121
+ products: after.products.map((product) => product.slug),
122
+ });
123
+ }
124
+ return events;
125
+ };
126
+
127
+ export const answerFields = (input: Checkout.Input, answer: string) => ({
128
+ answer: (input.multiple && parseMultipleAnswer(answer)) || answer,
129
+ ...(input.note !== undefined && { note: input.note }),
130
+ ...(input.kind === "choice" &&
131
+ classifyChoiceAnswer(input, answer) === "custom" && {
132
+ custom: true as const,
133
+ }),
134
+ });
135
+
136
+ /** The setup page that joins a link; the link rides in the fragment, which never reaches a server. */
137
+ export const setupPageUrl = (
138
+ setupAppUrl: string,
139
+ linkId: string,
140
+ setupId: string,
141
+ ) => `${setupAppUrl.replace(/\/$/, "")}/#link=${linkId}&setup=${setupId}`;
142
+
143
+ /**
144
+ * Opens `url` in the user's default browser without waiting for it.
145
+ * `SETUP_AGENT_BROWSER` names another program to run with the URL instead,
146
+ * such as a test driver.
147
+ */
148
+ const openUrl = (url: string) => {
149
+ const custom = process.env.SETUP_AGENT_BROWSER?.trim();
150
+ const [command, args] = custom
151
+ ? [custom, [url]]
152
+ : process.platform === "darwin"
153
+ ? ["open", [url]]
154
+ : process.platform === "win32"
155
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
156
+ : ["xdg-open", [url]];
157
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
158
+ child.on("error", () => {});
159
+ child.unref();
160
+ };
161
+
162
+ /** `value` as one shell word, quoted only when it holds more than plain URL characters. */
163
+ export const shellWord = (value: string) =>
164
+ /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)
165
+ ? value
166
+ : `'${value.replaceAll("'", `'\\''`)}'`;
167
+
168
+ const ASK_MESSAGES: Record<
169
+ AskReason,
170
+ (question: string, context: { agoSeconds: number; page: string }) => string
171
+ > = {
172
+ "recently-opened": (question, { agoSeconds }) =>
173
+ `A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there, or ask the user directly in the terminal: ${question}`,
174
+ "browser-closed": (question) =>
175
+ `The user closed the setup tab. Ask the user directly in the terminal: ${question}`,
176
+ "no-browser": (question, { page }) =>
177
+ `No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`,
178
+ };
179
+
180
+ /** What the agent reads when setup opened the browser, or when it should ask the user itself. */
181
+ const gateEvent = (
182
+ action: BrowserGateAction,
183
+ answerCommand: string,
184
+ page: string,
185
+ ): StreamEvent => {
186
+ const { need } = action;
187
+ if (action.type === "open")
188
+ return {
189
+ event: "browser.opened",
190
+ id: need.id,
191
+ url: action.url,
192
+ 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. The answer arrives on this stream.`,
193
+ };
194
+ const question = terminalQuestion(need);
195
+ return {
196
+ event: "input.needed",
197
+ id: need.id,
198
+ reason: action.reason,
199
+ message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {
200
+ agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),
201
+ page,
202
+ }),
203
+ ...question,
204
+ answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,
205
+ };
206
+ };
207
+
208
+ export type FollowSetupOptions = {
209
+ /** Writes one line of events; the default prints a JSON array to stdout. */
210
+ print?: (events: StreamEvent[]) => void;
211
+ /** The agent the stream reports in its hello; detected by default. */
212
+ agent?: { cwd?: string; kind?: string };
213
+ /** Opens the setup page when the setup waits on the user; without it, the stream never opens one. */
214
+ browser?: {
215
+ setupAppUrl: string;
216
+ linkId: string;
217
+ /** The command that answers a need, before the need id: `npx assistant-ui@latest setup <url> answer`. */
218
+ answerCommand: string;
219
+ open?: (url: string) => void;
220
+ /** False for `--no-open`: the agent is told to ask instead. */
221
+ canOpen?: boolean;
222
+ now?: () => number;
223
+ };
224
+ };
225
+
226
+ /**
227
+ * Follows a setup as the agent: says hello, keeps the agent present with
228
+ * heartbeats, prints what changed as stream events, and opens the browser
229
+ * only when the setup waits on the user. Resolves with `stop`, which says
230
+ * goodbye; the caller disposes the client.
231
+ */
232
+ export const followSetup = async (
233
+ client: Pick<CheckoutClient, "state" | "subscribe" | "commands">,
234
+ { print, agent, browser }: FollowSetupOptions = {},
235
+ ) => {
236
+ const write =
237
+ print ??
238
+ ((events: StreamEvent[]) => {
239
+ process.stdout.write(`${JSON.stringify(events)}\n`);
240
+ });
241
+ const page = (state: Checkout.State) =>
242
+ browser && state.id !== null
243
+ ? setupPageUrl(browser.setupAppUrl, browser.linkId, state.id)
244
+ : undefined;
245
+ const gate =
246
+ browser &&
247
+ createBrowserGate({
248
+ url: page,
249
+ ...(browser.canOpen !== undefined && { canOpen: browser.canOpen }),
250
+ ...(browser.now !== undefined && { now: browser.now }),
251
+ });
252
+ let previous = client.state;
253
+ if (previous === undefined) throw new Error("the setup has no state");
254
+ let pending = diffEvents({ ...previous, log: [] }, previous);
255
+ let timer: ReturnType<typeof setTimeout> | undefined;
256
+ const flush = () => {
257
+ clearTimeout(timer);
258
+ timer = undefined;
259
+ if (pending.length > 0) write(pending);
260
+ pending = [];
261
+ };
262
+ const runGate = (state: Checkout.State) => {
263
+ for (const action of gate?.update(state) ?? []) {
264
+ if (action.type === "open") (browser!.open ?? openUrl)(action.url);
265
+ pending.push(
266
+ gateEvent(action, browser!.answerCommand, page(state) ?? ""),
267
+ );
268
+ }
269
+ };
270
+ const unsubscribe = client.subscribe(() => {
271
+ const next = client.state;
272
+ if (next === undefined) return;
273
+ pending.push(...diffEvents(previous!, next));
274
+ previous = next;
275
+ runGate(next);
276
+ if (pending.length > 0 && timer === undefined)
277
+ timer = setTimeout(flush, 200);
278
+ });
279
+ runGate(previous);
280
+ flush();
281
+ // Presence lapses without a state change, so the gate also looks every second.
282
+ const tick =
283
+ gate &&
284
+ setInterval(() => {
285
+ runGate(previous!);
286
+ flush();
287
+ }, 1_000);
288
+ const kind = agent?.kind ?? detectAgentKind();
289
+ try {
290
+ await client.commands["agent/hello"]({
291
+ cwd: agent?.cwd ?? process.cwd(),
292
+ ...(kind !== undefined && { kind }),
293
+ });
294
+ } catch (error) {
295
+ clearInterval(tick);
296
+ clearTimeout(timer);
297
+ unsubscribe();
298
+ throw error;
299
+ }
300
+ const heartbeat = setInterval(() => {
301
+ void client.commands["agent/heartbeat"]().catch(() => {});
302
+ }, AGENT_HEARTBEAT_MS);
303
+ let stopped: Promise<void> | undefined;
304
+ return {
305
+ stop: () =>
306
+ (stopped ??= (async () => {
307
+ clearInterval(heartbeat);
308
+ clearInterval(tick);
309
+ unsubscribe();
310
+ flush();
311
+ await client.commands["agent/bye"]().catch(() => {});
312
+ })()),
313
+ };
314
+ };