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
@@ -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.8",
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"
@@ -43,11 +47,21 @@
43
47
  "README.md"
44
48
  ],
45
49
  "dependencies": {
46
- "@assistant-ui/tap": "^0.9.25",
50
+ "cross-spawn": "^7.0.6",
47
51
  "statewire": "^0.22.1"
48
52
  },
53
+ "peerDependencies": {
54
+ "@assistant-ui/tap": "^0.9.25"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@assistant-ui/tap": {
58
+ "optional": true
59
+ }
60
+ },
49
61
  "devDependencies": {
62
+ "@assistant-ui/tap": "0.9.25",
50
63
  "@assistant-ui/x-buildutils": "0.0.32",
64
+ "@types/cross-spawn": "^6.0.6",
51
65
  "@types/node": "^26.6.5",
52
66
  "@types/react": "^19.3.0",
53
67
  "react": "^19.3.1",
@@ -0,0 +1,137 @@
1
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, onTestFinished } from "vitest";
5
+ import {
6
+ CODING_AGENTS,
7
+ detectAgentKind,
8
+ findCommand,
9
+ isDirectInvocation,
10
+ runCodingAgent,
11
+ } from "./agents";
12
+
13
+ describe("CODING_AGENTS", () => {
14
+ it("lists the twenty most used coding agents once each", () => {
15
+ expect(CODING_AGENTS).toHaveLength(20);
16
+ const kinds = CODING_AGENTS.map((agent) => agent.kind);
17
+ expect(new Set(kinds).size).toBe(kinds.length);
18
+ expect(kinds.slice(0, 4)).toEqual(["claude", "codex", "cursor", "gemini"]);
19
+ });
20
+
21
+ it("starts an agent with the prompt as its first message", () => {
22
+ const byKind = Object.fromEntries(
23
+ CODING_AGENTS.map((agent) => [agent.kind, agent]),
24
+ );
25
+ expect(byKind.claude!.launch!.args("go")).toEqual(["go"]);
26
+ expect(byKind.gemini!.launch!.args("go")).toEqual(["-i", "go"]);
27
+ expect(byKind.opencode!.launch!.args("go")).toEqual(["--prompt", "go"]);
28
+ expect(byKind.windsurf!.launch).toBeUndefined();
29
+ });
30
+ });
31
+
32
+ describe("detectAgentKind", () => {
33
+ it("reads the agent from AI_AGENT first", () => {
34
+ expect(detectAgentKind({ AI_AGENT: "claude-code_2-1-3_agent" })).toBe(
35
+ "claude",
36
+ );
37
+ expect(detectAgentKind({ AI_AGENT: "github-copilot" })).toBe("copilot");
38
+ expect(detectAgentKind({ AI_AGENT: "some-new-agent" })).toBe("other");
39
+ expect(detectAgentKind({ AI_AGENT: " " })).toBeUndefined();
40
+ expect(detectAgentKind({ AI_AGENT: "claudette" })).toBe("other");
41
+ expect(detectAgentKind({ AI_AGENT: "codex" })).toBe("codex");
42
+ expect(
43
+ detectAgentKind({
44
+ AI_AGENT: "claude-code_2-1-3_agent",
45
+ CODEX_THREAD_ID: "t",
46
+ }),
47
+ ).toBe("claude");
48
+ });
49
+
50
+ it("reads the variables each agent exports to its shell", () => {
51
+ expect(detectAgentKind({ CODEX_THREAD_ID: "t" })).toBe("codex");
52
+ expect(detectAgentKind({ QWEN_CODE: "1" })).toBe("qwen");
53
+ expect(detectAgentKind({ OPENCODE_CLIENT: "cli" })).toBe("opencode");
54
+ expect(detectAgentKind({ AUGMENT_AGENT: "1" })).toBe("augment");
55
+ });
56
+
57
+ it("treats a person at a terminal inside an editor, or with Goose configured, as no agent", () => {
58
+ expect(detectAgentKind({ GOOSE_PROVIDER: "openai" })).toBeUndefined();
59
+ expect(
60
+ detectAgentKind({ CURSOR_TRACE_ID: "x", TERM_PROGRAM: "vscode" }),
61
+ ).toBe(undefined);
62
+ expect(
63
+ isDirectInvocation(
64
+ { CURSOR_TRACE_ID: "x" },
65
+ { isTTY: true },
66
+ { isTTY: true },
67
+ ),
68
+ ).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("findCommand and runCodingAgent", () => {
73
+ const binDirectory = async () => {
74
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-bin-"));
75
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
76
+ const script = join(directory, "fake-agent");
77
+ await writeFile(
78
+ script,
79
+ '#!/bin/sh\n[ "$1" = "-i" ] && [ "$2" = "hello" ] && exit 7\nexit 1\n',
80
+ );
81
+ await chmod(script, 0o755);
82
+ const second = join(directory, "other-agent");
83
+ await writeFile(second, "#!/bin/sh\nexit 0\n");
84
+ await chmod(second, 0o755);
85
+ return directory;
86
+ };
87
+
88
+ it.skipIf(process.platform === "win32")(
89
+ "finds the first installed command and runs it with the prompt",
90
+ async () => {
91
+ const directory = await binDirectory();
92
+ const path = process.env.PATH;
93
+ process.env.PATH = `${directory}:${path}`;
94
+ onTestFinished(() => {
95
+ process.env.PATH = path;
96
+ });
97
+ expect(findCommand(["missing-agent", "fake-agent"])).toBe("fake-agent");
98
+ expect(findCommand(["other-agent", "fake-agent"])).toBe("other-agent");
99
+ expect(findCommand(["fake-agent", "other-agent"])).toBe("fake-agent");
100
+ expect(findCommand(["fake-agent"], { PATH: "" })).toBeUndefined();
101
+ expect(findCommand(["missing-agent"])).toBeUndefined();
102
+ const code = await runCodingAgent(
103
+ {
104
+ kind: "fake",
105
+ name: "Fake",
106
+ launch: {
107
+ commands: ["fake-agent"],
108
+ args: (prompt) => ["-i", prompt],
109
+ install: "",
110
+ },
111
+ },
112
+ "hello",
113
+ );
114
+ expect(code).toBe(7);
115
+ },
116
+ );
117
+
118
+ it("reports an agent it cannot start as not installed", async () => {
119
+ expect(
120
+ await runCodingAgent(
121
+ {
122
+ kind: "fake",
123
+ name: "Fake",
124
+ launch: {
125
+ commands: ["no-such-agent-xyz"],
126
+ args: () => [],
127
+ install: "",
128
+ },
129
+ },
130
+ "hello",
131
+ ),
132
+ ).toBeUndefined();
133
+ expect(await runCodingAgent({ kind: "zed", name: "Zed" }, "hi")).toBe(
134
+ undefined,
135
+ );
136
+ });
137
+ });