premanmcp 0.10.6 → 0.11.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/bin/connect.js CHANGED
@@ -1,1570 +1,70 @@
1
1
  /**
2
2
  * `preman connect` — pick a coding agent and get connected, in one command.
3
3
  *
4
- * Replaces the hand-edited MCP config: the user picks Cursor / Claude Code /
5
- * Codex from a list, PreMan logs them in if needed, writes that agent's config
6
- * itself, and binds the connection to their account with a pair code.
4
+ * This file is the command itself: preflight, the order the steps run in, and
5
+ * the ladder that gets the first PreMan call to happen. The parts it orchestrates
6
+ * live next door, because each one grew its own reasons to be careful and reading
7
+ * them interleaved stopped being possible at around a thousand lines:
7
8
  *
8
- * Optionally captures a cloud-dispatch credential in the same pass so PreMan can
9
- * start agent runs for them later (SCRUM-124). That step is always skippable and
10
- * never fails the connect.
9
+ * connect/agents.js which agent, and how it is asked
10
+ * connect/configs.js writing each agent's config, and proving it landed
11
+ * connect/pairing.js pair codes and the credential behind them
12
+ * connect/checkin.js self-test, agent run, terminal opening, waiting
13
+ * connect/guide.js endpoints, first test, runner, desktop, integrations, hook
14
+ * connect/dispatch.js the cloud-dispatch credential
15
+ *
16
+ * Everything those modules export is re-exported at the bottom, so `cli.js` and
17
+ * the smoke suites keep importing `bin/connect.js` and cannot tell this changed.
11
18
  */
12
19
 
13
- import { execFileSync, spawn, spawnSync } from "node:child_process";
14
- import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
20
  import os from "node:os";
16
21
  import path from "node:path";
22
+ import { existsSync } from "node:fs";
17
23
 
18
- import { callTool as callPremanTool, printTestSummary } from "./api_tools.js";
19
- import { installDesktopCommand } from "./desktop.js";
20
- import { installHook, hookStatus } from "./hook.js";
21
- import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
22
- import {
23
- confirmRunnerOnline,
24
- pairingIsLive,
25
- readRunnerState,
26
- registerRunner,
27
- runnerIsAlive,
28
- startBackground,
29
- } from "./runner.js";
30
24
  import {
31
25
  apiKeyIsExplicit,
32
- assertOk,
33
26
  authenticateTerminal,
34
27
  backendUrl,
35
28
  buildServerConfig,
36
- callBackendJson,
37
- clearStoredCredentials,
38
29
  cliInvocation,
39
- frontendUrl,
40
30
  hasKeyAvailable,
41
- LAUNCHER_ARGS,
42
- LAUNCHER_COMMAND,
43
31
  makeArgs,
32
+ onPath,
44
33
  pathPremanOwner,
45
- promptSecret,
46
- promptText,
47
- readJsonFile,
48
34
  resolveApiKey,
49
- writeJsonFile,
50
35
  } from "./shared.js";
51
-
52
- const EXIT_USAGE = 2;
53
-
54
- class ConnectError extends Error {
55
- constructor(message, exitCode = 1) {
56
- super(message);
57
- this.exitCode = exitCode;
58
- }
59
- }
60
-
61
- /**
62
- * Supported agents. `id` matches the backend's normalize_agent() vocabulary so
63
- * the link record and this CLI cannot disagree about who is connected.
64
- */
65
- const AGENTS = [
66
- {
67
- id: "cursor",
68
- label: "Cursor",
69
- aliases: ["cursor"],
70
- dispatch: {
71
- credential: "Cursor API key",
72
- needsRoutine: false,
73
- source: "cursor.com/dashboard → Integrations → API Keys",
74
- },
75
- snippetHint: "merge into ~/.cursor/mcp.json",
76
- restartHint: 'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on.',
77
- },
78
- {
79
- id: "claude_code",
80
- label: "Claude Code",
81
- aliases: ["claude", "claude-code", "claude_code", "claudecode"],
82
- dispatch: {
83
- credential: "Claude Code routine token",
84
- needsRoutine: true,
85
- // Named explicitly because "Claude Code routine token" reads like an
86
- // Anthropic API key, which is a different credential from a different page.
87
- source: "claude.ai/code/routines → your routine → Add API trigger",
88
- },
89
- snippetHint: "run:",
90
- restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
91
- },
92
- {
93
- id: "codex",
94
- label: "Codex",
95
- aliases: ["codex", "openai-codex", "openai_codex"],
96
- dispatch: null, // No public fire API; stays on the copy-paste path.
97
- snippetHint: "append to ~/.codex/config.toml",
98
- restartHint: "Restart Codex so it re-reads its config.toml.",
99
- },
100
- ];
101
-
102
- function findAgent(value) {
103
- const raw = String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
104
- return AGENTS.find((a) => a.id === raw || a.aliases.includes(raw)) || null;
105
- }
106
-
107
- function onPath(binary) {
108
- const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
109
- stdio: "ignore",
110
- });
111
- return probe.status === 0;
112
- }
113
-
114
- /**
115
- * Why this agent's CLI cannot run unattended right now, or "".
116
- *
117
- * Installed is not the same as usable. `cursor-agent` sits on PATH and exits 1
118
- * on every `-p` run until someone signs in, which surfaced as "Cursor did not
119
- * register any endpoints" and sent people looking at PreMan for an hour. Only
120
- * Cursor is probed because only its CLI has a cheap non-interactive status
121
- * subcommand; the others are diagnosed from their own output when they fail.
122
- */
123
- export function agentBlocker(agentId) {
124
- if (agentId !== "cursor") return "";
125
- const probe = spawnSync("cursor-agent", ["status"], {
126
- encoding: "utf8",
127
- timeout: 15000,
128
- stdio: ["ignore", "pipe", "pipe"],
129
- });
130
- const text = `${probe.stdout || ""}${probe.stderr || ""}`;
131
- if (/not logged in|not authenticated|no active session/i.test(text)) {
132
- return "cursor-agent is installed but not signed in — run `cursor-agent login`";
133
- }
134
- return "";
135
- }
136
-
137
- /** Best guess at which agent this machine actually uses, for the default pick. */
138
- function detectAgents() {
139
- const home = os.homedir();
140
- return {
141
- cursor: existsSync(path.join(home, ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID),
142
- claude_code: onPath("claude") || existsSync(path.join(home, ".claude.json")),
143
- codex: onPath("codex") || existsSync(process.env.CODEX_HOME || path.join(home, ".codex")),
144
- };
145
- }
146
-
147
- /**
148
- * The agent whose session this command is running inside, if any.
149
- *
150
- * A better default than "first one installed": someone who types this into an
151
- * agent's terminal almost always means that agent, and on a machine with all
152
- * three installed the detected-order default is usually wrong.
153
- */
154
- export function runningInside(env = process.env) {
155
- if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
156
- if (env.CURSOR_TRACE_ID) return "cursor";
157
- return "";
158
- }
159
-
160
- /** Is PreMan's MCP server already written into this agent's config? */
161
- function agentIsLinked(agentId, serverName = "preman") {
162
- try {
163
- const home = os.homedir();
164
- if (agentId === "cursor") {
165
- return Boolean(readJsonFile(path.join(home, ".cursor", "mcp.json")).mcpServers?.[serverName]);
166
- }
167
- if (agentId === "claude_code") {
168
- return Boolean(readJsonFile(path.join(home, ".claude.json")).mcpServers?.[serverName]);
169
- }
170
- if (agentId === "codex") {
171
- const file = path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
172
- return existsSync(file) && readFileSync(file, "utf8").includes(`[mcp_servers.${serverName}]`);
173
- }
174
- } catch {
175
- // An unreadable config is not an answer; fall through to the other signals.
176
- }
177
- return false;
178
- }
179
-
180
- /**
181
- * Which agent this machine should pair as, or "" when only a person can say.
182
- *
183
- * Pairing needs an agent id, and demanding `--agent` for it turned "start the
184
- * runner" into a command someone had to be told twice. The signals are ordered
185
- * by how much they actually know: the session we are inside, then the agent
186
- * PreMan is already configured in, then the only one installed. Asking is the
187
- * last resort rather than the first, and a machine with no terminal to ask in
188
- * gets "" so the caller can fail with a sentence instead of hanging on a prompt.
189
- */
190
- export async function resolveAgentForPairing({ interactive = Boolean(process.stdin.isTTY) } = {}) {
191
- const inside = runningInside();
192
- if (inside) return inside;
193
-
194
- const linked = AGENTS.filter((agent) => agentIsLinked(agent.id));
195
- if (linked.length === 1) return linked[0].id;
196
-
197
- const detected = detectAgents();
198
- const present = AGENTS.filter((agent) => detected[agent.id]);
199
- if (present.length === 1) return present[0].id;
200
-
201
- if (!interactive) return "";
202
- const picked = await promptAgentChoice(detected);
203
- return picked?.id || "";
204
- }
205
-
206
- async function promptAgentChoice(detected, insideId = runningInside()) {
207
- process.stdout.write("Which coding agent?\n");
208
- AGENTS.forEach((agent, index) => {
209
- let mark = "";
210
- if (agent.id === insideId) mark = " (this session)";
211
- else if (detected[agent.id]) mark = " (detected)";
212
- process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
213
- });
214
-
215
- const inside = AGENTS.findIndex((a) => a.id === insideId);
216
- const defaultIndex =
217
- inside >= 0
218
- ? inside
219
- : Math.max(
220
- 0,
221
- AGENTS.findIndex((a) => detected[a.id])
222
- );
223
- const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
224
- if (!answer) return AGENTS[defaultIndex];
225
-
226
- const byNumber = Number.parseInt(answer, 10);
227
- if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= AGENTS.length) {
228
- return AGENTS[byNumber - 1];
229
- }
230
- const byName = findAgent(answer);
231
- if (byName) return byName;
232
- throw new ConnectError(`Not a valid choice: ${answer}`, EXIT_USAGE);
233
- }
234
-
235
- // ── Config writers ──────────────────────────────────────────────────────
236
-
237
- function cursorConfigPath(projectInstall) {
238
- return projectInstall
239
- ? path.join(process.cwd(), ".cursor", "mcp.json")
240
- : path.join(os.homedir(), ".cursor", "mcp.json");
241
- }
242
-
243
- export function writeCursorConfig({ serverName, serverConfig, projectInstall }) {
244
- const configPath = cursorConfigPath(projectInstall);
245
- const config = { ...readJsonFile(configPath) };
246
- config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
247
- writeJsonFile(configPath, config);
248
- return { path: configPath, how: "wrote" };
249
- }
250
-
251
- /** The `claude mcp add` invocation, shared by the writer and the printed fallback. */
252
- export function claudeMcpAddArgs(serverName, serverConfig, projectInstall) {
253
- const envArgs = Object.entries(serverConfig.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
254
- return [
255
- "mcp",
256
- "add",
257
- serverName,
258
- "--scope",
259
- projectInstall ? "project" : "user",
260
- ...envArgs,
261
- "--",
262
- serverConfig.command,
263
- ...serverConfig.args,
264
- ];
265
- }
266
-
267
- /**
268
- * Claude Code owns ~/.claude.json, so prefer its own CLI. Fall back to writing
269
- * the config directly when `claude` is not installed — a user can connect before
270
- * installing the agent.
271
- */
272
- export function writeClaudeConfig({ serverName, serverConfig, projectInstall }) {
273
- if (onPath("claude")) {
274
- const args = claudeMcpAddArgs(serverName, serverConfig, projectInstall);
275
- try {
276
- execFileSync("claude", args, { stdio: "pipe" });
277
- return { path: projectInstall ? ".mcp.json" : "Claude Code user config", how: "registered via claude mcp add" };
278
- } catch (error) {
279
- const detail = error?.stderr?.toString().trim() || error.message;
280
- process.stdout.write(`Note: claude mcp add failed (${detail}); writing the config directly.\n`);
281
- }
282
- }
283
-
284
- if (projectInstall) {
285
- const configPath = path.join(process.cwd(), ".mcp.json");
286
- const config = { ...readJsonFile(configPath) };
287
- config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
288
- writeJsonFile(configPath, config);
289
- return { path: configPath, how: "wrote" };
290
- }
291
-
292
- // Merge only mcpServers: the user config holds a lot of unrelated state.
293
- // CLAUDE_CONFIG_DIR relocates that file, and writing to ~ when it is set
294
- // produces a config Claude Code never reads.
295
- const configPath = path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
296
- const config = { ...readJsonFile(configPath) };
297
- config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
298
- writeJsonFile(configPath, config);
299
- return { path: configPath, how: "wrote" };
300
- }
301
-
302
- /**
303
- * Render the `[mcp_servers.<name>]` block for Codex's TOML config. Only strings
304
- * and string arrays appear here, so a hand-rolled writer is enough — no TOML
305
- * dependency for one fixed shape.
306
- */
307
- export function renderCodexToml(serverName, serverConfig) {
308
- const str = (value) => JSON.stringify(String(value));
309
- const lines = [
310
- `[mcp_servers.${serverName}]`,
311
- `command = ${str(serverConfig.command)}`,
312
- `args = [${serverConfig.args.map(str).join(", ")}]`,
313
- ];
314
- const env = Object.entries(serverConfig.env);
315
- if (env.length) {
316
- lines.push("", `[mcp_servers.${serverName}.env]`);
317
- for (const [key, value] of env) lines.push(`${key} = ${str(value)}`);
318
- }
319
- return `${lines.join("\n")}\n`;
320
- }
321
-
322
- /** Replace an existing preman block, or append a new one, leaving the rest alone. */
323
- export function upsertCodexToml(existing, serverName, block) {
324
- const header = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`, "m");
325
- if (!header.test(existing)) {
326
- const separator = existing.trim() ? "\n" : "";
327
- return `${existing.trimEnd()}${separator}\n${block}`;
328
- }
329
-
330
- const lines = existing.split("\n");
331
- const out = [];
332
- let skipping = false;
333
- for (const line of lines) {
334
- const isOurHeader = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`).test(line);
335
- if (isOurHeader) {
336
- skipping = true;
337
- continue;
338
- }
339
- if (skipping && /^\[/.test(line) && !isOurHeader) skipping = false;
340
- if (!skipping) out.push(line);
341
- }
342
- return `${out.join("\n").trimEnd()}\n\n${block}`;
343
- }
344
-
345
- export function writeCodexConfig({ serverName, serverConfig }) {
346
- const configPath = path.join(
347
- process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
348
- "config.toml"
349
- );
350
- const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
351
- const next = upsertCodexToml(existing, serverName, renderCodexToml(serverName, serverConfig));
352
- mkdirSync(path.dirname(configPath), { recursive: true });
353
- writeFileSync(configPath, next, { mode: 0o600 });
354
- chmodSync(configPath, 0o600); // mode above is ignored for an existing file
355
- return { path: configPath, how: "wrote" };
356
- }
357
-
358
- const WRITERS = {
359
- cursor: writeCursorConfig,
360
- claude_code: writeClaudeConfig,
361
- codex: writeCodexConfig,
362
- };
363
-
364
- // ── Copy-paste fallbacks ────────────────────────────────────────────────
365
-
366
- /** Quote only what a shell would otherwise mangle, so the line stays readable. */
367
- function shellQuote(value) {
368
- return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
369
- }
370
-
371
- /** What a user would paste by hand to get exactly what the writer would have written. */
372
- export function renderAgentSnippet(agentId, serverName, serverConfig, { projectInstall = false } = {}) {
373
- if (agentId === "codex") return renderCodexToml(serverName, serverConfig);
374
- if (agentId === "claude_code") {
375
- const args = claudeMcpAddArgs(serverName, serverConfig, projectInstall);
376
- return `claude ${args.map(shellQuote).join(" ")}\n`;
377
- }
378
- return `${JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2)}\n`;
379
- }
380
-
381
- /** Every agent's snippet, for when we could not pick one for the user. */
382
- export function renderAllAgentSnippets(args, serverName, { projectInstall = false } = {}) {
383
- // Build config WITHOUT resolved API key to avoid leaking secrets in CI logs.
384
- // Users must supply --api-key or set PREMAN_API_KEY separately.
385
- const env = {
386
- PREMAN_BACKEND: backendUrl(args),
387
- PREMAN_FRONTEND: frontendUrl(args),
388
- };
389
- const serverConfig = {
390
- command: LAUNCHER_COMMAND,
391
- args: [...LAUNCHER_ARGS],
392
- env,
393
- };
394
-
395
-
396
- return AGENTS.map((agent) => {
397
- // Adjust hint based on projectInstall, matching verifyWrittenConfig logic
398
- let hint = agent.snippetHint;
399
- if (agent.id === "cursor" && projectInstall) {
400
- hint = "merge into .cursor/mcp.json";
401
- } else if (agent.id === "claude_code") {
402
- hint = projectInstall ? "run (with --scope project):" : "run:";
403
- }
404
- return `# ${agent.label} — ${hint}\n` + renderAgentSnippet(agent.id, serverName, serverConfig, { projectInstall });
405
- }).join("\n");
406
- }
407
-
408
- // ── Post-write validation ───────────────────────────────────────────────
409
-
410
- /** The lines belonging to our `[mcp_servers.<name>]` block, or null if absent. */
411
- function codexBlockLines(text, serverName) {
412
- const lines = text.split("\n");
413
- const start = lines.findIndex((line) => line.trim() === `[mcp_servers.${serverName}]`);
414
- if (start === -1) return null;
415
- const block = [];
416
- for (let i = start + 1; i < lines.length; i += 1) {
417
- const line = lines[i];
418
- if (/^\[/.test(line) && !line.startsWith(`[mcp_servers.${serverName}.`)) break;
419
- block.push(line);
420
- }
421
- return block;
422
- }
423
-
424
- /**
425
- * Read back what we just wrote, so a silently-failed write is not reported as a
426
- * success. Proves the entry is on disk (or that Claude Code knows about it) —
427
- * whether the agent has actually loaded it is only ever proven by a check-in.
428
- *
429
- * Never throws: a verification that cannot run must not fail the connect. An
430
- * unreadable state reports "unknown" and stays quiet, because a wrong warning
431
- * costs more trust than a missing one.
432
- */
433
- export function verifyWrittenConfig(agent, { serverName, written }) {
434
- try {
435
- if (agent.id === "codex") {
436
- const text = written.path && existsSync(written.path) ? readFileSync(written.path, "utf8") : "";
437
- const block = codexBlockLines(text, serverName);
438
- if (!block) {
439
- return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
440
- }
441
- if (!block.some((line) => line.trim() === `command = "${LAUNCHER_COMMAND}"`)) {
442
- return {
443
- status: "mismatch",
444
- detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
445
- };
446
- }
447
- return { status: "verified" };
448
- }
449
-
450
- // Claude Code registered the server itself — ask its CLI what it ended up with.
451
- if (agent.id === "claude_code" && written.how !== "wrote") {
452
- const probe = spawnSync("claude", ["mcp", "get", serverName], { stdio: "ignore" });
453
- if (probe.error) return { status: "unknown", detail: "could not run claude" };
454
- return probe.status === 0
455
- ? { status: "verified" }
456
- : { status: "mismatch", detail: `claude mcp get ${serverName} did not find the server` };
457
- }
458
-
459
- const entry = readJsonFile(written.path).mcpServers?.[serverName];
460
- if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
461
- if (entry.command !== LAUNCHER_COMMAND) {
462
- return {
463
- status: "mismatch",
464
- detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
465
- };
466
- }
467
- return { status: "verified" };
468
- } catch (error) {
469
- return { status: "unknown", detail: error.message };
470
- }
471
- }
472
-
473
- // ── Pairing ─────────────────────────────────────────────────────────────
474
-
475
- /**
476
- * Mint the pair code, distinguishing "could not reach it" from "this key is dead".
477
- *
478
- * 401 is the backend saying the key is invalid or revoked, which is the one
479
- * failure the caller must not shrug off: every other outcome still leaves a
480
- * usable config, but writing a revoked key into an agent's config produces a
481
- * setup that looks connected and fails on every call.
482
- */
483
- async function startPairing(args, agent, apiKey) {
484
- const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
485
- token: apiKey,
486
- json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
487
- });
488
- if (result.status_code === 401) return { pairCode: "", stale: true };
489
- if (!result.ok) {
490
- process.stdout.write(
491
- `Note: could not start pairing (${result.status_code}); the config still works, ` +
492
- "your agent will link on its first PreMan call.\n"
493
- );
494
- return { pairCode: "", stale: false };
495
- }
496
- return { pairCode: String(result.pair_code || ""), stale: false };
497
- }
498
-
499
- /**
500
- * The --no-pair stand-in for the auth check pairing performs implicitly.
501
- *
502
- * Declining a pair code should not also decline noticing the key is dead,
503
- * otherwise this flag writes exactly the broken config pairing would catch.
504
- */
505
- async function verifyKey(args, apiKey) {
506
- const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
507
- return { pairCode: "", stale: result.status_code === 401 };
508
- }
509
-
510
- /** Whichever of the two ways to learn the backend still accepts this key. */
511
- async function checkKeyAndPair(args, agent, apiKey) {
512
- return args.has("--no-pair")
513
- ? verifyKey(args, apiKey)
514
- : startPairing(args, agent, apiKey);
515
- }
516
-
517
- /**
518
- * Replace credentials the backend has rejected, and return the new key.
519
- *
520
- * Only stored credentials can be replaced here: an explicit --api-key or
521
- * PREMAN_API_KEY would just be re-read, so those say so and stop rather than
522
- * looping. Refusing to continue is deliberate — the alternative is writing a
523
- * key we know is dead into the agent's config.
524
- */
525
- async function refreshStaleCredentials(args) {
526
- if (apiKeyIsExplicit(args)) {
527
- const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
528
- throw new ConnectError(
529
- `The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
530
- `dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
531
- EXIT_USAGE
532
- );
533
- }
534
- if (args.has("--skip-login") || !process.stdin.isTTY) {
535
- throw new ConnectError(
536
- "Saved PreMan credentials are invalid or revoked. Run " +
537
- `'${cliInvocation()} logout', then connect again to sign in.`,
538
- EXIT_USAGE
539
- );
540
- }
541
-
542
- process.stdout.write(
543
- "\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
544
- "account behind it was removed. Signing you in again.\n"
545
- );
546
- clearStoredCredentials();
547
- await authenticateTerminal(args);
548
- process.stdout.write("\n");
549
- return resolveApiKey(args);
550
- }
551
-
552
- export async function waitForConnection(
553
- args,
554
- apiKey,
555
- {
556
- intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
557
- timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
558
- stopWhen = null,
559
- // Called once per unsuccessful poll, so a wait measured in minutes can show
560
- // that it is still a wait rather than a hang.
561
- onPoll = null,
562
- } = {}
563
- ) {
564
- const deadline = Date.now() + timeoutMs;
565
- let interrupted = false;
566
- const onInterrupt = () => {
567
- interrupted = true;
568
- };
569
- process.on("SIGINT", onInterrupt);
570
-
571
- try {
572
- while (Date.now() < deadline && !interrupted) {
573
- const status = await callBackendJson(args, "GET", "/workbench/coding-agent", {
574
- token: apiKey,
575
- });
576
- if (status.ok && status.connected) return true;
577
- if (stopWhen && stopWhen()) return false;
578
- if (onPoll) onPoll();
579
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
580
- }
581
- } finally {
582
- process.off("SIGINT", onInterrupt);
583
- }
584
- return false;
585
- }
586
-
587
- /** A dot per poll, and the newline that closes the run of them. */
588
- function pollTicker() {
589
- let dots = 0;
590
- return {
591
- tick() {
592
- dots += 1;
593
- process.stdout.write(".");
594
- },
595
- end() {
596
- if (!dots) return;
597
- dots = 0;
598
- process.stdout.write("\n");
599
- },
600
- };
601
- }
602
-
603
- // ── Auto check-in ───────────────────────────────────────────────────────
604
-
605
- /**
606
- * How to ask each agent to make one PreMan call without opening its UI.
607
- *
608
- * The link is established by the agent's first call, so anything that reaches
609
- * `preman_status` finishes the connect. Claude Code needs the server on its
610
- * allow-list because print mode refuses un-allowed MCP tools rather than
611
- * prompting.
612
- */
613
- export function headlessCheckIn(agent, serverName) {
614
- const prompt = checkInPrompt(serverName);
615
- if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
616
- if (agent.id === "claude_code") {
617
- return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
618
- }
619
- if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
620
- return null;
621
- }
622
-
623
- function checkInPrompt(serverName) {
624
- return `Call the ${serverName} MCP tool preman_status and report the result.`;
625
- }
626
-
627
- /**
628
- * How to start each agent as the session the user actually works in.
629
- *
630
- * Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
631
- * the agent anybody goes on to use, and its failures are invisible because
632
- * nobody is looking at it. The interactive form is the same prompt without that
633
- * flag — it needs a terminal of its own, which is what openInNewTerminal is for.
634
- */
635
- export function interactiveCheckIn(agent, serverName) {
636
- const prompt = checkInPrompt(serverName);
637
- if (agent.id === "cursor") return { bin: "cursor-agent", args: [prompt] };
638
- if (agent.id === "claude_code") {
639
- return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
640
- }
641
- if (agent.id === "codex") return { bin: "codex", args: [prompt] };
642
- return null;
643
- }
644
-
645
- /** A command line for a spec, quoted for the shell the terminal will start. */
646
- export function commandLine(spec) {
647
- return [spec.bin, ...spec.args].map(shellQuote).join(" ");
648
- }
649
-
650
- /** Escape a shell line into an AppleScript string literal. */
651
- function appleScriptString(value) {
652
- return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
653
- }
654
-
655
- /**
656
- * The terminal emulator to open a window in, or null when we know of none.
657
- *
658
- * `sync` marks the launchers that hand the work to an app and return — those
659
- * report their own failure through an exit code, which is worth waiting for.
660
- * The rest *are* the window, so they are spawned detached and outlive us.
661
- */
662
- function terminalLauncher(line) {
663
- if (process.platform === "darwin") {
664
- const iterm = existsSync("/Applications/iTerm.app");
665
- const script = iterm
666
- ? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
667
- : `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
668
- return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
669
- }
670
-
671
- if (process.platform === "win32") {
672
- return {
673
- bin: "cmd.exe",
674
- args: ["/c", "start", "cmd.exe", "/k", line],
675
- label: "Command Prompt",
676
- sync: true,
677
- };
678
- }
679
-
680
- // Keep the shell alive afterwards: an agent that exits immediately would
681
- // otherwise take its own error message off the screen with it.
682
- const body = `${line}; exec ${process.env.SHELL || "sh"}`;
683
- const candidates = [
684
- { bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
685
- { bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
686
- { bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
687
- { bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
688
- ];
689
- return candidates.find((candidate) => onPath(candidate.bin)) || null;
690
- }
691
-
692
- /**
693
- * Open `command` in a new terminal window, best effort.
694
- *
695
- * Same contract as openUrl: never throws, and declines rather than guessing when
696
- * nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
697
- * stdout for CI, piped output and test runs. Callers fall back to a headless run.
698
- */
699
- export function openInNewTerminal(command, options = {}) {
700
- const cwd = options?.cwd || process.cwd();
701
- const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
702
- if (optOut && !["0", "false", "no"].includes(optOut)) {
703
- return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
704
- }
705
- if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
706
-
707
- const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
708
- if (!launcher) return { opened: false, reason: "no terminal emulator found" };
709
-
710
- try {
711
- if (launcher.sync) {
712
- const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
713
- if (done.error) return { opened: false, reason: done.error.message };
714
- if (done.status !== 0) {
715
- return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
716
- }
717
- } else {
718
- const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
719
- child.unref();
720
- }
721
- return { opened: true, terminal: launcher.label };
722
- } catch (error) {
723
- return { opened: false, reason: error.message };
724
- }
725
- }
726
-
727
- /**
728
- * Finish the link ourselves instead of asking the user to go restart their agent.
729
- *
730
- * The config on disk is already correct at this point; all that is missing is
731
- * one call from the agent, and telling someone to make it from another terminal
732
- * is a dead end in the one terminal they are sitting in.
733
- *
734
- * So start the agent. Interactively, in a window of its own, because that is the
735
- * session the user keeps: a print-mode run answers once, exits, and leaves them
736
- * exactly where they started. Only when no window can be opened — CI, SSH, a
737
- * machine with no terminal emulator — does this fall back to the headless run,
738
- * which still finishes the link even though nobody sees it happen.
739
- *
740
- * `cwd` is where the agent starts, and it is load-bearing rather than tidiness:
741
- * the MCP server reads its repo config out of the working directory, so starting
742
- * the agent elsewhere is how a caller escapes a directory that would otherwise
743
- * redirect it to a backend this key was never issued for.
744
- *
745
- * Returns `ran: false` when the agent's binary is absent or will not start, and
746
- * the caller falls back to the printed instructions.
747
- */
748
- export async function autoCheckIn(
749
- args,
750
- agent,
751
- apiKey,
752
- {
753
- // Never outlast the connect's own wait budget: this phase is part of it, not
754
- // an extra one bolted on the front.
755
- timeoutMs = Math.min(
756
- Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
757
- Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
758
- ),
759
- serverName = "preman",
760
- intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
761
- cwd = process.cwd(),
762
- onLaunch = () => {},
763
- onPoll = null,
764
- } = {}
765
- ) {
766
- const session = interactiveCheckIn(agent, serverName);
767
- if (session && onPath(session.bin)) {
768
- const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd });
769
- if (opened) {
770
- onLaunch({ mode: "interactive", bin: session.bin, terminal });
771
- // Detached, so there is no exit to watch for and no output to quote: the
772
- // agent is in front of the user now, and the deadline is all that bounds
773
- // this. Whoever is watching the window can Ctrl+C out of the wait.
774
- const connected = await waitForConnection(args, apiKey, { intervalMs, timeoutMs, onPoll });
775
- return { ran: true, connected, command: session.bin, interactive: true, terminal };
776
- }
777
- }
778
-
779
- const spec = headlessCheckIn(agent, serverName);
780
- if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
781
- if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
782
-
783
- let child;
784
- try {
785
- // Piped rather than ignored: an agent that runs and does not check in used to
786
- // report exactly that and nothing else, which is the least useful sentence
787
- // available. Its own last words usually name the cause.
788
- child = spawn(spec.bin, spec.args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
789
- } catch (error) {
790
- return { ran: false, connected: false, reason: error.message };
791
- }
792
- onLaunch({ mode: "headless", bin: spec.bin });
793
-
794
- let spawnError = null;
795
- let exitedAt = 0;
796
- let output = "";
797
- const absorb = (chunk) => {
798
- output = `${output}${chunk}`.slice(-4000);
799
- };
800
- child.stdout?.setEncoding("utf8");
801
- child.stderr?.setEncoding("utf8");
802
- child.stdout?.on("data", absorb);
803
- child.stderr?.on("data", absorb);
804
- child.on("error", (error) => {
805
- spawnError = error;
806
- exitedAt = exitedAt || Date.now();
807
- });
808
- child.on("exit", () => {
809
- exitedAt = exitedAt || Date.now();
810
- });
811
-
812
- // The check-in can land moments after the agent's own process ends, so keep
813
- // polling briefly past its exit rather than declaring failure at the edge.
814
- const grace = intervalMs * 2;
815
- try {
816
- const connected = await waitForConnection(args, apiKey, {
817
- intervalMs,
818
- timeoutMs,
819
- onPoll,
820
- stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
821
- });
822
- if (spawnError && !connected) {
823
- return { ran: false, connected: false, reason: spawnError.message, output };
824
- }
825
- return { ran: true, connected, command: spec.bin, output };
826
- } finally {
827
- if (child.exitCode === null && child.signalCode === null) child.kill();
828
- }
829
- }
830
-
831
- /** The last non-empty line of an agent's output, for a one-line diagnosis. */
832
- export function lastLine(output, cap = 200) {
833
- const lines = String(output || "")
834
- .split("\n")
835
- .map((line) => line.trim())
836
- .filter(Boolean);
837
- return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
838
- }
839
-
840
- // ── MCP self-test ───────────────────────────────────────────────────────
841
-
842
- /**
843
- * How long to give the self-test, and whether to run it at all.
844
- *
845
- * `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
846
- * run wants: the launcher is `npm exec premanmcp@latest`, so every case that
847
- * reaches this would otherwise go to the registry.
848
- */
849
- export function selfTestBudgetMs() {
850
- const raw = process.env.PREMAN_SELFTEST_MS;
851
- if (raw === undefined || raw === "") return 60000;
852
- const parsed = Number(raw);
853
- return Number.isFinite(parsed) ? parsed : 60000;
854
- }
855
-
856
- /**
857
- * Run the config we just wrote, exactly as the agent will, and call one tool.
858
- *
859
- * This is both the fastest way to finish the link and the only check that proves
860
- * the whole chain — launcher, package, key, backend — rather than proving the
861
- * file is on disk. It matters because the failure it catches is invisible
862
- * otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
863
- * redirect the server to a backend the key was never issued for, and every
864
- * symptom of that points at the key.
865
- *
866
- * Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
867
- */
868
- export async function mcpSelfTest(
869
- serverConfig,
870
- { timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
871
- ) {
872
- if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
873
- let child;
874
- try {
875
- child = spawn(serverConfig.command, serverConfig.args, {
876
- cwd,
877
- env: { ...process.env, ...serverConfig.env },
878
- stdio: ["pipe", "pipe", "pipe"],
879
- });
880
- } catch (error) {
881
- return { ok: false, reason: error.message };
882
- }
883
-
884
- return new Promise((resolve) => {
885
- let stdout = "";
886
- let stderr = "";
887
- let settled = false;
888
-
889
- const finish = (result) => {
890
- if (settled) return;
891
- settled = true;
892
- clearTimeout(timer);
893
- if (child.exitCode === null && child.signalCode === null) child.kill();
894
- resolve(result);
895
- };
896
-
897
- const timer = setTimeout(
898
- () => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
899
- timeoutMs
900
- );
901
-
902
- const send = (message) => {
903
- try {
904
- child.stdin.write(`${JSON.stringify(message)}\n`);
905
- } catch (error) {
906
- finish({ ok: false, reason: error.message });
907
- }
908
- };
909
-
910
- child.on("error", (error) => finish({ ok: false, reason: error.message }));
911
- child.on("exit", (code) =>
912
- finish({
913
- ok: false,
914
- reason: `the MCP server exited with code ${code}`,
915
- stderr: lastLine(stderr),
916
- })
917
- );
918
-
919
- child.stdout.setEncoding("utf8");
920
- child.stderr.setEncoding("utf8");
921
- child.stderr.on("data", (chunk) => {
922
- stderr = `${stderr}${chunk}`.slice(-4000);
923
- });
924
- child.stdout.on("data", (chunk) => {
925
- stdout += chunk;
926
- let newline = stdout.indexOf("\n");
927
- while (newline !== -1) {
928
- const line = stdout.slice(0, newline).trim();
929
- stdout = stdout.slice(newline + 1);
930
- newline = stdout.indexOf("\n");
931
- if (!line.startsWith("{")) continue;
932
- let message;
933
- try {
934
- message = JSON.parse(line);
935
- } catch {
936
- continue;
937
- }
938
- if (message.id === 1) {
939
- send({ jsonrpc: "2.0", method: "notifications/initialized" });
940
- send({
941
- jsonrpc: "2.0",
942
- id: 2,
943
- method: "tools/call",
944
- params: { name: "preman_status", arguments: {} },
945
- });
946
- continue;
947
- }
948
- if (message.id !== 2) continue;
949
- const text = message.result?.content?.[0]?.text;
950
- let status = {};
951
- try {
952
- status = text ? JSON.parse(text) : {};
953
- } catch {
954
- status = { raw: text };
955
- }
956
- finish({ ok: !message.error, status, reason: message.error?.message || "" });
957
- }
958
- });
959
-
960
- send({
961
- jsonrpc: "2.0",
962
- id: 1,
963
- method: "initialize",
964
- params: {
965
- protocolVersion: "2024-11-05",
966
- capabilities: {},
967
- clientInfo: { name: "preman-connect-selftest", version: "1" },
968
- },
969
- });
970
- });
971
- }
972
-
973
- // ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
974
-
975
- async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
976
- if (!agent.dispatch) return;
977
- if (args.has("--skip-dispatch-credential")) return;
978
-
979
- let secret = args.value("--dispatch-credential", "");
980
- let routineId = args.value("--routine-id", "");
981
-
982
- if (!secret) {
983
- if (!prompt || !process.stdin.isTTY) return;
984
- // Framed as the expected step rather than an optional aside. Without it
985
- // PreMan can only suggest fixes; with it, it can run them. Presenting it as
986
- // "optional, press Enter to skip" meant almost everyone skipped the thing
987
- // that makes the product act rather than advise.
988
- process.stdout.write(
989
- `\nOptional — let PreMan start ${agent.label} runs for you, so it can apply\n` +
990
- `fixes and run checks on a schedule instead of only telling you what to do.\n` +
991
- `Get the token from ${agent.dispatch.source}.\n`
992
- );
993
- secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to skip): `);
994
- if (!secret) {
995
- process.stdout.write(
996
- `Skipped. Run '${cliInvocation()} dispatch --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
997
- );
998
- return;
999
- }
1000
- if (agent.dispatch.needsRoutine && !routineId) {
1001
- routineId = await promptText("Routine id or URL: ");
1002
- }
1003
- }
1004
-
1005
- if (agent.dispatch.needsRoutine && !routineId) {
1006
- process.stdout.write("Skipped: a routine id is required alongside the token.\n");
1007
- return;
1008
- }
1009
-
1010
- const body = agent.dispatch.needsRoutine
1011
- ? { provider: agent.id, secret, routine_id: extractRoutineId(routineId) }
1012
- : { provider: agent.id, secret };
1013
-
1014
- const result = await callBackendJson(args, "PUT", "/workbench/coding-agent/dispatch", {
1015
- token: apiKey,
1016
- json: body,
1017
- });
1018
-
1019
- if (result.ok) {
1020
- process.stdout.write("Cloud dispatch enabled — PreMan can now start runs for you.\n");
1021
- } else if (result.status_code === 404 || result.status_code === 405) {
1022
- process.stdout.write("Note: this PreMan backend does not support cloud dispatch yet; skipped.\n");
1023
- } else {
1024
- process.stdout.write(`Note: could not save the credential (${result.status_code}); skipped.\n`);
1025
- }
1026
- }
1027
-
1028
- export const DISPATCH_HELP = `
1029
- Dispatch options:
1030
- --agent <name> cursor | claude-code (defaults to the connected agent)
1031
- --dispatch-credential <t> Cloud-dispatch token (non-interactive)
1032
- --routine-id <id> Claude Code routine id, with --dispatch-credential
1033
- --api-key <key> PreMan API key. If omitted, stored credentials are used
1034
- --backend <url> PreMan backend URL
1035
- `;
1036
-
1037
- /**
1038
- * `preman dispatch` — store the cloud-dispatch credential on its own.
1039
- *
1040
- * Exists so connect never has to ask for a token mid-onboarding. Someone who
1041
- * skipped it (or did not have it yet) comes back here instead of re-running the
1042
- * whole connect.
1043
- */
1044
- export async function dispatchCommand(commandArgs) {
1045
- const args = makeArgs(commandArgs);
1046
- const apiKey = resolveApiKey(args);
1047
- if (!apiKey) {
1048
- throw new ConnectError(
1049
- `No PreMan credentials. Run '${cliInvocation()} connect' first, or pass --api-key pm_live_….`,
1050
- EXIT_USAGE
1051
- );
1052
- }
1053
-
1054
- let agent = findAgent(args.value("--agent", ""));
1055
- if (!agent && args.value("--agent", "")) {
1056
- throw new ConnectError(
1057
- `Unknown agent: ${args.value("--agent", "")}. Use cursor or claude-code.`,
1058
- EXIT_USAGE
1059
- );
1060
- }
1061
-
1062
- if (!agent) {
1063
- const current = await callBackendJson(args, "GET", "/workbench/coding-agent", { token: apiKey });
1064
- agent = findAgent(current.ok ? current.agent : "");
1065
- if (!agent) {
1066
- throw new ConnectError(
1067
- "Could not tell which agent to set up. Pass --agent cursor or --agent claude-code.",
1068
- EXIT_USAGE
1069
- );
1070
- }
1071
- }
1072
-
1073
- if (!agent.dispatch) {
1074
- throw new ConnectError(`${agent.label} has no cloud-dispatch API yet.`, EXIT_USAGE);
1075
- }
1076
- if (!args.value("--dispatch-credential", "") && !process.stdin.isTTY) {
1077
- throw new ConnectError(
1078
- "preman dispatch needs a terminal, or --dispatch-credential <token>.",
1079
- EXIT_USAGE
1080
- );
1081
- }
1082
-
1083
- await captureDispatchCredential(args, agent, apiKey);
1084
- }
1085
-
1086
- /** Accept either a bare trig_… id or the routine URL it appears in. */
1087
- export function extractRoutineId(value) {
1088
- const raw = String(value || "").trim();
1089
- const match = raw.match(/trig_[A-Za-z0-9]+/);
1090
- return match ? match[0] : raw;
1091
- }
1092
-
1093
- // ── Guided first run ────────────────────────────────────────────────────
1094
-
1095
- const MANUAL_STEPS =
1096
- " preman endpoints discover # brief for your agent → endpoints.json\n" +
1097
- " preman endpoints setup --file endpoints.json # register runnable requests\n" +
1098
- " preman test <request-id> # generate + run your first scenarios\n";
1099
-
1100
- function nextStepsBlock(agent) {
1101
- return (
1102
- "\nNext steps:\n" +
1103
- ` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
1104
- ` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
1105
- ` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
1106
- ` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
1107
- );
1108
- }
1109
-
1110
- /**
1111
- * What is left once the link is done, as commands rather than as questions.
1112
- *
1113
- * Connect used to run all of these itself, which bought a prompt per step and a
1114
- * wait as long as the slowest broken one — and a link that had already succeeded
1115
- * ended on a failed runner and a five-minute GitHub poll. Each is one command, so
1116
- * this lists them and gets out of the way.
1117
- */
1118
- function whatIsLeftBlock(args) {
1119
- const cli = cliInvocation();
1120
- return (
1121
- "\nWhat is left:\n" +
1122
- ` ${cli} endpoints discover # map this repo's endpoints\n` +
1123
- ` ${cli} runner start --background # let PreMan apply fixes on this machine\n` +
1124
- ` ${cli} github # connect GitHub, or do it in the dashboard\n` +
1125
- `\n${cli} status reports your endpoints, last push and integrations.\n` +
1126
- `Watch runs at ${frontendUrl(args)}\n`
1127
- );
1128
- }
1129
-
1130
- /**
1131
- * Ask, or take the step's own default when nobody can answer.
1132
- *
1133
- * `--yes` returns the default rather than a blanket yes, which matters for
1134
- * exactly one step: the desktop app defaults to no because it downloads a
1135
- * hundred-odd megabytes and writes to /Applications. A blanket yes made
1136
- * `preman connect --yes` do that unattended, which is not what anyone means by
1137
- * "do not ask me questions" -- they get `preman install-desktop` for that.
1138
- */
1139
- async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
1140
- if (assumeYes) return defaultYes;
1141
- if (!process.stdin.isTTY) return false;
1142
- const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
1143
- if (answer === "") return defaultYes;
1144
- return answer.startsWith("y");
1145
- }
1146
-
1147
- function step(title) {
1148
- process.stdout.write(`\n${title}\n`);
1149
- }
1150
-
1151
- /** What the account can see right now: registry rows, and the runnable subset. */
1152
- async function endpointCounts(args) {
1153
- const inventory = await callPremanTool(args, "get_endpoints", {
1154
- include_workbench: true,
1155
- limit: 200,
1156
- });
1157
- const runnable = inventory.workbench_requests || [];
1158
- return {
1159
- registered: (inventory.endpoints || []).length,
1160
- runnable: runnable.length,
1161
- first: runnable[0] || null,
1162
- };
1163
- }
1164
-
1165
- /**
1166
- * The one-shot command that has an agent execute the discovery brief.
1167
- *
1168
- * Same shape as `headlessCheckIn`, with the read tools the walk needs added to
1169
- * Claude Code's allow-list: print mode refuses anything un-allowed instead of
1170
- * asking, so an agent given only the MCP server can call PreMan and read nothing.
1171
- */
1172
- export function headlessDiscovery(agent, serverName, instructions = []) {
1173
- const prompt = [
1174
- `Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
1175
- "Work autonomously and do not ask for confirmation.",
1176
- "",
1177
- ...instructions,
1178
- ].join("\n");
1179
- if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
1180
- if (agent.id === "claude_code") {
1181
- return {
1182
- bin: "claude",
1183
- args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
1184
- };
1185
- }
1186
- if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
1187
- return null;
1188
- }
1189
-
1190
- /**
1191
- * Get this project's endpoints into PreMan without handing anyone homework.
1192
- *
1193
- * Discovery is the agent's job — only it can read the repo — but "here is a brief,
1194
- * go paste it somewhere" is the step everybody dropped out on, and the commands
1195
- * printed after it referenced a file the user never had. So run the agent on the
1196
- * brief and report the two numbers that matter.
1197
- *
1198
- * Never throws: a failed discovery must not fail the connect.
1199
- */
1200
- export async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
1201
- try {
1202
- const before = await endpointCounts(args);
1203
- if (before.registered) {
1204
- process.stdout.write(
1205
- `${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
1206
- );
1207
- return before;
1208
- }
1209
-
1210
- const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
1211
- const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
1212
- // Discovery has to read *this* repo, so it cannot be moved out of a directory
1213
- // that redirects it the way the check-in was — it can only be skipped, before
1214
- // it spends ten minutes proving what the self-test already found out.
1215
- const blocker = blockedHere || (spec && onPath(spec.bin) ? agentBlocker(agent.id) : "");
1216
- if (!spec || !onPath(spec.bin) || blocker) {
1217
- // Say why before printing homework: "here is a brief" reads as PreMan not
1218
- // working, when the actual answer is one login away.
1219
- if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
1220
- for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
1221
- process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
1222
- return before;
1223
- }
1224
-
1225
- process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
1226
- const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
1227
- const outcome = await runAgentOnce(spec, timeoutMs);
1228
- const after = await endpointCounts(args);
1229
-
1230
- if (!after.registered) {
1231
- // Both halves: the exit code says the run failed, the agent's own last line
1232
- // says why, and either one alone has sent someone down the wrong path.
1233
- const why =
1234
- [outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
1235
- "it registered nothing";
1236
- process.stdout.write(
1237
- `${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
1238
- ` Run it yourself: ${cliInvocation()} endpoints discover\n`
1239
- );
1240
- return after;
1241
- }
1242
- process.stdout.write(
1243
- `${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
1244
- );
1245
- return after;
1246
- } catch (error) {
1247
- process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
1248
- return { registered: 0, runnable: 0, first: null };
1249
- }
1250
- }
1251
-
1252
- /** Run one bounded, non-interactive agent invocation and keep its tail. */
1253
- async function runAgentOnce(spec, timeoutMs) {
1254
- return new Promise((resolve) => {
1255
- let child;
1256
- try {
1257
- child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
1258
- } catch (error) {
1259
- resolve({ ok: false, reason: error.message, output: "" });
1260
- return;
1261
- }
1262
- let output = "";
1263
- const absorb = (chunk) => {
1264
- output = `${output}${chunk}`.slice(-8000);
1265
- };
1266
- child.stdout?.setEncoding("utf8");
1267
- child.stderr?.setEncoding("utf8");
1268
- child.stdout?.on("data", absorb);
1269
- child.stderr?.on("data", absorb);
1270
-
1271
- const timer = setTimeout(() => {
1272
- child.kill("SIGTERM");
1273
- resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
1274
- }, timeoutMs);
1275
- child.on("error", (error) => {
1276
- clearTimeout(timer);
1277
- resolve({ ok: false, reason: error.message, output });
1278
- });
1279
- child.on("close", (code) => {
1280
- clearTimeout(timer);
1281
- resolve({
1282
- ok: code === 0,
1283
- reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
1284
- output,
1285
- });
1286
- });
1287
- });
1288
- }
1289
-
1290
- /** Generate and run scenarios against the first runnable request, if they want it. */
1291
- async function runFirstTest(args, runnable, { assumeYes }) {
1292
- const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
1293
- if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
1294
- process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
1295
- return;
1296
- }
1297
- try {
1298
- process.stdout.write("Generating scenarios…\n");
1299
- const result = await callPremanTool(args, "generate_endpoint_tests", {
1300
- target: runnable.id,
1301
- run: true,
1302
- allow_writes: false,
1303
- max_cases: 10,
1304
- });
1305
- printTestSummary(result);
1306
- } catch (error) {
1307
- process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
1308
- }
1309
- }
1310
-
1311
- /**
1312
- * Whether a stored pairing can still be used, rather than merely matching.
1313
- *
1314
- * Matching agent and path only prove the state was written for this project. The
1315
- * token behind it can be revoked, replaced by another device, or left over from an
1316
- * API key that no longer exists — and reusing one of those starts a daemon that
1317
- * 401s on its first stream and exits, which reads as "the runner cannot start
1318
- * here" when the pairing is the only thing that needed replacing.
1319
- *
1320
- * A backend we cannot reach answers "usable": re-pairing is for a token the
1321
- * backend rejected, not for a network that was briefly down.
1322
- */
1323
- export async function runnerPairingIsUsable(args, existing, agentId, { cwd = process.cwd() } = {}) {
1324
- if (!existing || existing.agent !== agentId) return false;
1325
- if (path.resolve(String(existing.project_path || "")) !== path.resolve(cwd)) return false;
1326
- return pairingIsLive(args, existing);
1327
- }
1328
-
1329
- /**
1330
- * Pair this machine as a runner and leave it running.
1331
- *
1332
- * Without this, everything PreMan finds later is advice: it can describe a fix
1333
- * and cannot apply one. With it, the queue has somewhere to land.
1334
- */
1335
- async function setUpRunner(args, agent, { assumeYes }) {
1336
- if (args.has("--no-runner")) return { state: "skipped" };
1337
- const existing = readRunnerState();
1338
- if (existing && runnerIsAlive()) {
1339
- process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
1340
- return { state: "running" };
1341
- }
1342
- if (
1343
- !(await confirm(
1344
- `Let PreMan run ${agent.label} here when it finds something to fix?`,
1345
- { assumeYes }
1346
- ))
1347
- ) {
1348
- process.stdout.write(
1349
- `${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
1350
- );
1351
- return { state: "skipped" };
1352
- }
1353
-
1354
- try {
1355
- if (!(await runnerPairingIsUsable(args, existing, agent.id))) {
1356
- await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1357
- }
1358
- const started = startBackground([]);
1359
- // Claiming a runner that died two seconds later is worse than saying nothing:
1360
- // the whole point of this step is that PreMan can act, and someone told it
1361
- // can will wait for fixes that no device is listening for.
1362
- const up = await confirmRunnerOnline(started);
1363
- if (up.state === "exited") {
1364
- process.stdout.write(
1365
- `${MARK.fail()} The runner exited right after starting.\n` +
1366
- (up.detail ? ` ${up.detail}\n` : "") +
1367
- ` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
1368
- );
1369
- return { state: "failed", detail: up.detail };
1370
- }
1371
- process.stdout.write(
1372
- `${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1373
- ` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
1374
- );
1375
- // Paired and online still cannot run anything if the agent it dispatches to
1376
- // will not start, and every job would fail with the same opaque exit code.
1377
- const blocker = agentBlocker(agent.id);
1378
- if (blocker) {
1379
- process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
1380
- }
1381
- return { state: "running", pid: started.pid };
1382
- } catch (error) {
1383
- process.stdout.write(
1384
- `${MARK.fail()} Could not start the runner: ${error.message}\n` +
1385
- ` Retry later: ${cliInvocation()} runner start --background\n`
1386
- );
1387
- return { state: "failed", detail: error.message };
1388
- }
1389
- }
1390
-
1391
- /** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
1392
- async function offerDesktop(args, { assumeYes }) {
1393
- if (args.has("--no-desktop")) return { state: "skipped" };
1394
- if (process.platform !== "darwin") return { state: "unsupported" };
1395
- if (existsSync("/Applications/PreMan.app")) {
1396
- process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
1397
- return { state: "installed" };
1398
- }
1399
- // Default no, unlike every other step here: this one downloads a hundred-odd
1400
- // megabytes and writes to /Applications, which nobody should get by pressing
1401
- // Enter to move past a prompt.
1402
- if (
1403
- !(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
1404
- assumeYes,
1405
- defaultYes: false,
1406
- }))
1407
- ) {
1408
- process.stdout.write(
1409
- `${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
1410
- );
1411
- return { state: "skipped" };
1412
- }
1413
- try {
1414
- return await installDesktopCommand([]);
1415
- } catch (error) {
1416
- process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
1417
- return { state: "failed", detail: error.message };
1418
- }
1419
- }
1420
-
1421
- /**
1422
- * Which of GitHub, AWS and Slack this account already has.
1423
- *
1424
- * One probe per provider because each owns its own list route; a provider whose
1425
- * route errors is reported as unknown rather than missing, so a backend hiccup
1426
- * does not send someone through an install they already did.
1427
- */
1428
- export async function integrationStatus(args, apiKey) {
1429
- const probe = async (method, route, pick) => {
1430
- try {
1431
- const result = await callBackendJson(args, method, route, { token: apiKey });
1432
- if (!result.ok) return null;
1433
- return (pick(result) || []).length;
1434
- } catch {
1435
- return null;
1436
- }
1437
- };
1438
- const [github, aws, slack] = await Promise.all([
1439
- probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
1440
- probe("GET", "/aws-links", (r) => r.links || r.list),
1441
- probe("GET", "/slack/connections", (r) => r.connections || r.list),
1442
- ]);
1443
- return { github, aws, slack };
1444
- }
1445
-
1446
- /**
1447
- * Show what is connected, and offer to finish what is not.
1448
- *
1449
- * Each install lives in the customer's browser session, so every one of these
1450
- * opens a URL and polls our own API until the other side reports the connection
1451
- * exists — the terminal picks the result up on its own, whether they finished in
1452
- * the browser or in the desktop app.
1453
- */
1454
- async function connectIntegrations(args, apiKey, { assumeYes }) {
1455
- if (args.has("--no-integrations")) return;
1456
- const status = await integrationStatus(args, apiKey);
1457
- const providers = [
1458
- { key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
1459
- { key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
1460
- { key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
1461
- ];
1462
-
1463
- for (const provider of providers) {
1464
- const count = status[provider.key];
1465
- if (count === null) {
1466
- process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
1467
- } else if (count > 0) {
1468
- process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
1469
- } else {
1470
- process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
1471
- }
1472
- }
1473
-
1474
- for (const provider of providers) {
1475
- if (status[provider.key] !== 0) continue;
1476
- if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
1477
- try {
1478
- await provider.run();
1479
- } catch (error) {
1480
- // One provider's failure must not cost the customer the ones that worked.
1481
- process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
1482
- }
1483
- }
1484
- }
1485
-
1486
- /**
1487
- * Install the pre-push hook, so a push is what triggers the tests.
1488
- *
1489
- * Not a question: testing what you just changed before it ships is the product,
1490
- * the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
1491
- * still there. Asking only produced accounts that never tested anything.
1492
- */
1493
- async function setUpPushTesting(args) {
1494
- if (args.has("--no-hook")) return { state: "skipped" };
1495
- const current = (() => {
1496
- try {
1497
- return hookStatus({ probe: true });
1498
- } catch {
1499
- return null; // not a git repository
1500
- }
1501
- })();
1502
- if (!current) {
1503
- process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1504
- return { state: "unavailable" };
1505
- }
1506
- // A hook whose invocation went stale, or no longer answers, is reinstalled
1507
- // rather than reported as on: it is the case where PreMan looks connected and
1508
- // silently checks nothing.
1509
- if (current.state === "installed" && current.current && current.works) {
1510
- process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1511
- return { state: "installed" };
1512
- }
1513
-
1514
- const result = installHook(args);
1515
- if (result.action === "conflict") {
1516
- process.stdout.write(
1517
- `${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
1518
- );
1519
- return result;
1520
- }
1521
- if (result.action === "unproven") {
1522
- process.stdout.write(
1523
- `${MARK.skip()} No push testing: ${result.detail}\n` +
1524
- ` Then: ${cliInvocation()} hook install\n`
1525
- );
1526
- return result;
1527
- }
1528
- process.stdout.write(
1529
- `${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
1530
- " It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
1531
- );
1532
- return result;
1533
- }
1534
-
1535
- /**
1536
- * Everything after the link, in one pass, for whoever asks for it with `--guide`.
1537
- *
1538
- * Order follows what a new account needs to see: what PreMan found, then where to
1539
- * watch it, then who to tell, then when to run it. It is opt-in because running
1540
- * all of it unasked is what turned a finished connect into a screen of prompts,
1541
- * a dead runner and a five-minute wait on an integration nobody had asked for.
1542
- */
1543
- async function guidedFirstRun(args, agent, apiKey, serverName, { blockedHere = "" } = {}) {
1544
- const assumeYes = args.has("--yes");
1545
-
1546
- step("Endpoints");
1547
- const counts = await discoverEndpoints(args, agent, serverName, blockedHere);
1548
-
1549
- if (counts.first) {
1550
- step("First test");
1551
- await runFirstTest(args, counts.first, { assumeYes });
1552
- }
1553
-
1554
- step("Runner");
1555
- await setUpRunner(args, agent, { assumeYes });
1556
-
1557
- step("Desktop app");
1558
- await offerDesktop(args, { assumeYes });
1559
-
1560
- step("Integrations");
1561
- await connectIntegrations(args, apiKey, { assumeYes });
1562
-
1563
- step("Testing on push");
1564
- await setUpPushTesting(args);
1565
-
1566
- process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
1567
- }
36
+ import { MARK } from "./integrations.js";
37
+ import { ConnectError, EXIT_USAGE } from "./connect/errors.js";
38
+ import {
39
+ agentBlocker,
40
+ detectAgents,
41
+ findAgent,
42
+ promptAgentChoice,
43
+ } from "./connect/agents.js";
44
+ import {
45
+ WRITERS,
46
+ renderAgentSnippet,
47
+ renderAllAgentSnippets,
48
+ renderCodexToml,
49
+ verifyWrittenConfig,
50
+ } from "./connect/configs.js";
51
+ import { checkKeyAndPair, refreshStaleCredentials } from "./connect/pairing.js";
52
+ import {
53
+ autoCheckIn,
54
+ lastLine,
55
+ mcpSelfTest,
56
+ pollTicker,
57
+ selfTestBudgetMs,
58
+ waitForConnection,
59
+ } from "./connect/checkin.js";
60
+ import { captureDispatchCredential } from "./connect/dispatch.js";
61
+ import {
62
+ discoverEndpoints,
63
+ guidedFirstRun,
64
+ nextStepsBlock,
65
+ setUpPushTesting,
66
+ whatIsLeftBlock,
67
+ } from "./connect/guide.js";
1568
68
 
1569
69
  // ── Preflight ───────────────────────────────────────────────────────────
1570
70
 
@@ -1683,7 +183,7 @@ export async function connectCommand(commandArgs) {
1683
183
 
1684
184
  if (printOnly) {
1685
185
  if (!agent) agent = await promptAgentChoice(detectAgents());
1686
- const serverConfig = buildServerConfig(args);
186
+ const serverConfig = buildServerConfig(args, { codingAgent: agent.id });
1687
187
  if (agent.id === "codex") {
1688
188
  process.stdout.write(renderCodexToml(serverName, serverConfig));
1689
189
  } else {
@@ -1715,6 +215,7 @@ export async function connectCommand(commandArgs) {
1715
215
  let apiKey = resolveApiKey(args);
1716
216
 
1717
217
  let pairCode = "";
218
+ let pairingId = "";
1718
219
  if (apiKey) {
1719
220
  let pairing = await checkKeyAndPair(args, agent, apiKey);
1720
221
  if (pairing.stale) {
@@ -1725,9 +226,13 @@ export async function connectCommand(commandArgs) {
1725
226
  }
1726
227
  }
1727
228
  pairCode = pairing.pairCode;
229
+ pairingId = pairing.pairingId;
1728
230
  }
1729
231
 
1730
- const serverConfig = buildServerConfig(args, { pairCode });
232
+ const serverConfig = buildServerConfig(args, {
233
+ pairCode,
234
+ codingAgent: agent.id,
235
+ });
1731
236
  const written = WRITERS[agent.id]({ serverName, serverConfig, projectInstall });
1732
237
 
1733
238
  process.stdout.write(
@@ -1741,7 +246,7 @@ export async function connectCommand(commandArgs) {
1741
246
  // Generate hint that matches the actual install location
1742
247
  let hint = agent.snippetHint;
1743
248
  if (agent.id === "cursor" && projectInstall) {
1744
- hint = "merge into .cursor/mcp.json";
249
+ hint = `merge into .cursor/mcp.json, then run: cursor-agent mcp enable ${serverName}`;
1745
250
  } else if (agent.id === "claude_code") {
1746
251
  hint = projectInstall ? "run (with --scope project):" : "run:";
1747
252
  }
@@ -1766,6 +271,7 @@ export async function connectCommand(commandArgs) {
1766
271
  written,
1767
272
  serverConfig,
1768
273
  projectInstall,
274
+ pairingId,
1769
275
  });
1770
276
  // The agent goes back to the caller because `onboard` runs steps after this one
1771
277
  // that need to know which agent to drive, and asking twice is a question we
@@ -1793,6 +299,16 @@ export async function connectCommand(commandArgs) {
1793
299
  }
1794
300
  if (!args.has("--no-guide")) {
1795
301
  await setUpPushTesting(args);
302
+ // Discovery is back inline, alone. What made the old full pass bad was its
303
+ // tail — a runner that fails on its own and a five-minute GitHub poll, after
304
+ // the link had already succeeded. Discovery is the one step PreMan is
305
+ // useless without: with no endpoints there is nothing to test, nothing to
306
+ // schedule and nothing for the push hook just installed above to check. It
307
+ // asks nothing, never throws, and skips itself when the repo is already
308
+ // mapped or no agent can be run.
309
+ await discoverEndpoints(args, agent, serverName, {
310
+ blockedHere: checkIn.blockedHere,
311
+ });
1796
312
  process.stdout.write(whatIsLeftBlock(args));
1797
313
  }
1798
314
  await captureDispatchCredential(args, agent, apiKey, { prompt: false });
@@ -1870,7 +386,7 @@ async function establishCheckIn(
1870
386
  args,
1871
387
  agent,
1872
388
  apiKey,
1873
- { serverName, written, serverConfig, projectInstall = false }
389
+ { serverName, written, serverConfig, projectInstall = false, pairingId = "" }
1874
390
  ) {
1875
391
  const notes = [];
1876
392
  // Both set only when this directory redirects the agent: where it can be
@@ -1901,7 +417,15 @@ async function establishCheckIn(
1901
417
  `so your agent will use ${status.backend_url}.`
1902
418
  );
1903
419
  }
1904
- if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
420
+ if (
421
+ test.ok &&
422
+ status.authenticated &&
423
+ (await waitForConnection(args, apiKey, {
424
+ timeoutMs: 15000,
425
+ expectedAgent: agent.id,
426
+ pairingId,
427
+ }))
428
+ ) {
1905
429
  return done(true);
1906
430
  }
1907
431
  note(
@@ -1959,7 +483,11 @@ async function establishCheckIn(
1959
483
  if (
1960
484
  retry.ok &&
1961
485
  retry.status?.authenticated &&
1962
- (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))
486
+ (await waitForConnection(args, apiKey, {
487
+ timeoutMs: 15000,
488
+ expectedAgent: agent.id,
489
+ pairingId,
490
+ }))
1963
491
  ) {
1964
492
  return done(true);
1965
493
  }
@@ -1984,6 +512,7 @@ async function establishCheckIn(
1984
512
  launch = await autoCheckIn(args, agent, apiKey, {
1985
513
  serverName,
1986
514
  cwd: elsewhere || process.cwd(),
515
+ pairingId,
1987
516
  onPoll: ticker.tick,
1988
517
  onLaunch: ({ mode, terminal }) =>
1989
518
  say(
@@ -2021,7 +550,13 @@ async function establishCheckIn(
2021
550
  "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
2022
551
  );
2023
552
 
2024
- if (await waitForConnection(args, apiKey, { onPoll: ticker.tick })) {
553
+ if (
554
+ await waitForConnection(args, apiKey, {
555
+ expectedAgent: agent.id,
556
+ pairingId,
557
+ onPoll: ticker.tick,
558
+ })
559
+ ) {
2025
560
  ticker.end();
2026
561
  return done(true);
2027
562
  }
@@ -2036,3 +571,15 @@ async function establishCheckIn(
2036
571
  );
2037
572
  return done(false);
2038
573
  }
574
+
575
+ // ── Facade ──────────────────────────────────────────────────────────────
576
+
577
+ // Kept so every existing importer -- cli.js, the smoke suites, and the two
578
+ // that reach in by dynamic import -- sees the surface it always did.
579
+
580
+ export { agentBlocker, resolveAgentForPairing, resolveAgentToDrive, runningInside } from "./connect/agents.js";
581
+ export { autoCheckIn, commandLine, headlessCheckIn, interactiveCheckIn, lastLine, mcpSelfTest, openInNewTerminal, selfTestBudgetMs, waitForConnection } from "./connect/checkin.js";
582
+ export { claudeMcpAddArgs, cursorMcpEnableArgs, renderAgentSnippet, renderAllAgentSnippets, renderCodexToml, upsertCodexToml, verifyWrittenConfig, writeClaudeConfig, writeCodexConfig, writeCursorConfig } from "./connect/configs.js";
583
+ export { DISPATCH_HELP, dispatchCommand, extractRoutineId } from "./connect/dispatch.js";
584
+ export { discoverEndpoints, headlessDiscovery, integrationStatus, runnerPairingIsUsable } from "./connect/guide.js";
585
+ export { ConnectError, EXIT_USAGE } from "./connect/errors.js";