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.
- package/README.md +31 -0
- package/dist/agents.d.ts +33 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +221 -0
- package/dist/agents.js.map +1 -0
- package/dist/browser.d.ts +40 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +91 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +38 -49
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +89 -180
- package/dist/cli.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +13 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/needs.d.ts +62 -0
- package/dist/needs.d.ts.map +1 -0
- package/dist/needs.js +184 -0
- package/dist/needs.js.map +1 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +8 -1
- package/dist/protocol.js.map +1 -1
- package/dist/sdk.d.ts +48 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +97 -0
- package/dist/sdk.js.map +1 -0
- package/dist/stream.d.ts +93 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +184 -0
- package/dist/stream.js.map +1 -0
- package/package.json +16 -2
- package/src/agents.test.ts +137 -0
- package/src/agents.ts +243 -0
- package/src/browser.test.ts +192 -0
- package/src/browser.ts +146 -0
- package/src/cli.test.ts +78 -0
- package/src/cli.ts +189 -258
- package/src/host.test.ts +25 -0
- package/src/host.ts +7 -0
- package/src/index.ts +1 -0
- package/src/needs.test.ts +180 -0
- package/src/needs.ts +273 -0
- package/src/protocol.ts +17 -0
- package/src/sdk.test.ts +111 -0
- package/src/sdk.ts +145 -0
- package/src/stream.test.ts +223 -0
- package/src/stream.ts +314 -0
package/src/agents.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import spawn from "cross-spawn";
|
|
2
|
+
import { accessSync, constants } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A coding agent a person can hand a setup to. `launch` is set when the
|
|
7
|
+
* agent has a terminal command that opens an interactive session with a
|
|
8
|
+
* first prompt; the others are handed the prompt to paste.
|
|
9
|
+
*/
|
|
10
|
+
export type CodingAgent = {
|
|
11
|
+
kind: string;
|
|
12
|
+
name: string;
|
|
13
|
+
launch?: {
|
|
14
|
+
/** Command names to try in order; the first one on PATH runs. */
|
|
15
|
+
commands: readonly string[];
|
|
16
|
+
args: (prompt: string) => string[];
|
|
17
|
+
install: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The coding agents people use most, in the order the picker lists them. */
|
|
22
|
+
export const CODING_AGENTS: readonly CodingAgent[] = [
|
|
23
|
+
{
|
|
24
|
+
kind: "claude",
|
|
25
|
+
name: "Claude Code",
|
|
26
|
+
launch: {
|
|
27
|
+
commands: ["claude"],
|
|
28
|
+
args: (prompt) => [prompt],
|
|
29
|
+
install: "npm install -g @anthropic-ai/claude-code",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: "codex",
|
|
34
|
+
name: "Codex",
|
|
35
|
+
launch: {
|
|
36
|
+
commands: ["codex"],
|
|
37
|
+
args: (prompt) => [prompt],
|
|
38
|
+
install: "npm install -g @openai/codex",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
kind: "cursor",
|
|
43
|
+
name: "Cursor",
|
|
44
|
+
launch: {
|
|
45
|
+
commands: ["cursor-agent", "agent"],
|
|
46
|
+
args: (prompt) => [prompt],
|
|
47
|
+
install: "curl https://cursor.com/install -fsS | bash",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
kind: "gemini",
|
|
52
|
+
name: "Gemini CLI",
|
|
53
|
+
launch: {
|
|
54
|
+
commands: ["gemini"],
|
|
55
|
+
args: (prompt) => ["-i", prompt],
|
|
56
|
+
install: "npm install -g @google/gemini-cli",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kind: "copilot",
|
|
61
|
+
name: "Copilot CLI",
|
|
62
|
+
launch: {
|
|
63
|
+
commands: ["copilot"],
|
|
64
|
+
args: (prompt) => ["-i", prompt],
|
|
65
|
+
install: "npm install -g @github/copilot",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{ kind: "amp", name: "Amp" },
|
|
69
|
+
{
|
|
70
|
+
kind: "opencode",
|
|
71
|
+
name: "OpenCode",
|
|
72
|
+
launch: {
|
|
73
|
+
commands: ["opencode"],
|
|
74
|
+
args: (prompt) => ["--prompt", prompt],
|
|
75
|
+
install: "npm install -g opencode-ai",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{ kind: "cline", name: "Cline" },
|
|
79
|
+
{ kind: "aider", name: "Aider" },
|
|
80
|
+
{ kind: "goose", name: "Goose" },
|
|
81
|
+
{ kind: "warp", name: "Warp" },
|
|
82
|
+
{ kind: "windsurf", name: "Windsurf" },
|
|
83
|
+
{
|
|
84
|
+
kind: "kiro",
|
|
85
|
+
name: "Kiro",
|
|
86
|
+
launch: {
|
|
87
|
+
commands: ["kiro-cli"],
|
|
88
|
+
args: (prompt) => ["chat", prompt],
|
|
89
|
+
install: "curl -fsSL https://cli.kiro.dev/install | bash",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{ kind: "junie", name: "Junie" },
|
|
93
|
+
{
|
|
94
|
+
kind: "qwen",
|
|
95
|
+
name: "Qwen Code",
|
|
96
|
+
launch: {
|
|
97
|
+
commands: ["qwen"],
|
|
98
|
+
args: (prompt) => ["-i", prompt],
|
|
99
|
+
install: "npm install -g @qwen-code/qwen-code",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{ kind: "roo", name: "Roo Code" },
|
|
103
|
+
{ kind: "zed", name: "Zed" },
|
|
104
|
+
{
|
|
105
|
+
kind: "augment",
|
|
106
|
+
name: "Augment",
|
|
107
|
+
launch: {
|
|
108
|
+
commands: ["auggie"],
|
|
109
|
+
args: (prompt) => [prompt],
|
|
110
|
+
install: "npm install -g @augmentcode/auggie",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
kind: "droid",
|
|
115
|
+
name: "Factory Droid",
|
|
116
|
+
launch: {
|
|
117
|
+
commands: ["droid"],
|
|
118
|
+
args: (prompt) => [prompt],
|
|
119
|
+
install: "curl -fsSL https://app.factory.ai/cli | sh",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{ kind: "crush", name: "Crush" },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/** The variables each agent exports to the shells it runs commands in. */
|
|
126
|
+
const AGENT_ENV: readonly [kind: string, variable: string][] = [
|
|
127
|
+
["claude", "CLAUDECODE"],
|
|
128
|
+
["claude", "CLAUDE_CODE"],
|
|
129
|
+
["codex", "CODEX_SANDBOX"],
|
|
130
|
+
["codex", "CODEX_SANDBOX_NETWORK_DISABLED"],
|
|
131
|
+
["codex", "CODEX_CI"],
|
|
132
|
+
["codex", "CODEX_THREAD_ID"],
|
|
133
|
+
["cursor", "CURSOR_AGENT"],
|
|
134
|
+
["gemini", "GEMINI_CLI"],
|
|
135
|
+
["qwen", "QWEN_CODE"],
|
|
136
|
+
["copilot", "COPILOT_AGENT"],
|
|
137
|
+
["copilot", "COPILOT_CLI"],
|
|
138
|
+
["opencode", "OPENCODE_CLIENT"],
|
|
139
|
+
["augment", "AUGMENT_AGENT"],
|
|
140
|
+
["junie", "JUNIE_DATA"],
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** `AI_AGENT` values name the agent first, such as `claude-code_2-1-3_agent` or `github-copilot`. */
|
|
144
|
+
const AI_AGENT_PREFIXES: readonly [prefix: string, kind: string][] = [
|
|
145
|
+
["claude", "claude"],
|
|
146
|
+
["codex", "codex"],
|
|
147
|
+
["cursor", "cursor"],
|
|
148
|
+
["gemini", "gemini"],
|
|
149
|
+
["github-copilot", "copilot"],
|
|
150
|
+
["github_copilot", "copilot"],
|
|
151
|
+
["copilot", "copilot"],
|
|
152
|
+
["opencode", "opencode"],
|
|
153
|
+
["augment", "augment"],
|
|
154
|
+
["auggie", "augment"],
|
|
155
|
+
["qwen", "qwen"],
|
|
156
|
+
["amp", "amp"],
|
|
157
|
+
["goose", "goose"],
|
|
158
|
+
["junie", "junie"],
|
|
159
|
+
["droid", "droid"],
|
|
160
|
+
["factory", "droid"],
|
|
161
|
+
["kiro", "kiro"],
|
|
162
|
+
["cline", "cline"],
|
|
163
|
+
["crush", "crush"],
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
/** The coding agent this process runs under, from the variables each one exports to its shell. */
|
|
167
|
+
export const detectAgentKind = (
|
|
168
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
169
|
+
): string | undefined => {
|
|
170
|
+
const named = env.AI_AGENT?.trim().toLowerCase();
|
|
171
|
+
if (named) {
|
|
172
|
+
// The name, or the name followed by a separator and a version or role.
|
|
173
|
+
const match = AI_AGENT_PREFIXES.find(
|
|
174
|
+
([prefix]) =>
|
|
175
|
+
named.startsWith(prefix) &&
|
|
176
|
+
(named.length === prefix.length ||
|
|
177
|
+
/[-_ ./0-9]/.test(named[prefix.length]!)),
|
|
178
|
+
);
|
|
179
|
+
return match?.[1] ?? "other";
|
|
180
|
+
}
|
|
181
|
+
return AGENT_ENV.find(([, variable]) => env[variable])?.[0];
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** True when a person typed the command into a terminal rather than an agent running it from its shell. */
|
|
185
|
+
export const isDirectInvocation = (
|
|
186
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
187
|
+
stdin: { isTTY?: boolean | undefined } = process.stdin,
|
|
188
|
+
stdout: { isTTY?: boolean | undefined } = process.stdout,
|
|
189
|
+
) =>
|
|
190
|
+
detectAgentKind(env) === undefined &&
|
|
191
|
+
stdin.isTTY === true &&
|
|
192
|
+
stdout.isTTY === true;
|
|
193
|
+
|
|
194
|
+
/** The first of `commands` found on PATH, or `undefined`. */
|
|
195
|
+
export const findCommand = (
|
|
196
|
+
commands: readonly string[],
|
|
197
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
198
|
+
): string | undefined => {
|
|
199
|
+
// Empty entries (the working directory) are skipped on purpose: a project
|
|
200
|
+
// must not be able to plant its own `claude` next to the user.
|
|
201
|
+
const directories = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
202
|
+
const extensions =
|
|
203
|
+
process.platform === "win32"
|
|
204
|
+
? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
|
|
205
|
+
: [""];
|
|
206
|
+
return commands.find((command) =>
|
|
207
|
+
directories.some((directory) =>
|
|
208
|
+
extensions.some((extension) => {
|
|
209
|
+
try {
|
|
210
|
+
accessSync(join(directory, command + extension), constants.X_OK);
|
|
211
|
+
return true;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Starts `agent` in this terminal with `prompt` as its first message and
|
|
222
|
+
* resolves with its exit code; `undefined` when its command is not installed.
|
|
223
|
+
*/
|
|
224
|
+
export const runCodingAgent = (
|
|
225
|
+
agent: CodingAgent,
|
|
226
|
+
prompt: string,
|
|
227
|
+
): Promise<number | undefined> => {
|
|
228
|
+
const launch = agent.launch;
|
|
229
|
+
if (!launch) return Promise.resolve(undefined);
|
|
230
|
+
const command = findCommand(launch.commands);
|
|
231
|
+
if (command === undefined) return Promise.resolve(undefined);
|
|
232
|
+
const child = spawn(command, launch.args(prompt), { stdio: "inherit" });
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
235
|
+
if (error.code === "ENOENT") resolve(undefined);
|
|
236
|
+
else {
|
|
237
|
+
process.stderr.write(`${error.message}\n`);
|
|
238
|
+
resolve(1);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
child.on("exit", (status) => resolve(status ?? 1));
|
|
242
|
+
});
|
|
243
|
+
};
|
|
@@ -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
|
+
};
|
package/src/cli.test.ts
CHANGED
|
@@ -602,6 +602,28 @@ describe("begin planning", () => {
|
|
|
602
602
|
});
|
|
603
603
|
|
|
604
604
|
describe("agent instructions", () => {
|
|
605
|
+
it("goes straight to planning for a setup the agent started", () => {
|
|
606
|
+
const browserUrl =
|
|
607
|
+
"https://setup.assistant-ui.com/#link=abc12345&setup=def67890";
|
|
608
|
+
const started = agentInstructions("https://setup.test/abc12345", {
|
|
609
|
+
startedByAgent: { browserUrl },
|
|
610
|
+
});
|
|
611
|
+
expect(started).not.toContain("click Begin plan");
|
|
612
|
+
expect(started).not.toContain("wait start\nKeep the stream running");
|
|
613
|
+
expect(started).toContain("there is no Begin plan click to wait for");
|
|
614
|
+
expect(started).toContain(browserUrl);
|
|
615
|
+
expect(started).toContain("a setup you opened in the user's browser");
|
|
616
|
+
const opened = agentInstructions("https://setup.test/abc12345");
|
|
617
|
+
expect(opened).toContain("click Begin plan");
|
|
618
|
+
expect(opened).toContain("a setup the user opened in their browser");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("asks what to set up when the setup has no products", () => {
|
|
622
|
+
expect(agentInstructions("https://setup.test/s")).toContain(
|
|
623
|
+
"If the setup lists no products, first ask the user what to set up",
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
|
|
605
627
|
it("prints a setup URL as one quoted shell word", () => {
|
|
606
628
|
const instructions = agentInstructions("https://checkout.test/a'b;c");
|
|
607
629
|
expect(instructions).toContain(
|
|
@@ -1199,3 +1221,59 @@ describe("model key handoff", () => {
|
|
|
1199
1221
|
},
|
|
1200
1222
|
);
|
|
1201
1223
|
});
|
|
1224
|
+
|
|
1225
|
+
describe("answer", () => {
|
|
1226
|
+
it("answers an input for the user from the agent's side", async () => {
|
|
1227
|
+
const state: Checkout.State = {
|
|
1228
|
+
...initialCheckoutState(),
|
|
1229
|
+
id: "setup-a",
|
|
1230
|
+
createdAt: 1,
|
|
1231
|
+
status: "planning",
|
|
1232
|
+
inputs: [
|
|
1233
|
+
{
|
|
1234
|
+
id: "q1",
|
|
1235
|
+
phase: "planning",
|
|
1236
|
+
kind: "text",
|
|
1237
|
+
prompt: "Where?",
|
|
1238
|
+
optional: false,
|
|
1239
|
+
status: "open",
|
|
1240
|
+
createdAt: 2,
|
|
1241
|
+
},
|
|
1242
|
+
],
|
|
1243
|
+
};
|
|
1244
|
+
const answer = vi.fn(async () => {});
|
|
1245
|
+
connectCheckoutMock.mockResolvedValue({
|
|
1246
|
+
state,
|
|
1247
|
+
commands: { "checkout/answer": answer },
|
|
1248
|
+
dispose: vi.fn(),
|
|
1249
|
+
} as unknown as Awaited<ReturnType<typeof connectCheckout>>);
|
|
1250
|
+
const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
|
|
1251
|
+
onTestFinished(() => {
|
|
1252
|
+
stdout.mockRestore();
|
|
1253
|
+
connectCheckoutMock.mockReset();
|
|
1254
|
+
});
|
|
1255
|
+
await main([
|
|
1256
|
+
"https://checkout.test/session",
|
|
1257
|
+
"answer",
|
|
1258
|
+
"q1",
|
|
1259
|
+
"apps/web",
|
|
1260
|
+
"--note",
|
|
1261
|
+
"said in chat",
|
|
1262
|
+
]);
|
|
1263
|
+
expect(answer).toHaveBeenCalledWith({
|
|
1264
|
+
inputId: "q1",
|
|
1265
|
+
answer: "apps/web",
|
|
1266
|
+
note: "said in chat",
|
|
1267
|
+
});
|
|
1268
|
+
expect(stdout).toHaveBeenCalledWith(
|
|
1269
|
+
`${JSON.stringify({ ok: true, id: "q1", status: "answered" })}\n`,
|
|
1270
|
+
);
|
|
1271
|
+
});
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
describe("help", () => {
|
|
1275
|
+
it("documents answer in the help", () => {
|
|
1276
|
+
expect(HELP).toContain("answer <input-id> <answer>");
|
|
1277
|
+
expect(HELP).toContain("--setup-app-url");
|
|
1278
|
+
});
|
|
1279
|
+
});
|