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/dist/stream.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { detectAgentKind } from "./agents.js";
2
2
  import { AGENT_HEARTBEAT_MS, classifyChoiceAnswer, parseMultipleAnswer } from "./protocol.js";
3
+ import { renderText } from "./guide.js";
3
4
  import { formatTerminalQuestion, terminalQuestion } from "./needs.js";
4
5
  import { createBrowserGate } from "./browser.js";
5
6
  import spawn from "cross-spawn";
6
7
  // src/stream.ts
7
- const STAY_CONNECTED = "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.";
8
- const diffEvents = (before, after) => {
8
+ /** The events between two states, with hints worded by the setup server's `guide`. */
9
+ const diffEvents = (before, after, guide) => {
9
10
  const events = [];
11
+ const stayConnected = renderText(guide, "stream.stay-connected");
10
12
  if (before.status === "waiting" && after.status === "planning") events.push({ event: "planning.started" });
11
13
  for (const entry of after.log) if (entry.role === "user" && entry.acknowledgedAt === void 0 && !before.log.some((previous) => previous.id === entry.id)) events.push({
12
14
  event: "message.created",
@@ -43,11 +45,11 @@ const diffEvents = (before, after) => {
43
45
  }
44
46
  if (before.status !== "done" && after.status === "done") events.push({
45
47
  event: "finished",
46
- next: STAY_CONNECTED
48
+ next: stayConnected
47
49
  });
48
50
  if (before.status !== "cancelled" && after.status === "cancelled") events.push({
49
51
  event: "cancelled",
50
- next: STAY_CONNECTED
52
+ next: stayConnected
51
53
  });
52
54
  if (after.id !== null && after.id !== before.id) events.push({
53
55
  event: "setup.created",
@@ -79,29 +81,34 @@ const openUrl = (url) => {
79
81
  };
80
82
  /** `value` as one shell word, quoted only when it holds more than plain URL characters. */
81
83
  const shellWord = (value) => /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
82
- const ASK_MESSAGES = {
83
- "recently-opened": (question, { agoSeconds, wait }) => `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}`,
84
- "browser-closed": (question) => `The user closed the setup tab. Ask the user directly in the terminal: ${question}`,
85
- "no-browser": (question, { page }) => `No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`
84
+ const ASK_TEXTS = {
85
+ "recently-opened": "stream.ask.recently-opened",
86
+ "browser-closed": "stream.ask.browser-closed",
87
+ "no-browser": "stream.ask.no-browser"
86
88
  };
87
89
  /** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */
88
90
  const waitCommandFor = (answerCommand) => answerCommand.endsWith(" answer") ? `${answerCommand.slice(0, -7)} wait` : void 0;
89
91
  /** What the agent reads when setup opened the browser, or when it should ask the user itself. */
90
- const gateEvent = (action, answerCommand, page, waitCommand) => {
92
+ const gateEvent = (action, answerCommand, page, waitCommand, guide) => {
91
93
  const { need } = action;
92
94
  const wait = waitCommand !== void 0 && need.kind !== "finish" ? ` with "${waitCommand} ${need.id}"` : "";
93
95
  if (action.type === "open") return {
94
96
  event: "browser.opened",
95
97
  id: need.id,
96
98
  url: action.url,
97
- 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.`}`
99
+ message: renderText(guide, `stream.opened.${need.kind}`, {
100
+ url: action.url,
101
+ wait: wait || " with \"wait\"",
102
+ prompt: need.kind === "input" ? need.input.prompt.trim() || renderText(guide, "need.input.empty-prompt") : ""
103
+ })
98
104
  };
99
- const question = terminalQuestion(need);
105
+ const question = terminalQuestion(need, guide);
100
106
  return {
101
107
  event: "input.needed",
102
108
  id: need.id,
103
109
  reason: action.reason,
104
- message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {
110
+ message: renderText(guide, ASK_TEXTS[action.reason], {
111
+ question: formatTerminalQuestion(question),
105
112
  agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1e3),
106
113
  page,
107
114
  wait
@@ -116,7 +123,7 @@ const gateEvent = (action, answerCommand, page, waitCommand) => {
116
123
  * only when the setup waits on the user. Resolves with `stop`, which says
117
124
  * goodbye; the caller disposes the client.
118
125
  */
119
- const followSetup = async (client, { print, agent, browser } = {}) => {
126
+ const followSetup = async (client, { print, agent, browser, guide }) => {
120
127
  const write = print ?? ((events) => {
121
128
  process.stdout.write(`${JSON.stringify(events)}\n`);
122
129
  });
@@ -131,7 +138,7 @@ const followSetup = async (client, { print, agent, browser } = {}) => {
131
138
  let pending = diffEvents({
132
139
  ...previous,
133
140
  log: []
134
- }, previous);
141
+ }, previous, guide);
135
142
  let timer;
136
143
  const flush = () => {
137
144
  clearTimeout(timer);
@@ -142,13 +149,13 @@ const followSetup = async (client, { print, agent, browser } = {}) => {
142
149
  const runGate = (state) => {
143
150
  for (const action of gate?.update(state) ?? []) {
144
151
  if (action.type === "open") (browser.open ?? openUrl)(action.url);
145
- pending.push(gateEvent(action, browser.answerCommand, page(state) ?? "", waitCommandFor(browser.answerCommand)));
152
+ pending.push(gateEvent(action, browser.answerCommand, page(state) ?? "", waitCommandFor(browser.answerCommand), guide));
146
153
  }
147
154
  };
148
155
  const unsubscribe = client.subscribe(() => {
149
156
  const next = client.state;
150
157
  if (next === void 0) return;
151
- pending.push(...diffEvents(previous, next));
158
+ pending.push(...diffEvents(previous, next, guide));
152
159
  previous = next;
153
160
  runGate(next);
154
161
  if (pending.length > 0 && timer === void 0) timer = setTimeout(flush, 200);
@@ -1 +1 @@
1
- {"version":3,"file":"stream.js","names":[],"sources":["../src/stream.ts"],"sourcesContent":["import spawn from \"cross-spawn\";\nimport { detectAgentKind } from \"./agents\";\nimport { createBrowserGate } from \"./browser\";\nimport type { AskReason, BrowserGateAction } from \"./browser\";\nimport type { CheckoutClient } from \"./client\";\nimport { formatTerminalQuestion, terminalQuestion } from \"./needs\";\nimport type { TerminalQuestion } from \"./needs\";\nimport {\n AGENT_HEARTBEAT_MS,\n classifyChoiceAnswer,\n parseMultipleAnswer,\n} from \"./protocol\";\nimport type { Checkout } from \"./protocol\";\n\nexport type StreamEvent =\n | { event: \"planning.started\" }\n | { event: \"message.created\"; messageId: string; text: string }\n | {\n event: \"input.answered\";\n inputId: string;\n prompt: string;\n /** The entries of a multiple choice answer, else the answer text. */\n answer: string | string[];\n note?: string;\n /** True when a choice answer is, or a multiple one contains, the user's own text rather than an option. */\n custom?: true;\n }\n | { event: \"input.dismissed\"; inputId: string; prompt: string }\n | { event: \"plan.approved\"; revision: number }\n | { event: \"plan.changes-requested\"; revision: number; feedback: string }\n | { event: \"finished\"; next: string }\n | { event: \"cancelled\"; next: string }\n | { event: \"setup.created\"; products: string[] }\n | {\n event: \"browser.opened\";\n /** The need the tab opened for: an input id, plan or finish. */\n id: string;\n url: string;\n message: string;\n }\n | ({\n event: \"input.needed\";\n id: string;\n reason: AskReason;\n message: string;\n /** The command that answers it, with the answer's shape in angle brackets. */\n answer: string;\n } & TerminalQuestion);\n\nconst STAY_CONNECTED =\n \"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.\";\n\nexport const diffEvents = (\n before: Checkout.State,\n after: Checkout.State,\n): StreamEvent[] => {\n const events: StreamEvent[] = [];\n if (before.status === \"waiting\" && after.status === \"planning\") {\n events.push({ event: \"planning.started\" });\n }\n for (const entry of after.log) {\n if (\n entry.role === \"user\" &&\n entry.acknowledgedAt === undefined &&\n !before.log.some((previous) => previous.id === entry.id)\n ) {\n events.push({\n event: \"message.created\",\n messageId: entry.id,\n text: entry.text,\n });\n }\n }\n for (const input of after.inputs) {\n const previous = before.inputs.find(\n (candidate) => candidate.id === input.id,\n );\n if (previous?.status === input.status) continue;\n if (input.status === \"answered\") {\n const answer = input.answer ?? \"\";\n events.push({\n event: \"input.answered\",\n inputId: input.id,\n prompt: input.prompt,\n ...answerFields(input, answer),\n });\n } else if (input.status === \"dismissed\" && after.status !== \"cancelled\") {\n events.push({\n event: \"input.dismissed\",\n inputId: input.id,\n prompt: input.prompt,\n });\n }\n }\n for (const plan of after.plans) {\n const previous = before.plans.find(\n (candidate) => candidate.revision === plan.revision,\n );\n if (previous?.status === plan.status || plan.status === \"proposed\") {\n continue;\n }\n if (plan.status === \"approved\") {\n events.push({ event: \"plan.approved\", revision: plan.revision });\n } else {\n events.push({\n event: \"plan.changes-requested\",\n revision: plan.revision,\n feedback: plan.feedback ?? \"\",\n });\n }\n }\n if (before.status !== \"done\" && after.status === \"done\") {\n events.push({ event: \"finished\", next: STAY_CONNECTED });\n }\n if (before.status !== \"cancelled\" && after.status === \"cancelled\") {\n events.push({ event: \"cancelled\", next: STAY_CONNECTED });\n }\n if (after.id !== null && after.id !== before.id) {\n events.push({\n event: \"setup.created\",\n products: after.products.map((product) => product.slug),\n });\n }\n return events;\n};\n\nexport const answerFields = (input: Checkout.Input, answer: string) => ({\n answer: (input.multiple && parseMultipleAnswer(answer)) || answer,\n ...(input.note !== undefined && { note: input.note }),\n ...(input.kind === \"choice\" &&\n classifyChoiceAnswer(input, answer) === \"custom\" && {\n custom: true as const,\n }),\n});\n\n/** The setup page that joins a link; the link rides in the fragment, which never reaches a server. */\nexport const setupPageUrl = (\n setupAppUrl: string,\n linkId: string,\n setupId: string,\n) => `${setupAppUrl.replace(/\\/$/, \"\")}/#link=${linkId}&setup=${setupId}`;\n\n/**\n * Opens `url` in the user's default browser without waiting for it.\n * `SETUP_AGENT_BROWSER` names another program to run with the URL instead,\n * such as a test driver.\n */\nconst openUrl = (url: string) => {\n const custom = process.env.SETUP_AGENT_BROWSER?.trim();\n const [command, args] = custom\n ? [custom, [url]]\n : process.platform === \"darwin\"\n ? [\"open\", [url]]\n : process.platform === \"win32\"\n ? [\"rundll32\", [\"url.dll,FileProtocolHandler\", url]]\n : [\"xdg-open\", [url]];\n const child = spawn(command, args, { detached: true, stdio: \"ignore\" });\n child.on(\"error\", () => {});\n child.unref();\n};\n\n/** `value` as one shell word, quoted only when it holds more than plain URL characters. */\nexport const shellWord = (value: string) =>\n /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)\n ? value\n : `'${value.replaceAll(\"'\", `'\\\\''`)}'`;\n\nconst ASK_MESSAGES: Record<\n AskReason,\n (\n question: string,\n context: { agoSeconds: number; page: string; wait: string },\n ) => string\n> = {\n \"recently-opened\": (question, { agoSeconds, wait }) =>\n `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}`,\n \"browser-closed\": (question) =>\n `The user closed the setup tab. Ask the user directly in the terminal: ${question}`,\n \"no-browser\": (question, { page }) =>\n `No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`,\n};\n\n/** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */\nconst waitCommandFor = (answerCommand: string) =>\n answerCommand.endsWith(\" answer\")\n ? `${answerCommand.slice(0, -\" answer\".length)} wait`\n : undefined;\n\n/** What the agent reads when setup opened the browser, or when it should ask the user itself. */\nconst gateEvent = (\n action: BrowserGateAction,\n answerCommand: string,\n page: string,\n waitCommand?: string,\n): StreamEvent => {\n const { need } = action;\n // Closing the setup is the user's call after \"done\"; there is nothing to block on.\n const wait =\n waitCommand !== undefined && need.kind !== \"finish\"\n ? ` with \"${waitCommand} ${need.id}\"`\n : \"\";\n if (action.type === \"open\")\n return {\n event: \"browser.opened\",\n id: need.id,\n url: action.url,\n 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.`}`,\n };\n const question = terminalQuestion(need);\n return {\n event: \"input.needed\",\n id: need.id,\n reason: action.reason,\n message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {\n agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),\n page,\n wait,\n }),\n ...question,\n answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,\n };\n};\n\nexport type FollowSetupOptions = {\n /** Writes one line of events; the default prints a JSON array to stdout. */\n print?: (events: StreamEvent[]) => void;\n /** The agent the stream reports in its hello; detected by default. */\n agent?: { cwd?: string; kind?: string };\n /** Opens the setup page when the setup waits on the user; without it, the stream never opens one. */\n browser?: {\n setupAppUrl: string;\n linkId: string;\n /** The command that answers a need, before the need id: `npx assistant-ui@latest setup <url> answer`. */\n answerCommand: string;\n open?: (url: string) => void;\n /** False for `--no-open`: the agent is told to ask instead. */\n canOpen?: boolean;\n now?: () => number;\n };\n};\n\n/**\n * Follows a setup as the agent: says hello, keeps the agent present with\n * heartbeats, prints what changed as stream events, and opens the browser\n * only when the setup waits on the user. Resolves with `stop`, which says\n * goodbye; the caller disposes the client.\n */\nexport const followSetup = async (\n client: Pick<CheckoutClient, \"state\" | \"subscribe\" | \"commands\">,\n { print, agent, browser }: FollowSetupOptions = {},\n) => {\n const write =\n print ??\n ((events: StreamEvent[]) => {\n process.stdout.write(`${JSON.stringify(events)}\\n`);\n });\n const page = (state: Checkout.State) =>\n browser && state.id !== null\n ? setupPageUrl(browser.setupAppUrl, browser.linkId, state.id)\n : undefined;\n const gate =\n browser &&\n createBrowserGate({\n url: page,\n ...(browser.canOpen !== undefined && { canOpen: browser.canOpen }),\n ...(browser.now !== undefined && { now: browser.now }),\n });\n let previous = client.state;\n if (previous === undefined) throw new Error(\"the setup has no state\");\n let pending = diffEvents({ ...previous, log: [] }, previous);\n let timer: ReturnType<typeof setTimeout> | undefined;\n const flush = () => {\n clearTimeout(timer);\n timer = undefined;\n if (pending.length > 0) write(pending);\n pending = [];\n };\n const runGate = (state: Checkout.State) => {\n for (const action of gate?.update(state) ?? []) {\n if (action.type === \"open\") (browser!.open ?? openUrl)(action.url);\n pending.push(\n gateEvent(\n action,\n browser!.answerCommand,\n page(state) ?? \"\",\n waitCommandFor(browser!.answerCommand),\n ),\n );\n }\n };\n const unsubscribe = client.subscribe(() => {\n const next = client.state;\n if (next === undefined) return;\n pending.push(...diffEvents(previous!, next));\n previous = next;\n runGate(next);\n if (pending.length > 0 && timer === undefined)\n timer = setTimeout(flush, 200);\n });\n runGate(previous);\n flush();\n // Presence lapses without a state change, so the gate also looks every second.\n const tick =\n gate &&\n setInterval(() => {\n runGate(previous!);\n flush();\n }, 1_000);\n const kind = agent?.kind ?? detectAgentKind();\n try {\n await client.commands[\"agent/hello\"]({\n cwd: agent?.cwd ?? process.cwd(),\n ...(kind !== undefined && { kind }),\n });\n } catch (error) {\n clearInterval(tick);\n clearTimeout(timer);\n unsubscribe();\n throw error;\n }\n const heartbeat = setInterval(() => {\n void client.commands[\"agent/heartbeat\"]().catch(() => {});\n }, AGENT_HEARTBEAT_MS);\n let stopped: Promise<void> | undefined;\n return {\n stop: () =>\n (stopped ??= (async () => {\n clearInterval(heartbeat);\n clearInterval(tick);\n unsubscribe();\n flush();\n await client.commands[\"agent/bye\"]().catch(() => {});\n })()),\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"stream.js","names":[],"sources":["../src/stream.ts"],"sourcesContent":["import spawn from \"cross-spawn\";\nimport { detectAgentKind } from \"./agents\";\nimport { createBrowserGate } from \"./browser\";\nimport type { AskReason, BrowserGateAction } from \"./browser\";\nimport type { CheckoutClient } from \"./client\";\nimport { formatTerminalQuestion, terminalQuestion } from \"./needs\";\nimport type { TerminalQuestion } from \"./needs\";\nimport {\n AGENT_HEARTBEAT_MS,\n classifyChoiceAnswer,\n parseMultipleAnswer,\n} from \"./protocol\";\nimport type { Checkout } from \"./protocol\";\nimport { renderText } from \"./guide\";\nimport type { Guide } from \"./guide\";\n\nexport type StreamEvent =\n | { event: \"planning.started\" }\n | { event: \"message.created\"; messageId: string; text: string }\n | {\n event: \"input.answered\";\n inputId: string;\n prompt: string;\n /** The entries of a multiple choice answer, else the answer text. */\n answer: string | string[];\n note?: string;\n /** True when a choice answer is, or a multiple one contains, the user's own text rather than an option. */\n custom?: true;\n }\n | { event: \"input.dismissed\"; inputId: string; prompt: string }\n | { event: \"plan.approved\"; revision: number }\n | { event: \"plan.changes-requested\"; revision: number; feedback: string }\n | { event: \"finished\"; next: string }\n | { event: \"cancelled\"; next: string }\n | { event: \"setup.created\"; products: string[] }\n | {\n event: \"browser.opened\";\n /** The need the tab opened for: an input id, plan or finish. */\n id: string;\n url: string;\n message: string;\n }\n | ({\n event: \"input.needed\";\n id: string;\n reason: AskReason;\n message: string;\n /** The command that answers it, with the answer's shape in angle brackets. */\n answer: string;\n } & TerminalQuestion);\n\n/** The events between two states, with hints worded by the setup server's `guide`. */\nexport const diffEvents = (\n before: Checkout.State,\n after: Checkout.State,\n guide: Pick<Guide, \"texts\">,\n): StreamEvent[] => {\n const events: StreamEvent[] = [];\n const stayConnected = renderText(guide, \"stream.stay-connected\");\n if (before.status === \"waiting\" && after.status === \"planning\") {\n events.push({ event: \"planning.started\" });\n }\n for (const entry of after.log) {\n if (\n entry.role === \"user\" &&\n entry.acknowledgedAt === undefined &&\n !before.log.some((previous) => previous.id === entry.id)\n ) {\n events.push({\n event: \"message.created\",\n messageId: entry.id,\n text: entry.text,\n });\n }\n }\n for (const input of after.inputs) {\n const previous = before.inputs.find(\n (candidate) => candidate.id === input.id,\n );\n if (previous?.status === input.status) continue;\n if (input.status === \"answered\") {\n const answer = input.answer ?? \"\";\n events.push({\n event: \"input.answered\",\n inputId: input.id,\n prompt: input.prompt,\n ...answerFields(input, answer),\n });\n } else if (input.status === \"dismissed\" && after.status !== \"cancelled\") {\n events.push({\n event: \"input.dismissed\",\n inputId: input.id,\n prompt: input.prompt,\n });\n }\n }\n for (const plan of after.plans) {\n const previous = before.plans.find(\n (candidate) => candidate.revision === plan.revision,\n );\n if (previous?.status === plan.status || plan.status === \"proposed\") {\n continue;\n }\n if (plan.status === \"approved\") {\n events.push({ event: \"plan.approved\", revision: plan.revision });\n } else {\n events.push({\n event: \"plan.changes-requested\",\n revision: plan.revision,\n feedback: plan.feedback ?? \"\",\n });\n }\n }\n if (before.status !== \"done\" && after.status === \"done\") {\n events.push({ event: \"finished\", next: stayConnected });\n }\n if (before.status !== \"cancelled\" && after.status === \"cancelled\") {\n events.push({ event: \"cancelled\", next: stayConnected });\n }\n if (after.id !== null && after.id !== before.id) {\n events.push({\n event: \"setup.created\",\n products: after.products.map((product) => product.slug),\n });\n }\n return events;\n};\n\nexport const answerFields = (input: Checkout.Input, answer: string) => ({\n answer: (input.multiple && parseMultipleAnswer(answer)) || answer,\n ...(input.note !== undefined && { note: input.note }),\n ...(input.kind === \"choice\" &&\n classifyChoiceAnswer(input, answer) === \"custom\" && {\n custom: true as const,\n }),\n});\n\n/** The setup page that joins a link; the link rides in the fragment, which never reaches a server. */\nexport const setupPageUrl = (\n setupAppUrl: string,\n linkId: string,\n setupId: string,\n) => `${setupAppUrl.replace(/\\/$/, \"\")}/#link=${linkId}&setup=${setupId}`;\n\n/**\n * Opens `url` in the user's default browser without waiting for it.\n * `SETUP_AGENT_BROWSER` names another program to run with the URL instead,\n * such as a test driver.\n */\nconst openUrl = (url: string) => {\n const custom = process.env.SETUP_AGENT_BROWSER?.trim();\n const [command, args] = custom\n ? [custom, [url]]\n : process.platform === \"darwin\"\n ? [\"open\", [url]]\n : process.platform === \"win32\"\n ? [\"rundll32\", [\"url.dll,FileProtocolHandler\", url]]\n : [\"xdg-open\", [url]];\n const child = spawn(command, args, { detached: true, stdio: \"ignore\" });\n child.on(\"error\", () => {});\n child.unref();\n};\n\n/** `value` as one shell word, quoted only when it holds more than plain URL characters. */\nexport const shellWord = (value: string) =>\n /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)\n ? value\n : `'${value.replaceAll(\"'\", `'\\\\''`)}'`;\n\nconst ASK_TEXTS = {\n \"recently-opened\": \"stream.ask.recently-opened\",\n \"browser-closed\": \"stream.ask.browser-closed\",\n \"no-browser\": \"stream.ask.no-browser\",\n} as const satisfies Record<AskReason, string>;\n\n/** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */\nconst waitCommandFor = (answerCommand: string) =>\n answerCommand.endsWith(\" answer\")\n ? `${answerCommand.slice(0, -\" answer\".length)} wait`\n : undefined;\n\n/** What the agent reads when setup opened the browser, or when it should ask the user itself. */\nconst gateEvent = (\n action: BrowserGateAction,\n answerCommand: string,\n page: string,\n waitCommand: string | undefined,\n guide: Pick<Guide, \"texts\">,\n): StreamEvent => {\n const { need } = action;\n // Closing the setup is the user's call after \"done\"; there is nothing to block on.\n const wait =\n waitCommand !== undefined && need.kind !== \"finish\"\n ? ` with \"${waitCommand} ${need.id}\"`\n : \"\";\n if (action.type === \"open\")\n return {\n event: \"browser.opened\",\n id: need.id,\n url: action.url,\n message: renderText(guide, `stream.opened.${need.kind}`, {\n url: action.url,\n wait: wait || ' with \"wait\"',\n prompt:\n need.kind === \"input\"\n ? need.input.prompt.trim() ||\n renderText(guide, \"need.input.empty-prompt\")\n : \"\",\n }),\n };\n const question = terminalQuestion(need, guide);\n return {\n event: \"input.needed\",\n id: need.id,\n reason: action.reason,\n message: renderText(guide, ASK_TEXTS[action.reason], {\n question: formatTerminalQuestion(question),\n agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),\n page,\n wait,\n }),\n ...question,\n answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,\n };\n};\n\nexport type FollowSetupOptions = {\n /** The setup server's wording for the stream's messages. */\n guide: Pick<Guide, \"texts\">;\n /** Writes one line of events; the default prints a JSON array to stdout. */\n print?: (events: StreamEvent[]) => void;\n /** The agent the stream reports in its hello; detected by default. */\n agent?: { cwd?: string; kind?: string };\n /** Opens the setup page when the setup waits on the user; without it, the stream never opens one. */\n browser?: {\n setupAppUrl: string;\n linkId: string;\n /** The command that answers a need, before the need id: `npx assistant-ui@latest setup <url> answer`. */\n answerCommand: string;\n open?: (url: string) => void;\n /** False for `--no-open`: the agent is told to ask instead. */\n canOpen?: boolean;\n now?: () => number;\n };\n};\n\n/**\n * Follows a setup as the agent: says hello, keeps the agent present with\n * heartbeats, prints what changed as stream events, and opens the browser\n * only when the setup waits on the user. Resolves with `stop`, which says\n * goodbye; the caller disposes the client.\n */\nexport const followSetup = async (\n client: Pick<CheckoutClient, \"state\" | \"subscribe\" | \"commands\">,\n { print, agent, browser, guide }: FollowSetupOptions,\n) => {\n const write =\n print ??\n ((events: StreamEvent[]) => {\n process.stdout.write(`${JSON.stringify(events)}\\n`);\n });\n const page = (state: Checkout.State) =>\n browser && state.id !== null\n ? setupPageUrl(browser.setupAppUrl, browser.linkId, state.id)\n : undefined;\n const gate =\n browser &&\n createBrowserGate({\n url: page,\n ...(browser.canOpen !== undefined && { canOpen: browser.canOpen }),\n ...(browser.now !== undefined && { now: browser.now }),\n });\n let previous = client.state;\n if (previous === undefined) throw new Error(\"the setup has no state\");\n let pending = diffEvents({ ...previous, log: [] }, previous, guide);\n let timer: ReturnType<typeof setTimeout> | undefined;\n const flush = () => {\n clearTimeout(timer);\n timer = undefined;\n if (pending.length > 0) write(pending);\n pending = [];\n };\n const runGate = (state: Checkout.State) => {\n for (const action of gate?.update(state) ?? []) {\n if (action.type === \"open\") (browser!.open ?? openUrl)(action.url);\n pending.push(\n gateEvent(\n action,\n browser!.answerCommand,\n page(state) ?? \"\",\n waitCommandFor(browser!.answerCommand),\n guide,\n ),\n );\n }\n };\n const unsubscribe = client.subscribe(() => {\n const next = client.state;\n if (next === undefined) return;\n pending.push(...diffEvents(previous!, next, guide));\n previous = next;\n runGate(next);\n if (pending.length > 0 && timer === undefined)\n timer = setTimeout(flush, 200);\n });\n runGate(previous);\n flush();\n // Presence lapses without a state change, so the gate also looks every second.\n const tick =\n gate &&\n setInterval(() => {\n runGate(previous!);\n flush();\n }, 1_000);\n const kind = agent?.kind ?? detectAgentKind();\n try {\n await client.commands[\"agent/hello\"]({\n cwd: agent?.cwd ?? process.cwd(),\n ...(kind !== undefined && { kind }),\n });\n } catch (error) {\n clearInterval(tick);\n clearTimeout(timer);\n unsubscribe();\n throw error;\n }\n const heartbeat = setInterval(() => {\n void client.commands[\"agent/heartbeat\"]().catch(() => {});\n }, AGENT_HEARTBEAT_MS);\n let stopped: Promise<void> | undefined;\n return {\n stop: () =>\n (stopped ??= (async () => {\n clearInterval(heartbeat);\n clearInterval(tick);\n unsubscribe();\n flush();\n await client.commands[\"agent/bye\"]().catch(() => {});\n })()),\n };\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "setup-agent",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "A live setup shared between a browser and the coding agent installing the products in it, on Statewire",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.assistant-ui.com/",
package/src/cli.test.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { connectCheckout } from "./client";
3
3
  import { initialCheckoutState, type Checkout } from "./protocol";
4
+ import { testGuide } from "./tests/guide-fixture";
4
5
  import { describe, expect, it, onTestFinished, vi } from "vitest";
5
6
  import {
6
7
  chmod,
@@ -19,10 +20,8 @@ import { tmpdir } from "node:os";
19
20
  import { join } from "node:path";
20
21
  import {
21
22
  HELP,
22
- INSTRUCTIONS,
23
23
  main,
24
24
  askSeed,
25
- agentInstructions,
26
25
  diffEvents,
27
26
  detectAgentKind,
28
27
  isDirectInvocation,
@@ -41,52 +40,6 @@ vi.mock("./client", async (importOriginal) => ({
41
40
  connectCheckout: connectCheckoutMock,
42
41
  }));
43
42
 
44
- describe("the agent's briefing", () => {
45
- it("preserves the published instructions export and setup-created event wording", () => {
46
- expect(INSTRUCTIONS).toBe(agentInstructions);
47
- expect(typeof HELP).toBe("string");
48
- expect(INSTRUCTIONS("https://example.test/session")).toContain(
49
- '"setup.created"',
50
- );
51
- expect(INSTRUCTIONS("https://example.test/session")).not.toContain(
52
- '"checkout.created"',
53
- );
54
- });
55
-
56
- it("describes a setup without the wording of a purchase", () => {
57
- const briefing = HELP + INSTRUCTIONS("https://example.test/session");
58
- expect(briefing).not.toMatch(/\b(?:cart|shop|checkout|purchase)\b/i);
59
- });
60
- });
61
-
62
- describe("running to the end", () => {
63
- it("tells the agent not to end its turn to wait, and how to block instead", () => {
64
- for (const instructions of [
65
- agentInstructions("https://example.test/session"),
66
- agentInstructions("https://example.test/session", {
67
- command: "npx assistant-ui@latest setup",
68
- startedByAgent: { browserUrl: "https://setup.test", streaming: true },
69
- }),
70
- ]) {
71
- expect(instructions).toContain("Run the setup to the end in one go.");
72
- expect(instructions).toContain(
73
- "never end your turn to wait for an answer",
74
- );
75
- expect(instructions).toMatch(/ wait <input-id>\n/);
76
- expect(instructions).toContain('run its "next" command, right away');
77
- expect(instructions).toContain('never repeat an "ask" or "plan"');
78
- expect(instructions).not.toContain(
79
- 'block with "wait <input-id>" only when nothing else can proceed',
80
- );
81
- }
82
- const streaming = agentInstructions("https://example.test/session", {
83
- startedByAgent: { browserUrl: "https://setup.test", streaming: true },
84
- });
85
- expect(streaming).toContain("New output does not wake you");
86
- expect(streaming).not.toContain('wait for "input.answered"');
87
- });
88
- });
89
-
90
43
  describe("waiting on the user", () => {
91
44
  // Under the two minutes Claude Code's shell gives a command by default.
92
45
  const DEFAULT_WAIT_SECONDS = 100;
@@ -577,10 +530,10 @@ describe("chat stream events", () => {
577
530
  },
578
531
  ],
579
532
  };
580
- expect(diffEvents(before, after)).toEqual([
533
+ expect(diffEvents(before, after, testGuide)).toEqual([
581
534
  { event: "message.created", messageId: "l2", text: "Use pnpm" },
582
535
  ]);
583
- expect(diffEvents(after, after)).toEqual([]);
536
+ expect(diffEvents(after, after, testGuide)).toEqual([]);
584
537
  });
585
538
  });
586
539
 
@@ -605,7 +558,9 @@ describe("begin planning", () => {
605
558
  ...before,
606
559
  inputs: [{ ...asked, status: "answered", answer, answeredAt: 2 }],
607
560
  });
608
- expect(diffEvents(before, answered('["slack","email"]'))).toEqual([
561
+ expect(
562
+ diffEvents(before, answered('["slack","email"]'), testGuide),
563
+ ).toEqual([
609
564
  {
610
565
  event: "input.answered",
611
566
  inputId: "q1",
@@ -613,7 +568,9 @@ describe("begin planning", () => {
613
568
  answer: ["slack", "email"],
614
569
  },
615
570
  ]);
616
- expect(diffEvents(before, answered('["email","Discord"]'))).toEqual([
571
+ expect(
572
+ diffEvents(before, answered('["email","Discord"]'), testGuide),
573
+ ).toEqual([
617
574
  {
618
575
  event: "input.answered",
619
576
  inputId: "q1",
@@ -630,18 +587,18 @@ describe("begin planning", () => {
630
587
  ...waiting,
631
588
  agent: { ...waiting.agent, connected: true, lastSeenAt: 1 },
632
589
  };
633
- expect(diffEvents(waiting, connected)).toEqual([]);
590
+ expect(diffEvents(waiting, connected, testGuide)).toEqual([]);
634
591
  const planning: Checkout.State = { ...connected, status: "planning" };
635
- expect(diffEvents(connected, planning)).toEqual([
592
+ expect(diffEvents(connected, planning, testGuide)).toEqual([
636
593
  { event: "planning.started" },
637
594
  ]);
638
- expect(diffEvents(planning, planning)).toEqual([]);
639
- expect(diffEvents(waiting, { ...waiting, status: "done" })).toEqual([
640
- { event: "finished", next: expect.stringContaining("Keep the stream") },
641
- ]);
642
- expect(diffEvents(waiting, { ...waiting, status: "cancelled" })).toEqual([
643
- { event: "cancelled", next: expect.stringContaining("Keep the stream") },
644
- ]);
595
+ expect(diffEvents(planning, planning, testGuide)).toEqual([]);
596
+ expect(
597
+ diffEvents(waiting, { ...waiting, status: "done" }, testGuide),
598
+ ).toEqual([{ event: "finished", next: "[stream.stay-connected]" }]);
599
+ expect(
600
+ diffEvents(waiting, { ...waiting, status: "cancelled" }, testGuide),
601
+ ).toEqual([{ event: "cancelled", next: "[stream.stay-connected]" }]);
645
602
  });
646
603
 
647
604
  it("announces the next setup the browser creates on the same link", () => {
@@ -656,13 +613,13 @@ describe("begin planning", () => {
656
613
  id: "c2",
657
614
  products: [{ slug: "cloud", name: "Cloud" }],
658
615
  };
659
- expect(diffEvents(done, next)).toEqual([
616
+ expect(diffEvents(done, next, testGuide)).toEqual([
660
617
  { event: "setup.created", products: ["cloud"] },
661
618
  ]);
662
- expect(diffEvents(next, next)).toEqual([]);
663
- expect(diffEvents(initialCheckoutState(), initialCheckoutState())).toEqual(
664
- [],
665
- );
619
+ expect(diffEvents(next, next, testGuide)).toEqual([]);
620
+ expect(
621
+ diffEvents(initialCheckoutState(), initialCheckoutState(), testGuide),
622
+ ).toEqual([]);
666
623
  });
667
624
 
668
625
  it.each(["planning", "cancelled"] as const)(
@@ -741,90 +698,6 @@ describe("begin planning", () => {
741
698
  );
742
699
  });
743
700
 
744
- describe("agent instructions", () => {
745
- it("goes straight to planning for a setup the agent started", () => {
746
- const browserUrl =
747
- "https://setup.assistant-ui.com/#link=abc12345&setup=def67890";
748
- const started = agentInstructions("https://setup.test/abc12345", {
749
- startedByAgent: { browserUrl },
750
- });
751
- expect(started).not.toContain("click Begin plan");
752
- expect(started).not.toContain("wait start\nKeep the stream running");
753
- expect(started).toContain("there is no Begin plan click to wait for");
754
- expect(started).toContain(browserUrl);
755
- expect(started).toContain("a setup you opened in the user's browser");
756
- const opened = agentInstructions("https://setup.test/abc12345");
757
- expect(opened).toContain("click Begin plan");
758
- expect(opened).toContain("a setup the user opened in their browser");
759
- });
760
-
761
- it("asks what to set up when the setup has no products", () => {
762
- expect(agentInstructions("https://setup.test/s")).toContain(
763
- "If the setup lists no products, first ask the user what to set up",
764
- );
765
- });
766
-
767
- it("prints a setup URL as one quoted shell word", () => {
768
- const instructions = agentInstructions("https://checkout.test/a'b;c");
769
- expect(instructions).toContain(
770
- "npx setup-agent 'https://checkout.test/a'\\''b;c' stream",
771
- );
772
- expect(instructions).not.toContain(
773
- "npx setup-agent https://checkout.test/a'",
774
- );
775
- expect(agentInstructions("https://checkout.test/s")).toContain(
776
- "npx setup-agent https://checkout.test/s stream",
777
- );
778
- });
779
-
780
- it("discovers related products and asks contextual entry points before the plan", () => {
781
- const instructions = agentInstructions(
782
- "http://localhost:18892/checkout/session",
783
- );
784
- expect(instructions).toContain(
785
- "products are starting names and guide references",
786
- );
787
- expect(instructions).toContain(
788
- "The starting list does not restrict the implementation",
789
- );
790
- expect(instructions).toContain("only 1–3 sensible alternatives");
791
- expect(instructions).toContain(
792
- "two options may use the same form factor in different places",
793
- );
794
- expect(instructions).toContain("--entry-points '<JSON array>'");
795
- expect(instructions).not.toContain("--product assistant-ui");
796
- expect(instructions.indexOf("--entry-points")).toBeLessThan(
797
- instructions.indexOf("plan --file"),
798
- );
799
- });
800
-
801
- it("waits for the entry-point answer before the plan is submitted", () => {
802
- const ask = agentInstructions("http://localhost:18892/checkout/session")
803
- .split("\n")
804
- .find((line) => line.includes("--entry-points '<JSON array>'"));
805
- expect(ask?.trim().endsWith("--wait")).toBe(true);
806
- expect(ask).not.toContain("--default");
807
- });
808
-
809
- it("skips the entry-point question for a setup that puts no assistant in the app", () => {
810
- const instructions = agentInstructions(
811
- "http://localhost:18892/checkout/session",
812
- );
813
- expect(instructions).toContain(
814
- "When the setup puts an assistant in the app, ask how users should access it",
815
- );
816
- expect(instructions).toContain(
817
- "Skip this question when no product's guide installs an assistant",
818
- );
819
- expect(instructions).toContain(
820
- "When you ask it, do not submit the plan before the user picks an entry point.",
821
- );
822
- expect(instructions).not.toContain(
823
- "recommendation. Do not submit the plan before the user picks an entry point.",
824
- );
825
- });
826
- });
827
-
828
701
  describe("writeEnvSecret", () => {
829
702
  const tempDir = async () => {
830
703
  const dir = await mkdtemp(join(tmpdir(), "setup-agent-"));