setup-agent 0.0.9 → 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.
@@ -0,0 +1,93 @@
1
+ import type { AskReason } from "./browser.js";
2
+ import type { CheckoutClient } from "./client.js";
3
+ import type { TerminalQuestion } from "./needs.js";
4
+ import type { Checkout } from "./protocol.js";
5
+ export type StreamEvent = {
6
+ event: "planning.started";
7
+ } | {
8
+ event: "message.created";
9
+ messageId: string;
10
+ text: string;
11
+ } | {
12
+ event: "input.answered";
13
+ inputId: string;
14
+ prompt: string;
15
+ /** The entries of a multiple choice answer, else the answer text. */
16
+ answer: string | string[];
17
+ note?: string;
18
+ /** True when a choice answer is, or a multiple one contains, the user's own text rather than an option. */
19
+ custom?: true;
20
+ } | {
21
+ event: "input.dismissed";
22
+ inputId: string;
23
+ prompt: string;
24
+ } | {
25
+ event: "plan.approved";
26
+ revision: number;
27
+ } | {
28
+ event: "plan.changes-requested";
29
+ revision: number;
30
+ feedback: string;
31
+ } | {
32
+ event: "finished";
33
+ next: string;
34
+ } | {
35
+ event: "cancelled";
36
+ next: string;
37
+ } | {
38
+ event: "setup.created";
39
+ products: string[];
40
+ } | {
41
+ event: "browser.opened";
42
+ /** The need the tab opened for: an input id, plan or finish. */
43
+ id: string;
44
+ url: string;
45
+ message: string;
46
+ } | ({
47
+ event: "input.needed";
48
+ id: string;
49
+ reason: AskReason;
50
+ message: string;
51
+ /** The command that answers it, with the answer's shape in angle brackets. */
52
+ answer: string;
53
+ } & TerminalQuestion);
54
+ export declare const diffEvents: (before: Checkout.State, after: Checkout.State) => StreamEvent[];
55
+ export declare const answerFields: (input: Checkout.Input, answer: string) => {
56
+ custom?: true;
57
+ note?: string;
58
+ answer: string | string[];
59
+ };
60
+ /** The setup page that joins a link; the link rides in the fragment, which never reaches a server. */
61
+ export declare const setupPageUrl: (setupAppUrl: string, linkId: string, setupId: string) => string;
62
+ /** `value` as one shell word, quoted only when it holds more than plain URL characters. */
63
+ export declare const shellWord: (value: string) => string;
64
+ export type FollowSetupOptions = {
65
+ /** Writes one line of events; the default prints a JSON array to stdout. */
66
+ print?: (events: StreamEvent[]) => void;
67
+ /** The agent the stream reports in its hello; detected by default. */
68
+ agent?: {
69
+ cwd?: string;
70
+ kind?: string;
71
+ };
72
+ /** Opens the setup page when the setup waits on the user; without it, the stream never opens one. */
73
+ browser?: {
74
+ setupAppUrl: string;
75
+ linkId: string;
76
+ /** The command that answers a need, before the need id: `npx assistant-ui@latest setup <url> answer`. */
77
+ answerCommand: string;
78
+ open?: (url: string) => void;
79
+ /** False for `--no-open`: the agent is told to ask instead. */
80
+ canOpen?: boolean;
81
+ now?: () => number;
82
+ };
83
+ };
84
+ /**
85
+ * Follows a setup as the agent: says hello, keeps the agent present with
86
+ * heartbeats, prints what changed as stream events, and opens the browser
87
+ * only when the setup waits on the user. Resolves with `stop`, which says
88
+ * goodbye; the caller disposes the client.
89
+ */
90
+ export declare const followSetup: (client: Pick<CheckoutClient, "state" | "subscribe" | "commands">, { print, agent, browser }?: FollowSetupOptions) => Promise<{
91
+ stop: () => Promise<void>;
92
+ }>;
93
+ //# sourceMappingURL=stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../src/stream.ts"],"names":[],"mappings":"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"}
package/dist/stream.js ADDED
@@ -0,0 +1,184 @@
1
+ import { detectAgentKind } from "./agents.js";
2
+ import { AGENT_HEARTBEAT_MS, classifyChoiceAnswer, parseMultipleAnswer } from "./protocol.js";
3
+ import { formatTerminalQuestion, terminalQuestion } from "./needs.js";
4
+ import { createBrowserGate } from "./browser.js";
5
+ import spawn from "cross-spawn";
6
+ // 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) => {
9
+ const events = [];
10
+ if (before.status === "waiting" && after.status === "planning") events.push({ event: "planning.started" });
11
+ 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
+ event: "message.created",
13
+ messageId: entry.id,
14
+ text: entry.text
15
+ });
16
+ for (const input of after.inputs) {
17
+ if (before.inputs.find((candidate) => candidate.id === input.id)?.status === input.status) continue;
18
+ if (input.status === "answered") {
19
+ const answer = input.answer ?? "";
20
+ events.push({
21
+ event: "input.answered",
22
+ inputId: input.id,
23
+ prompt: input.prompt,
24
+ ...answerFields(input, answer)
25
+ });
26
+ } else if (input.status === "dismissed" && after.status !== "cancelled") events.push({
27
+ event: "input.dismissed",
28
+ inputId: input.id,
29
+ prompt: input.prompt
30
+ });
31
+ }
32
+ for (const plan of after.plans) {
33
+ if (before.plans.find((candidate) => candidate.revision === plan.revision)?.status === plan.status || plan.status === "proposed") continue;
34
+ if (plan.status === "approved") events.push({
35
+ event: "plan.approved",
36
+ revision: plan.revision
37
+ });
38
+ else events.push({
39
+ event: "plan.changes-requested",
40
+ revision: plan.revision,
41
+ feedback: plan.feedback ?? ""
42
+ });
43
+ }
44
+ if (before.status !== "done" && after.status === "done") events.push({
45
+ event: "finished",
46
+ next: STAY_CONNECTED
47
+ });
48
+ if (before.status !== "cancelled" && after.status === "cancelled") events.push({
49
+ event: "cancelled",
50
+ next: STAY_CONNECTED
51
+ });
52
+ if (after.id !== null && after.id !== before.id) events.push({
53
+ event: "setup.created",
54
+ products: after.products.map((product) => product.slug)
55
+ });
56
+ return events;
57
+ };
58
+ const answerFields = (input, answer) => ({
59
+ answer: input.multiple && parseMultipleAnswer(answer) || answer,
60
+ ...input.note !== void 0 && { note: input.note },
61
+ ...input.kind === "choice" && classifyChoiceAnswer(input, answer) === "custom" && { custom: true }
62
+ });
63
+ /** The setup page that joins a link; the link rides in the fragment, which never reaches a server. */
64
+ const setupPageUrl = (setupAppUrl, linkId, setupId) => `${setupAppUrl.replace(/\/$/, "")}/#link=${linkId}&setup=${setupId}`;
65
+ /**
66
+ * Opens `url` in the user's default browser without waiting for it.
67
+ * `SETUP_AGENT_BROWSER` names another program to run with the URL instead,
68
+ * such as a test driver.
69
+ */
70
+ const openUrl = (url) => {
71
+ const custom = process.env.SETUP_AGENT_BROWSER?.trim();
72
+ const [command, args] = custom ? [custom, [url]] : process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
73
+ const child = spawn(command, args, {
74
+ detached: true,
75
+ stdio: "ignore"
76
+ });
77
+ child.on("error", () => {});
78
+ child.unref();
79
+ };
80
+ /** `value` as one shell word, quoted only when it holds more than plain URL characters. */
81
+ const shellWord = (value) => /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
82
+ const ASK_MESSAGES = {
83
+ "recently-opened": (question, { agoSeconds }) => `A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there, 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}`
86
+ };
87
+ /** What the agent reads when setup opened the browser, or when it should ask the user itself. */
88
+ const gateEvent = (action, answerCommand, page) => {
89
+ const { need } = action;
90
+ if (action.type === "open") return {
91
+ event: "browser.opened",
92
+ id: need.id,
93
+ url: action.url,
94
+ 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.`
95
+ };
96
+ const question = terminalQuestion(need);
97
+ return {
98
+ event: "input.needed",
99
+ id: need.id,
100
+ reason: action.reason,
101
+ message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {
102
+ agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1e3),
103
+ page
104
+ }),
105
+ ...question,
106
+ answer: `${answerCommand} ${need.id} <${question.answerFormat}>`
107
+ };
108
+ };
109
+ /**
110
+ * Follows a setup as the agent: says hello, keeps the agent present with
111
+ * heartbeats, prints what changed as stream events, and opens the browser
112
+ * only when the setup waits on the user. Resolves with `stop`, which says
113
+ * goodbye; the caller disposes the client.
114
+ */
115
+ const followSetup = async (client, { print, agent, browser } = {}) => {
116
+ const write = print ?? ((events) => {
117
+ process.stdout.write(`${JSON.stringify(events)}\n`);
118
+ });
119
+ const page = (state) => browser && state.id !== null ? setupPageUrl(browser.setupAppUrl, browser.linkId, state.id) : void 0;
120
+ const gate = browser && createBrowserGate({
121
+ url: page,
122
+ ...browser.canOpen !== void 0 && { canOpen: browser.canOpen },
123
+ ...browser.now !== void 0 && { now: browser.now }
124
+ });
125
+ let previous = client.state;
126
+ if (previous === void 0) throw new Error("the setup has no state");
127
+ let pending = diffEvents({
128
+ ...previous,
129
+ log: []
130
+ }, previous);
131
+ let timer;
132
+ const flush = () => {
133
+ clearTimeout(timer);
134
+ timer = void 0;
135
+ if (pending.length > 0) write(pending);
136
+ pending = [];
137
+ };
138
+ const runGate = (state) => {
139
+ for (const action of gate?.update(state) ?? []) {
140
+ if (action.type === "open") (browser.open ?? openUrl)(action.url);
141
+ pending.push(gateEvent(action, browser.answerCommand, page(state) ?? ""));
142
+ }
143
+ };
144
+ const unsubscribe = client.subscribe(() => {
145
+ const next = client.state;
146
+ if (next === void 0) return;
147
+ pending.push(...diffEvents(previous, next));
148
+ previous = next;
149
+ runGate(next);
150
+ if (pending.length > 0 && timer === void 0) timer = setTimeout(flush, 200);
151
+ });
152
+ runGate(previous);
153
+ flush();
154
+ const tick = gate && setInterval(() => {
155
+ runGate(previous);
156
+ flush();
157
+ }, 1e3);
158
+ const kind = agent?.kind ?? detectAgentKind();
159
+ try {
160
+ await client.commands["agent/hello"]({
161
+ cwd: agent?.cwd ?? process.cwd(),
162
+ ...kind !== void 0 && { kind }
163
+ });
164
+ } catch (error) {
165
+ clearInterval(tick);
166
+ clearTimeout(timer);
167
+ unsubscribe();
168
+ throw error;
169
+ }
170
+ const heartbeat = setInterval(() => {
171
+ client.commands["agent/heartbeat"]().catch(() => {});
172
+ }, AGENT_HEARTBEAT_MS);
173
+ let stopped;
174
+ return { stop: () => stopped ??= (async () => {
175
+ clearInterval(heartbeat);
176
+ clearInterval(tick);
177
+ unsubscribe();
178
+ flush();
179
+ await client.commands["agent/bye"]().catch(() => {});
180
+ })() };
181
+ };
182
+ export { answerFields, diffEvents, followSetup, setupPageUrl, shellWord };
183
+
184
+ //# sourceMappingURL=stream.js.map
@@ -0,0 +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 (question: string, context: { agoSeconds: number; page: string }) => string\n> = {\n \"recently-opened\": (question, { agoSeconds }) =>\n `A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there, 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/** 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): StreamEvent => {\n const { need } = action;\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. The answer arrives on this stream.`,\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 }),\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(action, browser!.answerCommand, page(state) ?? \"\"),\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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "setup-agent",
3
- "version": "0.0.9",
3
+ "version": "0.1.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/",
@@ -25,6 +25,10 @@
25
25
  "types": "./dist/index.d.ts",
26
26
  "default": "./dist/index.js"
27
27
  },
28
+ "./sdk": {
29
+ "types": "./dist/sdk.d.ts",
30
+ "default": "./dist/sdk.js"
31
+ },
28
32
  "./host": {
29
33
  "types": "./dist/host.d.ts",
30
34
  "default": "./dist/host.js"
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ BROWSER_CLOSE_GRACE_MS,
4
+ BROWSER_REOPEN_GUARD_MS,
5
+ createBrowserGate,
6
+ } from "./browser";
7
+ import { initialCheckoutState } from "./protocol";
8
+ import type { Checkout } from "./protocol";
9
+
10
+ const PAGE = "https://setup.test/#link=abc&setup=s1";
11
+ const open = { type: "open", url: PAGE };
12
+
13
+ /** A gate on a fake clock, with a setup the test asks questions on and a tab it opens and closes. */
14
+ const fixture = (options: { canOpen?: boolean; tracked?: boolean } = {}) => {
15
+ let clock = 1_000_000;
16
+ const state: Checkout.State = {
17
+ ...initialCheckoutState(),
18
+ id: "s1",
19
+ createdAt: 1,
20
+ status: "planning",
21
+ };
22
+ if (options.tracked === false) delete state.browser;
23
+ const gate = createBrowserGate({
24
+ url: () => PAGE,
25
+ now: () => clock,
26
+ ...(options.canOpen !== undefined && { canOpen: options.canOpen }),
27
+ });
28
+ return {
29
+ state,
30
+ advance: (ms: number) => (clock += ms),
31
+ update: () => gate.update(structuredClone(state)),
32
+ ask: (prompt = "Which project?") =>
33
+ state.inputs.push({
34
+ id: `q${state.inputs.length + 1}`,
35
+ phase: "planning",
36
+ kind: "text",
37
+ prompt,
38
+ optional: false,
39
+ status: "open",
40
+ createdAt: clock,
41
+ }),
42
+ answerAll: () =>
43
+ state.inputs.forEach((input) => (input.status = "answered")),
44
+ heartbeat: () => (state.browser = { lastSeenAt: clock, connected: true }),
45
+ bye: () => (state.browser!.connected = false),
46
+ };
47
+ };
48
+
49
+ describe("createBrowserGate", () => {
50
+ it("opens nothing until the setup needs the user, then one tab for everything waiting", () => {
51
+ const t = fixture();
52
+ expect(t.update()).toEqual([]);
53
+ t.advance(60_000);
54
+ expect(t.update()).toEqual([]);
55
+ t.ask();
56
+ t.ask();
57
+ expect(t.update()).toEqual([
58
+ expect.objectContaining({
59
+ ...open,
60
+ need: expect.objectContaining({ id: "q1" }),
61
+ }),
62
+ ]);
63
+ expect(t.update()).toEqual([]);
64
+ });
65
+
66
+ it("opens nothing while a joined tab is present", () => {
67
+ const t = fixture();
68
+ t.heartbeat();
69
+ t.update();
70
+ t.ask();
71
+ expect(t.update()).toEqual([]);
72
+ });
73
+
74
+ it("never opens a second tab within 30 seconds, nor reopens one that never joined", () => {
75
+ const t = fixture();
76
+ t.ask();
77
+ t.update();
78
+ t.advance(12_000);
79
+ t.ask();
80
+ expect(t.update()).toEqual([
81
+ expect.objectContaining({
82
+ type: "ask",
83
+ reason: "recently-opened",
84
+ openedAgoMs: 12_000,
85
+ }),
86
+ ]);
87
+ // 30s on, the tab never joined: what waits is handed to the agent once.
88
+ t.advance(18_000);
89
+ // q2 was already handed over at 12s, so only q1 is.
90
+ expect(t.update().map((action) => action.need.id)).toEqual(["q1"]);
91
+ expect(t.update()).toEqual([]);
92
+ // It is still not a close, so a later need is not a reason to reopen.
93
+ t.advance(65_000);
94
+ t.ask();
95
+ expect(t.update()).toEqual([
96
+ expect.objectContaining({
97
+ type: "ask",
98
+ reason: "recently-opened",
99
+ openedAgoMs: 95_000,
100
+ }),
101
+ ]);
102
+ });
103
+
104
+ it("reports a known-closed tab as closed inside the window, then reopens for the next need", () => {
105
+ const t = fixture();
106
+ t.ask();
107
+ t.update();
108
+ t.advance(2_000);
109
+ t.heartbeat();
110
+ t.update();
111
+ t.answerAll();
112
+ t.bye();
113
+ t.update();
114
+ t.advance(BROWSER_CLOSE_GRACE_MS);
115
+ expect(t.update()).toEqual([]);
116
+ t.ask();
117
+ expect(t.update()).toEqual([
118
+ expect.objectContaining({ type: "ask", reason: "browser-closed" }),
119
+ ]);
120
+ t.answerAll();
121
+ t.advance(BROWSER_REOPEN_GUARD_MS);
122
+ t.ask();
123
+ expect(t.update()).toEqual([expect.objectContaining(open)]);
124
+ });
125
+
126
+ it("counts a tab that stopped beating as closed, and tells the agent once when a question was waiting", () => {
127
+ const t = fixture();
128
+ t.ask();
129
+ t.update();
130
+ t.advance(1_000);
131
+ t.heartbeat();
132
+ t.update();
133
+ // No goodbye: the beats just stop. One missed beat is not a close.
134
+ t.advance(20_000 + 1_000);
135
+ expect(t.update()).toEqual([]);
136
+ t.advance(BROWSER_CLOSE_GRACE_MS);
137
+ expect(t.update()).toEqual([
138
+ expect.objectContaining({
139
+ type: "ask",
140
+ reason: "browser-closed",
141
+ need: expect.objectContaining({ id: "q1" }),
142
+ }),
143
+ ]);
144
+ expect(t.update()).toEqual([]);
145
+ t.advance(BROWSER_REOPEN_GUARD_MS);
146
+ t.ask();
147
+ expect(t.update()).toEqual([expect.objectContaining(open)]);
148
+ });
149
+
150
+ it("asks the agent for every question under --no-open", () => {
151
+ const t = fixture({ canOpen: false });
152
+ t.ask();
153
+ t.ask();
154
+ expect(
155
+ t.update().map((action) => action.type === "ask" && action.reason),
156
+ ).toEqual(["no-browser", "no-browser"]);
157
+ });
158
+
159
+ it("on a host without browser presence, opens once and then lets the agent ask", () => {
160
+ const t = fixture({ tracked: false });
161
+ t.ask();
162
+ expect(t.update()).toEqual([expect.objectContaining(open)]);
163
+ t.advance(120_000);
164
+ t.ask();
165
+ expect(
166
+ t.update().map((action) => action.type === "ask" && action.reason),
167
+ ).toEqual(["recently-opened", "recently-opened"]);
168
+ });
169
+
170
+ it("treats a proposed plan and a proposal to close as needs", () => {
171
+ const t = fixture();
172
+ t.state.plans.push({
173
+ revision: 1,
174
+ markdown: "x",
175
+ status: "proposed",
176
+ submittedAt: 1,
177
+ });
178
+ expect(t.update()).toEqual([
179
+ expect.objectContaining({
180
+ need: expect.objectContaining({ id: "plan" }),
181
+ }),
182
+ ]);
183
+ t.state.plans[0]!.status = "approved";
184
+ t.state.status = "installing";
185
+ t.state.completion = { proposedAt: 5 };
186
+ expect(t.update()).toEqual([
187
+ expect.objectContaining({
188
+ need: expect.objectContaining({ id: "finish" }),
189
+ }),
190
+ ]);
191
+ });
192
+ });
package/src/browser.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { needKey, pendingNeeds } from "./needs";
2
+ import type { SetupNeed } from "./needs";
3
+ import { BROWSER_HEARTBEAT_MS, BROWSER_PRESENCE_MS } from "./protocol";
4
+ import type { Checkout } from "./protocol";
5
+
6
+ /** Within this long of opening a tab, setup never opens another. */
7
+ export const BROWSER_REOPEN_GUARD_MS = 30_000;
8
+ /** A page silent this long after it was present counts as closed; one heartbeat gap is not enough. */
9
+ export const BROWSER_CLOSE_GRACE_MS = BROWSER_HEARTBEAT_MS + 1_000;
10
+
11
+ /**
12
+ * Why the agent should ask the user itself:
13
+ * - recently-opened: a tab was opened moments ago, or opened and never joined;
14
+ * - browser-closed: the user closed the setup tab (and it may not reopen yet);
15
+ * - no-browser: setup may not open a browser (`--no-open`).
16
+ */
17
+ export type AskReason = "recently-opened" | "browser-closed" | "no-browser";
18
+
19
+ export type BrowserGateAction =
20
+ | { type: "open"; url: string; need: SetupNeed }
21
+ | { type: "ask"; reason: AskReason; need: SetupNeed; openedAgoMs?: number };
22
+
23
+ /**
24
+ * Decides when setup opens the user's browser: only once the setup waits on
25
+ * the user, never while a joined tab is present, never twice within the
26
+ * guard window, and again for a later need once a joined tab went away.
27
+ * Presence is measured on this machine's clock from when each heartbeat
28
+ * arrived, so a skewed server clock does not matter.
29
+ */
30
+ export const createBrowserGate = ({
31
+ url,
32
+ canOpen = true,
33
+ now = Date.now,
34
+ }: {
35
+ /** The setup page for a state; `undefined` when there is none to open. */
36
+ url: (state: Checkout.State) => string | undefined;
37
+ /** False for `--no-open`: the agent is told to ask instead. */
38
+ canOpen?: boolean;
39
+ now?: () => number;
40
+ }) => {
41
+ const handled = new Set<string>();
42
+ /** Needs the agent was already told to ask about. */
43
+ const told = new Set<string>();
44
+ let openedAt: number | undefined;
45
+ let joinedSinceOpen = false;
46
+ let lastBeat: number | null | undefined;
47
+ let beatArrivedAt: number | undefined;
48
+ let present = false;
49
+ let absentSince: number | undefined;
50
+ let closedReported = true;
51
+ let joinTimeoutReported = true;
52
+
53
+ const trackPresence = (browser: Checkout.State["browser"]) => {
54
+ if (browser === undefined) return;
55
+ const time = now();
56
+ if (browser.lastSeenAt !== lastBeat) {
57
+ // Every beat, the first one included, counts from when it arrived here.
58
+ beatArrivedAt = browser.lastSeenAt === null ? undefined : time;
59
+ lastBeat = browser.lastSeenAt;
60
+ }
61
+ if (
62
+ browser.connected &&
63
+ beatArrivedAt !== undefined &&
64
+ time - beatArrivedAt < BROWSER_PRESENCE_MS
65
+ ) {
66
+ present = joinedSinceOpen = true;
67
+ absentSince = undefined;
68
+ closedReported = false;
69
+ } else if (present || absentSince !== undefined) {
70
+ // A page that went quiet left when its last beat expired, not when this noticed.
71
+ absentSince ??=
72
+ browser.connected && beatArrivedAt !== undefined
73
+ ? Math.min(time, beatArrivedAt + BROWSER_PRESENCE_MS)
74
+ : time;
75
+ present = false;
76
+ }
77
+ };
78
+
79
+ return {
80
+ update: (state: Checkout.State): BrowserGateAction[] => {
81
+ trackPresence(state.browser);
82
+ const time = now();
83
+ const needs = pendingNeeds(state);
84
+ const keys = new Set(needs.map(needKey));
85
+ for (const key of handled) if (!keys.has(key)) handled.delete(key);
86
+ for (const key of told) if (!keys.has(key)) told.delete(key);
87
+ // Present, or gone too briefly to call it closed.
88
+ const looksPresent =
89
+ present ||
90
+ (absentSince !== undefined &&
91
+ time - absentSince < BROWSER_CLOSE_GRACE_MS);
92
+ const closed = !looksPresent && absentSince !== undefined;
93
+ const ask = (need: SetupNeed, reason: AskReason): BrowserGateAction => {
94
+ told.add(needKey(need));
95
+ return {
96
+ type: "ask",
97
+ reason,
98
+ need,
99
+ ...(reason === "recently-opened" && {
100
+ openedAgoMs: time - openedAt!,
101
+ }),
102
+ };
103
+ };
104
+ const actions: BrowserGateAction[] = [];
105
+ if (closed && !closedReported) {
106
+ // The tab closed while the user had something to answer there.
107
+ closedReported = true;
108
+ for (const need of needs)
109
+ if (handled.has(needKey(need)))
110
+ actions.push(ask(need, "browser-closed"));
111
+ }
112
+
113
+ if (
114
+ !joinTimeoutReported &&
115
+ !joinedSinceOpen &&
116
+ time - openedAt! >= BROWSER_REOPEN_GUARD_MS
117
+ ) {
118
+ // The opened tab never joined: it did not open, or the user left it.
119
+ joinTimeoutReported = true;
120
+ for (const need of needs)
121
+ if (handled.has(needKey(need)) && !told.has(needKey(need)))
122
+ actions.push(ask(need, "recently-opened"));
123
+ }
124
+
125
+ const fresh = needs.filter((need) => !handled.has(needKey(need)));
126
+ for (const need of fresh) handled.add(needKey(need));
127
+ if (fresh.length === 0 || looksPresent) return actions;
128
+ const target = canOpen ? url(state) : undefined;
129
+ if (target === undefined)
130
+ return [...actions, ...fresh.map((need) => ask(need, "no-browser"))];
131
+ if (
132
+ openedAt === undefined ||
133
+ (time - openedAt >= BROWSER_REOPEN_GUARD_MS && joinedSinceOpen)
134
+ ) {
135
+ openedAt = time;
136
+ joinedSinceOpen = false;
137
+ joinTimeoutReported = false;
138
+ // One tab shows every waiting question.
139
+ return [...actions, { type: "open", url: target, need: fresh[0]! }];
140
+ }
141
+ const reason =
142
+ closed && joinedSinceOpen ? "browser-closed" : "recently-opened";
143
+ return [...actions, ...fresh.map((need) => ask(need, reason))];
144
+ },
145
+ };
146
+ };