premanmcp 0.10.6 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Getting the first PreMan call to happen, so nobody has to be told to restart.
3
+ *
4
+ * Three routes to the same check-in, cheapest first: run the MCP server
5
+ * ourselves over stdio, open the user's agent, or wait. The terminal-opening
6
+ * machinery is here because "we ran your agent for you" is only true if the
7
+ * window it lands in is the one they go on to use.
8
+ */
9
+
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { existsSync } from "node:fs";
12
+ import { callBackendJson, onPath } from "../shared.js";
13
+
14
+ import { findAgent } from "./agents.js";
15
+ import { shellQuote } from "./configs.js";
16
+
17
+ const MCP_CONNECT_SOURCES = new Set([
18
+ "mcp",
19
+ "preman_status",
20
+ "premanmcp",
21
+ "agent",
22
+ "mcp_call_tool",
23
+ ]);
24
+
25
+ export async function waitForConnection(
26
+ args,
27
+ apiKey,
28
+ {
29
+ intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
30
+ timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
31
+ expectedAgent = "",
32
+ pairingId = "",
33
+ stopWhen = null,
34
+ // Called once per unsuccessful poll, so a wait measured in minutes can show
35
+ // that it is still a wait rather than a hang.
36
+ onPoll = null,
37
+ } = {}
38
+ ) {
39
+ const deadline = Date.now() + timeoutMs;
40
+ let interrupted = false;
41
+ const onInterrupt = () => {
42
+ interrupted = true;
43
+ };
44
+ process.on("SIGINT", onInterrupt);
45
+
46
+ try {
47
+ while (Date.now() < deadline && !interrupted) {
48
+ const status = await callBackendJson(
49
+ args,
50
+ "GET",
51
+ pairingId
52
+ ? `/workbench/coding-agent/pairings/${encodeURIComponent(pairingId)}`
53
+ : "/workbench/coding-agent",
54
+ { token: apiKey }
55
+ );
56
+ const pairingStatus = pairingId ? String(status.status || "") : "";
57
+ const connected = pairingId
58
+ ? pairingStatus === "connected"
59
+ : Boolean(status.connected);
60
+ const connectedAgent = pairingId ? status.connected_agent : status.agent;
61
+ const backendAgent = findAgent(connectedAgent)?.id || "";
62
+ const verifiedBy = String(status.verified_by || "").trim().toLowerCase();
63
+ if (
64
+ status.ok &&
65
+ connected &&
66
+ (!expectedAgent ||
67
+ (backendAgent === expectedAgent && MCP_CONNECT_SOURCES.has(verifiedBy)))
68
+ ) {
69
+ return true;
70
+ }
71
+ if (pairingId && (pairingStatus === "expired" || status.status_code === 404)) {
72
+ return false;
73
+ }
74
+ if (stopWhen && stopWhen()) return false;
75
+ if (onPoll) onPoll();
76
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
77
+ }
78
+ } finally {
79
+ process.off("SIGINT", onInterrupt);
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /** A dot per poll, and the newline that closes the run of them. */
85
+ export function pollTicker() {
86
+ let dots = 0;
87
+ return {
88
+ tick() {
89
+ dots += 1;
90
+ process.stdout.write(".");
91
+ },
92
+ end() {
93
+ if (!dots) return;
94
+ dots = 0;
95
+ process.stdout.write("\n");
96
+ },
97
+ };
98
+ }
99
+
100
+ // ── Auto check-in ───────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * How to ask each agent to make one PreMan call without opening its UI.
104
+ *
105
+ * The link is established by the agent's first call, so anything that reaches
106
+ * `preman_status` finishes the connect. Claude Code needs the server on its
107
+ * allow-list because print mode refuses un-allowed MCP tools rather than
108
+ * prompting.
109
+ */
110
+ export function headlessCheckIn(agent, serverName) {
111
+ const prompt = checkInPrompt(serverName);
112
+ if (agent.id === "cursor") {
113
+ // Without --approve-mcps, cursor-agent starts with only built-in tools even
114
+ // when mcp.json lists PreMan — the first-time connect check-in then reports
115
+ // that the server is not connected.
116
+ return { bin: "cursor-agent", args: ["--approve-mcps", "-p", prompt] };
117
+ }
118
+ if (agent.id === "claude_code") {
119
+ return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
120
+ }
121
+ if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
122
+ return null;
123
+ }
124
+
125
+ function checkInPrompt(serverName) {
126
+ return `Call the ${serverName} MCP tool preman_status and report the result.`;
127
+ }
128
+
129
+ /**
130
+ * How to start each agent as the session the user actually works in.
131
+ *
132
+ * Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
133
+ * the agent anybody goes on to use, and its failures are invisible because
134
+ * nobody is looking at it. The interactive form is the same prompt without that
135
+ * flag — it needs a terminal of its own, which is what openInNewTerminal is for.
136
+ */
137
+ export function interactiveCheckIn(agent, serverName) {
138
+ const prompt = checkInPrompt(serverName);
139
+ if (agent.id === "cursor") return { bin: "cursor-agent", args: ["--approve-mcps", prompt] };
140
+ if (agent.id === "claude_code") {
141
+ return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
142
+ }
143
+ if (agent.id === "codex") return { bin: "codex", args: [prompt] };
144
+ return null;
145
+ }
146
+
147
+ /** A command line for a spec, quoted for the shell the terminal will start. */
148
+ export function commandLine(spec) {
149
+ return [spec.bin, ...spec.args].map(shellQuote).join(" ");
150
+ }
151
+
152
+ /** Escape a shell line into an AppleScript string literal. */
153
+ function appleScriptString(value) {
154
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
155
+ }
156
+
157
+ /**
158
+ * The terminal emulator to open a window in, or null when we know of none.
159
+ *
160
+ * `sync` marks the launchers that hand the work to an app and return — those
161
+ * report their own failure through an exit code, which is worth waiting for.
162
+ * The rest *are* the window, so they are spawned detached and outlive us.
163
+ */
164
+ function terminalLauncher(line) {
165
+ if (process.platform === "darwin") {
166
+ const iterm = existsSync("/Applications/iTerm.app");
167
+ const script = iterm
168
+ ? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
169
+ : `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
170
+ return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
171
+ }
172
+
173
+ if (process.platform === "win32") {
174
+ return {
175
+ bin: "cmd.exe",
176
+ args: ["/c", "start", "cmd.exe", "/k", line],
177
+ label: "Command Prompt",
178
+ sync: true,
179
+ };
180
+ }
181
+
182
+ // Keep the shell alive afterwards: an agent that exits immediately would
183
+ // otherwise take its own error message off the screen with it.
184
+ const body = `${line}; exec ${process.env.SHELL || "sh"}`;
185
+ const candidates = [
186
+ { bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
187
+ { bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
188
+ { bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
189
+ { bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
190
+ ];
191
+ return candidates.find((candidate) => onPath(candidate.bin)) || null;
192
+ }
193
+
194
+ /**
195
+ * Open `command` in a new terminal window, best effort.
196
+ *
197
+ * Same contract as openUrl: never throws, and declines rather than guessing when
198
+ * nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
199
+ * stdout for CI, piped output and test runs. Callers fall back to a headless run.
200
+ */
201
+ export function openInNewTerminal(command, options = {}) {
202
+ const cwd = options?.cwd || process.cwd();
203
+ const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
204
+ if (optOut && !["0", "false", "no"].includes(optOut)) {
205
+ return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
206
+ }
207
+ if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
208
+
209
+ const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
210
+ if (!launcher) return { opened: false, reason: "no terminal emulator found" };
211
+
212
+ try {
213
+ if (launcher.sync) {
214
+ const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
215
+ if (done.error) return { opened: false, reason: done.error.message };
216
+ if (done.status !== 0) {
217
+ return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
218
+ }
219
+ } else {
220
+ const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
221
+ child.unref();
222
+ }
223
+ return { opened: true, terminal: launcher.label };
224
+ } catch (error) {
225
+ return { opened: false, reason: error.message };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Finish the link ourselves instead of asking the user to go restart their agent.
231
+ *
232
+ * The config on disk is already correct at this point; all that is missing is
233
+ * one call from the agent, and telling someone to make it from another terminal
234
+ * is a dead end in the one terminal they are sitting in.
235
+ *
236
+ * So start the agent. Interactively, in a window of its own, because that is the
237
+ * session the user keeps: a print-mode run answers once, exits, and leaves them
238
+ * exactly where they started. Only when no window can be opened — CI, SSH, a
239
+ * machine with no terminal emulator — does this fall back to the headless run,
240
+ * which still finishes the link even though nobody sees it happen.
241
+ *
242
+ * `cwd` is where the agent starts, and it is load-bearing rather than tidiness:
243
+ * the MCP server reads its repo config out of the working directory, so starting
244
+ * the agent elsewhere is how a caller escapes a directory that would otherwise
245
+ * redirect it to a backend this key was never issued for.
246
+ *
247
+ * Returns `ran: false` when the agent's binary is absent or will not start, and
248
+ * the caller falls back to the printed instructions.
249
+ */
250
+ export async function autoCheckIn(
251
+ args,
252
+ agent,
253
+ apiKey,
254
+ {
255
+ // Never outlast the connect's own wait budget: this phase is part of it, not
256
+ // an extra one bolted on the front.
257
+ timeoutMs = Math.min(
258
+ Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
259
+ Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
260
+ ),
261
+ serverName = "preman",
262
+ intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
263
+ cwd = process.cwd(),
264
+ pairingId = "",
265
+ onLaunch = () => {},
266
+ onPoll = null,
267
+ } = {}
268
+ ) {
269
+ const session = interactiveCheckIn(agent, serverName);
270
+ if (session && onPath(session.bin)) {
271
+ const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd });
272
+ if (opened) {
273
+ onLaunch({ mode: "interactive", bin: session.bin, terminal });
274
+ // Detached, so there is no exit to watch for and no output to quote: the
275
+ // agent is in front of the user now, and the deadline is all that bounds
276
+ // this. Whoever is watching the window can Ctrl+C out of the wait.
277
+ const connected = await waitForConnection(args, apiKey, {
278
+ intervalMs,
279
+ timeoutMs,
280
+ expectedAgent: agent.id,
281
+ pairingId,
282
+ onPoll,
283
+ });
284
+ return { ran: true, connected, command: session.bin, interactive: true, terminal };
285
+ }
286
+ }
287
+
288
+ const spec = headlessCheckIn(agent, serverName);
289
+ if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
290
+ if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
291
+
292
+ let child;
293
+ try {
294
+ // Piped rather than ignored: an agent that runs and does not check in used to
295
+ // report exactly that and nothing else, which is the least useful sentence
296
+ // available. Its own last words usually name the cause.
297
+ child = spawn(spec.bin, spec.args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
298
+ } catch (error) {
299
+ return { ran: false, connected: false, reason: error.message };
300
+ }
301
+ onLaunch({ mode: "headless", bin: spec.bin });
302
+
303
+ let spawnError = null;
304
+ let exitedAt = 0;
305
+ let output = "";
306
+ const absorb = (chunk) => {
307
+ output = `${output}${chunk}`.slice(-4000);
308
+ };
309
+ child.stdout?.setEncoding("utf8");
310
+ child.stderr?.setEncoding("utf8");
311
+ child.stdout?.on("data", absorb);
312
+ child.stderr?.on("data", absorb);
313
+ child.on("error", (error) => {
314
+ spawnError = error;
315
+ exitedAt = exitedAt || Date.now();
316
+ });
317
+ child.on("exit", () => {
318
+ exitedAt = exitedAt || Date.now();
319
+ });
320
+
321
+ // The check-in can land moments after the agent's own process ends, so keep
322
+ // polling briefly past its exit rather than declaring failure at the edge.
323
+ const grace = intervalMs * 2;
324
+ try {
325
+ const connected = await waitForConnection(args, apiKey, {
326
+ intervalMs,
327
+ timeoutMs,
328
+ expectedAgent: agent.id,
329
+ pairingId,
330
+ onPoll,
331
+ stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
332
+ });
333
+ if (spawnError && !connected) {
334
+ return { ran: false, connected: false, reason: spawnError.message, output };
335
+ }
336
+ return { ran: true, connected, command: spec.bin, output };
337
+ } finally {
338
+ if (child.exitCode === null && child.signalCode === null) child.kill();
339
+ }
340
+ }
341
+
342
+ /** The last non-empty line of an agent's output, for a one-line diagnosis. */
343
+ export function lastLine(output, cap = 200) {
344
+ const lines = String(output || "")
345
+ .split("\n")
346
+ .map((line) => line.trim())
347
+ .filter(Boolean);
348
+ return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
349
+ }
350
+
351
+ // ── MCP self-test ───────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * How long to give the self-test, and whether to run it at all.
355
+ *
356
+ * `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
357
+ * run wants: the launcher is `npm exec premanmcp@latest`, so every case that
358
+ * reaches this would otherwise go to the registry.
359
+ */
360
+ export function selfTestBudgetMs() {
361
+ const raw = process.env.PREMAN_SELFTEST_MS;
362
+ if (raw === undefined || raw === "") return 60000;
363
+ const parsed = Number(raw);
364
+ return Number.isFinite(parsed) ? parsed : 60000;
365
+ }
366
+
367
+ /**
368
+ * Run the config we just wrote, exactly as the agent will, and call one tool.
369
+ *
370
+ * This is both the fastest way to finish the link and the only check that proves
371
+ * the whole chain — launcher, package, key, backend — rather than proving the
372
+ * file is on disk. It matters because the failure it catches is invisible
373
+ * otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
374
+ * redirect the server to a backend the key was never issued for, and every
375
+ * symptom of that points at the key.
376
+ *
377
+ * Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
378
+ */
379
+ export async function mcpSelfTest(
380
+ serverConfig,
381
+ { timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
382
+ ) {
383
+ if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
384
+ let child;
385
+ try {
386
+ child = spawn(serverConfig.command, serverConfig.args, {
387
+ cwd,
388
+ env: { ...process.env, ...serverConfig.env },
389
+ stdio: ["pipe", "pipe", "pipe"],
390
+ });
391
+ } catch (error) {
392
+ return { ok: false, reason: error.message };
393
+ }
394
+
395
+ return new Promise((resolve) => {
396
+ let stdout = "";
397
+ let stderr = "";
398
+ let settled = false;
399
+ let stdinFailureTimer = null;
400
+
401
+ const finish = (result) => {
402
+ if (settled) return;
403
+ settled = true;
404
+ clearTimeout(timer);
405
+ if (stdinFailureTimer) clearTimeout(stdinFailureTimer);
406
+ if (child.exitCode === null && child.signalCode === null) child.kill();
407
+ resolve(result);
408
+ };
409
+
410
+ const timer = setTimeout(
411
+ () => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
412
+ timeoutMs
413
+ );
414
+
415
+ const failStdin = (error) => {
416
+ if (settled || stdinFailureTimer) return;
417
+ const reason = error?.message || "the MCP server closed stdin";
418
+ // A launcher that exits immediately usually closes its pipe before Node
419
+ // emits the child exit. Give that more useful diagnosis one event-loop
420
+ // turn to win; a live process with closed stdin still fails promptly.
421
+ stdinFailureTimer = setTimeout(() => finish({ ok: false, reason }), 25);
422
+ };
423
+ // Stream write failures are emitted asynchronously; try/catch around
424
+ // `write()` cannot prevent an EPIPE from crashing the CLI.
425
+ child.stdin.on("error", failStdin);
426
+
427
+ const send = (message) => {
428
+ if (settled) return;
429
+ if (child.stdin.destroyed || child.stdin.writableEnded) {
430
+ failStdin(new Error("the MCP server closed stdin"));
431
+ return;
432
+ }
433
+ try {
434
+ child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
435
+ if (error) failStdin(error);
436
+ });
437
+ } catch (error) {
438
+ failStdin(error);
439
+ }
440
+ };
441
+
442
+ child.on("error", (error) => finish({ ok: false, reason: error.message }));
443
+ child.on("exit", (code) =>
444
+ finish({
445
+ ok: false,
446
+ reason: `the MCP server exited with code ${code}`,
447
+ stderr: lastLine(stderr),
448
+ })
449
+ );
450
+
451
+ child.stdout.setEncoding("utf8");
452
+ child.stderr.setEncoding("utf8");
453
+ child.stderr.on("data", (chunk) => {
454
+ stderr = `${stderr}${chunk}`.slice(-4000);
455
+ });
456
+ child.stdout.on("data", (chunk) => {
457
+ stdout += chunk;
458
+ let newline = stdout.indexOf("\n");
459
+ while (newline !== -1) {
460
+ const line = stdout.slice(0, newline).trim();
461
+ stdout = stdout.slice(newline + 1);
462
+ newline = stdout.indexOf("\n");
463
+ if (!line.startsWith("{")) continue;
464
+ let message;
465
+ try {
466
+ message = JSON.parse(line);
467
+ } catch {
468
+ continue;
469
+ }
470
+ if (message.id === 1) {
471
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
472
+ send({
473
+ jsonrpc: "2.0",
474
+ id: 2,
475
+ method: "tools/call",
476
+ params: { name: "preman_status", arguments: {} },
477
+ });
478
+ continue;
479
+ }
480
+ if (message.id !== 2) continue;
481
+ const text = message.result?.content?.[0]?.text;
482
+ let status = {};
483
+ try {
484
+ status = text ? JSON.parse(text) : {};
485
+ } catch {
486
+ status = { raw: text };
487
+ }
488
+ finish({ ok: !message.error, status, reason: message.error?.message || "" });
489
+ }
490
+ });
491
+
492
+ send({
493
+ jsonrpc: "2.0",
494
+ id: 1,
495
+ method: "initialize",
496
+ params: {
497
+ protocolVersion: "2024-11-05",
498
+ capabilities: {},
499
+ clientInfo: { name: "preman-connect-selftest", version: "1" },
500
+ },
501
+ });
502
+ });
503
+ }