premanmcp 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/bin/connect.js +334 -33
- package/bin/runner.js +40 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -48,13 +48,21 @@ to go restart anything, in cheapest-first order:
|
|
|
48
48
|
1. **Self-test.** It starts the MCP server exactly as your agent will and calls
|
|
49
49
|
`preman_status` over stdio. That both completes the link and proves the whole chain —
|
|
50
50
|
launcher, package, key, backend. `--no-self-test` turns it off.
|
|
51
|
-
2. **
|
|
52
|
-
|
|
51
|
+
2. **Agent run**, which also proves your agent can load what was written. It opens your
|
|
52
|
+
agent interactively in a new terminal window — the session you go on to use — and falls
|
|
53
|
+
back to a headless run (`claude -p`, `cursor-agent -p`, `codex exec`) where no window can
|
|
54
|
+
be opened, such as CI or SSH. `--no-auto-checkin` turns it off, `PREMAN_NO_TERMINAL=1`
|
|
55
|
+
keeps it headless.
|
|
53
56
|
3. **Wait**, if neither is possible: restart your agent and it links on its first call.
|
|
54
57
|
|
|
55
58
|
A self-test that answers from an unexpected backend is reported with the file that
|
|
56
59
|
redirected it — a repo-local `preman-mcp.config.json` with `"PREMAN_CONFIG_OVERRIDE": true`
|
|
57
|
-
wins over the MCP config env, and otherwise only fills in what the env leaves unset.
|
|
60
|
+
wins over the MCP config env, and otherwise only fills in what the env leaves unset. When
|
|
61
|
+
that file overrides `PREMAN_BACKEND`, the config `connect` wrote is still correct: the
|
|
62
|
+
server reads that file only from the directory it starts in, so `connect` names the file
|
|
63
|
+
and then retries from your home directory, where the override cannot reach it — self-test
|
|
64
|
+
first, then your agent. Agents you start in the overriding directory keep using its
|
|
65
|
+
backend, which is the point of the file.
|
|
58
66
|
|
|
59
67
|
Once linked, `connect` finishes onboarding without handing you homework:
|
|
60
68
|
|
package/bin/connect.js
CHANGED
|
@@ -509,6 +509,9 @@ export async function waitForConnection(
|
|
|
509
509
|
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
510
510
|
timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
|
|
511
511
|
stopWhen = null,
|
|
512
|
+
// Called once per unsuccessful poll, so a wait measured in minutes can show
|
|
513
|
+
// that it is still a wait rather than a hang.
|
|
514
|
+
onPoll = null,
|
|
512
515
|
} = {}
|
|
513
516
|
) {
|
|
514
517
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -525,6 +528,7 @@ export async function waitForConnection(
|
|
|
525
528
|
});
|
|
526
529
|
if (status.ok && status.connected) return true;
|
|
527
530
|
if (stopWhen && stopWhen()) return false;
|
|
531
|
+
if (onPoll) onPoll();
|
|
528
532
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
529
533
|
}
|
|
530
534
|
} finally {
|
|
@@ -533,6 +537,22 @@ export async function waitForConnection(
|
|
|
533
537
|
return false;
|
|
534
538
|
}
|
|
535
539
|
|
|
540
|
+
/** A dot per poll, and the newline that closes the run of them. */
|
|
541
|
+
function pollTicker() {
|
|
542
|
+
let dots = 0;
|
|
543
|
+
return {
|
|
544
|
+
tick() {
|
|
545
|
+
dots += 1;
|
|
546
|
+
process.stdout.write(".");
|
|
547
|
+
},
|
|
548
|
+
end() {
|
|
549
|
+
if (!dots) return;
|
|
550
|
+
dots = 0;
|
|
551
|
+
process.stdout.write("\n");
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
536
556
|
// ── Auto check-in ───────────────────────────────────────────────────────
|
|
537
557
|
|
|
538
558
|
/**
|
|
@@ -544,7 +564,7 @@ export async function waitForConnection(
|
|
|
544
564
|
* prompting.
|
|
545
565
|
*/
|
|
546
566
|
export function headlessCheckIn(agent, serverName) {
|
|
547
|
-
const prompt =
|
|
567
|
+
const prompt = checkInPrompt(serverName);
|
|
548
568
|
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
549
569
|
if (agent.id === "claude_code") {
|
|
550
570
|
return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
|
|
@@ -553,13 +573,127 @@ export function headlessCheckIn(agent, serverName) {
|
|
|
553
573
|
return null;
|
|
554
574
|
}
|
|
555
575
|
|
|
576
|
+
function checkInPrompt(serverName) {
|
|
577
|
+
return `Call the ${serverName} MCP tool preman_status and report the result.`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* How to start each agent as the session the user actually works in.
|
|
582
|
+
*
|
|
583
|
+
* Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
|
|
584
|
+
* the agent anybody goes on to use, and its failures are invisible because
|
|
585
|
+
* nobody is looking at it. The interactive form is the same prompt without that
|
|
586
|
+
* flag — it needs a terminal of its own, which is what openInNewTerminal is for.
|
|
587
|
+
*/
|
|
588
|
+
export function interactiveCheckIn(agent, serverName) {
|
|
589
|
+
const prompt = checkInPrompt(serverName);
|
|
590
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: [prompt] };
|
|
591
|
+
if (agent.id === "claude_code") {
|
|
592
|
+
return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
|
|
593
|
+
}
|
|
594
|
+
if (agent.id === "codex") return { bin: "codex", args: [prompt] };
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** A command line for a spec, quoted for the shell the terminal will start. */
|
|
599
|
+
export function commandLine(spec) {
|
|
600
|
+
return [spec.bin, ...spec.args].map(shellQuote).join(" ");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Escape a shell line into an AppleScript string literal. */
|
|
604
|
+
function appleScriptString(value) {
|
|
605
|
+
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The terminal emulator to open a window in, or null when we know of none.
|
|
610
|
+
*
|
|
611
|
+
* `sync` marks the launchers that hand the work to an app and return — those
|
|
612
|
+
* report their own failure through an exit code, which is worth waiting for.
|
|
613
|
+
* The rest *are* the window, so they are spawned detached and outlive us.
|
|
614
|
+
*/
|
|
615
|
+
function terminalLauncher(line) {
|
|
616
|
+
if (process.platform === "darwin") {
|
|
617
|
+
const iterm = existsSync("/Applications/iTerm.app");
|
|
618
|
+
const script = iterm
|
|
619
|
+
? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
|
|
620
|
+
: `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
|
|
621
|
+
return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (process.platform === "win32") {
|
|
625
|
+
return {
|
|
626
|
+
bin: "cmd.exe",
|
|
627
|
+
args: ["/c", "start", "cmd.exe", "/k", line],
|
|
628
|
+
label: "Command Prompt",
|
|
629
|
+
sync: true,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Keep the shell alive afterwards: an agent that exits immediately would
|
|
634
|
+
// otherwise take its own error message off the screen with it.
|
|
635
|
+
const body = `${line}; exec ${process.env.SHELL || "sh"}`;
|
|
636
|
+
const candidates = [
|
|
637
|
+
{ bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
|
|
638
|
+
{ bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
|
|
639
|
+
{ bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
|
|
640
|
+
{ bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
|
|
641
|
+
];
|
|
642
|
+
return candidates.find((candidate) => onPath(candidate.bin)) || null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Open `command` in a new terminal window, best effort.
|
|
647
|
+
*
|
|
648
|
+
* Same contract as openUrl: never throws, and declines rather than guessing when
|
|
649
|
+
* nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
|
|
650
|
+
* stdout for CI, piped output and test runs. Callers fall back to a headless run.
|
|
651
|
+
*/
|
|
652
|
+
export function openInNewTerminal(command, options = {}) {
|
|
653
|
+
const cwd = options?.cwd || process.cwd();
|
|
654
|
+
const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
|
|
655
|
+
if (optOut && !["0", "false", "no"].includes(optOut)) {
|
|
656
|
+
return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
|
|
657
|
+
}
|
|
658
|
+
if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
|
|
659
|
+
|
|
660
|
+
const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
|
|
661
|
+
if (!launcher) return { opened: false, reason: "no terminal emulator found" };
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
if (launcher.sync) {
|
|
665
|
+
const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
|
|
666
|
+
if (done.error) return { opened: false, reason: done.error.message };
|
|
667
|
+
if (done.status !== 0) {
|
|
668
|
+
return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
|
|
672
|
+
child.unref();
|
|
673
|
+
}
|
|
674
|
+
return { opened: true, terminal: launcher.label };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
return { opened: false, reason: error.message };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
556
680
|
/**
|
|
557
681
|
* Finish the link ourselves instead of asking the user to go restart their agent.
|
|
558
682
|
*
|
|
559
683
|
* The config on disk is already correct at this point; all that is missing is
|
|
560
684
|
* one call from the agent, and telling someone to make it from another terminal
|
|
561
|
-
* is a dead end in the one terminal they are sitting in.
|
|
562
|
-
*
|
|
685
|
+
* is a dead end in the one terminal they are sitting in.
|
|
686
|
+
*
|
|
687
|
+
* So start the agent. Interactively, in a window of its own, because that is the
|
|
688
|
+
* session the user keeps: a print-mode run answers once, exits, and leaves them
|
|
689
|
+
* exactly where they started. Only when no window can be opened — CI, SSH, a
|
|
690
|
+
* machine with no terminal emulator — does this fall back to the headless run,
|
|
691
|
+
* which still finishes the link even though nobody sees it happen.
|
|
692
|
+
*
|
|
693
|
+
* `cwd` is where the agent starts, and it is load-bearing rather than tidiness:
|
|
694
|
+
* the MCP server reads its repo config out of the working directory, so starting
|
|
695
|
+
* the agent elsewhere is how a caller escapes a directory that would otherwise
|
|
696
|
+
* redirect it to a backend this key was never issued for.
|
|
563
697
|
*
|
|
564
698
|
* Returns `ran: false` when the agent's binary is absent or will not start, and
|
|
565
699
|
* the caller falls back to the printed instructions.
|
|
@@ -577,8 +711,24 @@ export async function autoCheckIn(
|
|
|
577
711
|
),
|
|
578
712
|
serverName = "preman",
|
|
579
713
|
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
714
|
+
cwd = process.cwd(),
|
|
715
|
+
onLaunch = () => {},
|
|
716
|
+
onPoll = null,
|
|
580
717
|
} = {}
|
|
581
718
|
) {
|
|
719
|
+
const session = interactiveCheckIn(agent, serverName);
|
|
720
|
+
if (session && onPath(session.bin)) {
|
|
721
|
+
const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd });
|
|
722
|
+
if (opened) {
|
|
723
|
+
onLaunch({ mode: "interactive", bin: session.bin, terminal });
|
|
724
|
+
// Detached, so there is no exit to watch for and no output to quote: the
|
|
725
|
+
// agent is in front of the user now, and the deadline is all that bounds
|
|
726
|
+
// this. Whoever is watching the window can Ctrl+C out of the wait.
|
|
727
|
+
const connected = await waitForConnection(args, apiKey, { intervalMs, timeoutMs, onPoll });
|
|
728
|
+
return { ran: true, connected, command: session.bin, interactive: true, terminal };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
582
732
|
const spec = headlessCheckIn(agent, serverName);
|
|
583
733
|
if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
|
|
584
734
|
if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
|
|
@@ -588,10 +738,11 @@ export async function autoCheckIn(
|
|
|
588
738
|
// Piped rather than ignored: an agent that runs and does not check in used to
|
|
589
739
|
// report exactly that and nothing else, which is the least useful sentence
|
|
590
740
|
// available. Its own last words usually name the cause.
|
|
591
|
-
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
741
|
+
child = spawn(spec.bin, spec.args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
592
742
|
} catch (error) {
|
|
593
743
|
return { ran: false, connected: false, reason: error.message };
|
|
594
744
|
}
|
|
745
|
+
onLaunch({ mode: "headless", bin: spec.bin });
|
|
595
746
|
|
|
596
747
|
let spawnError = null;
|
|
597
748
|
let exitedAt = 0;
|
|
@@ -618,6 +769,7 @@ export async function autoCheckIn(
|
|
|
618
769
|
const connected = await waitForConnection(args, apiKey, {
|
|
619
770
|
intervalMs,
|
|
620
771
|
timeoutMs,
|
|
772
|
+
onPoll,
|
|
621
773
|
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
622
774
|
});
|
|
623
775
|
if (spawnError && !connected) {
|
|
@@ -1509,7 +1661,14 @@ export async function connectCommand(commandArgs) {
|
|
|
1509
1661
|
return;
|
|
1510
1662
|
}
|
|
1511
1663
|
|
|
1512
|
-
if (
|
|
1664
|
+
if (
|
|
1665
|
+
!(await establishCheckIn(args, agent, apiKey, {
|
|
1666
|
+
serverName,
|
|
1667
|
+
written,
|
|
1668
|
+
serverConfig,
|
|
1669
|
+
projectInstall,
|
|
1670
|
+
}))
|
|
1671
|
+
) {
|
|
1513
1672
|
// Still honour an explicitly-passed credential, but do not open a new prompt
|
|
1514
1673
|
// on top of a connect that just told the user something went wrong.
|
|
1515
1674
|
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
@@ -1523,20 +1682,90 @@ export async function connectCommand(commandArgs) {
|
|
|
1523
1682
|
await captureDispatchCredential(args, agent, apiKey);
|
|
1524
1683
|
}
|
|
1525
1684
|
|
|
1685
|
+
/**
|
|
1686
|
+
* A repo config that sends every agent started here to a backend the key we just
|
|
1687
|
+
* wrote was not issued for, or null.
|
|
1688
|
+
*
|
|
1689
|
+
* Only an override can do this: without the flag the MCP config's env wins, and
|
|
1690
|
+
* a self-test that disagrees with it is a transient, not a redirect. It matters
|
|
1691
|
+
* because it is the one failure waiting cannot fix — the agent starts in the
|
|
1692
|
+
* same directory, reads the same file, and checks in somewhere else forever.
|
|
1693
|
+
*/
|
|
1694
|
+
function backendRedirect(repo, status, serverConfig) {
|
|
1695
|
+
if (!repo?.override || !(repo.applied || []).includes("PREMAN_BACKEND")) return null;
|
|
1696
|
+
const trim = (url) => String(url || "").replace(/\/+$/, "");
|
|
1697
|
+
const actual = trim(status.backend_url);
|
|
1698
|
+
const wanted = trim(serverConfig?.env?.PREMAN_BACKEND);
|
|
1699
|
+
if (!actual || !wanted || actual === wanted) return null;
|
|
1700
|
+
return { path: repo.path, actual, wanted };
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Somewhere to start the agent when this directory would redirect its backend.
|
|
1705
|
+
*
|
|
1706
|
+
* The server reads its repo config from the working directory and nowhere else —
|
|
1707
|
+
* no walk up to the parents — so any directory without one of those two files is
|
|
1708
|
+
* already out of the override's reach. Home first, because an agent started there
|
|
1709
|
+
* is in a place the user recognises; a temp dir only if home is itself a repo
|
|
1710
|
+
* carrying a config.
|
|
1711
|
+
*
|
|
1712
|
+
* Null means every candidate was covered, and the caller has nothing to offer but
|
|
1713
|
+
* the instructions.
|
|
1714
|
+
*/
|
|
1715
|
+
export function neutralCwd(candidates = [os.homedir(), os.tmpdir()]) {
|
|
1716
|
+
return (
|
|
1717
|
+
candidates.find(
|
|
1718
|
+
(dir) =>
|
|
1719
|
+
dir &&
|
|
1720
|
+
existsSync(dir) &&
|
|
1721
|
+
!existsSync(path.join(dir, ".cursor", "preman-mcp.config.json")) &&
|
|
1722
|
+
!existsSync(path.join(dir, "preman-mcp.config.json"))
|
|
1723
|
+
) || null
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1526
1727
|
/**
|
|
1527
1728
|
* Finish the link here, by whatever means work, in cheapest-first order.
|
|
1528
1729
|
*
|
|
1529
1730
|
* 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
|
|
1530
1731
|
* seconds, and it proves the launcher/key/backend chain the agent will use.
|
|
1531
|
-
* 2. Failing that,
|
|
1532
|
-
*
|
|
1732
|
+
* 2. Failing that, start the agent — in its own window when there is a desktop
|
|
1733
|
+
* to open one on, headlessly otherwise — which also proves the agent can load
|
|
1734
|
+
* the config we wrote.
|
|
1533
1735
|
* 3. Failing that, ask them to restart it and wait, which is all this ever did.
|
|
1534
1736
|
*
|
|
1737
|
+
* A directory whose repo config redirects the backend moves steps 1 and 2 out of
|
|
1738
|
+
* that directory rather than giving up in it: the config on disk is right, and
|
|
1739
|
+
* where the server and the agent stand is the only thing that has to change. Not
|
|
1740
|
+
* step 2 under `--project`, though — that config exists in that directory alone,
|
|
1741
|
+
* so an agent sent anywhere else would have no server to call.
|
|
1742
|
+
*
|
|
1743
|
+
* Every diagnosis is printed the moment it is known rather than saved for the
|
|
1744
|
+
* end: the steps below are measured in minutes, and a note that explains what is
|
|
1745
|
+
* happening is worth nothing after it has stopped happening.
|
|
1746
|
+
*
|
|
1535
1747
|
* Returns whether the check-in landed, and prints the troubleshooting block
|
|
1536
1748
|
* itself when it did not.
|
|
1537
1749
|
*/
|
|
1538
|
-
async function establishCheckIn(
|
|
1750
|
+
async function establishCheckIn(
|
|
1751
|
+
args,
|
|
1752
|
+
agent,
|
|
1753
|
+
apiKey,
|
|
1754
|
+
{ serverName, written, serverConfig, projectInstall = false }
|
|
1755
|
+
) {
|
|
1539
1756
|
const notes = [];
|
|
1757
|
+
// Set only when this directory would redirect the agent, and then it is where
|
|
1758
|
+
// the agent gets started instead.
|
|
1759
|
+
let elsewhere = null;
|
|
1760
|
+
const ticker = pollTicker();
|
|
1761
|
+
const say = (text) => {
|
|
1762
|
+
ticker.end();
|
|
1763
|
+
process.stdout.write(text);
|
|
1764
|
+
};
|
|
1765
|
+
const note = (text) => {
|
|
1766
|
+
notes.push(text);
|
|
1767
|
+
say(`Note: ${text}\n`);
|
|
1768
|
+
};
|
|
1540
1769
|
|
|
1541
1770
|
if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
|
|
1542
1771
|
process.stdout.write("\nChecking the connection…\n");
|
|
@@ -1546,61 +1775,133 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
|
|
|
1546
1775
|
if (repo?.override && repo.applied?.length) {
|
|
1547
1776
|
// The one failure that reads as a bad key: a working server talking to a
|
|
1548
1777
|
// backend nobody chose. Name the file before it costs anyone an hour.
|
|
1549
|
-
|
|
1778
|
+
note(
|
|
1550
1779
|
`${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
|
|
1551
1780
|
`so your agent will use ${status.backend_url}.`
|
|
1552
1781
|
);
|
|
1553
1782
|
}
|
|
1554
1783
|
if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
|
|
1555
|
-
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1556
1784
|
return true;
|
|
1557
1785
|
}
|
|
1558
|
-
|
|
1786
|
+
note(
|
|
1559
1787
|
test.ok
|
|
1560
1788
|
? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
|
|
1561
1789
|
: `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
|
|
1562
1790
|
);
|
|
1791
|
+
|
|
1792
|
+
const redirect = backendRedirect(repo, status, serverConfig);
|
|
1793
|
+
if (redirect) {
|
|
1794
|
+
const flag = `--agent ${agent.id.replace("_", "-")}`;
|
|
1795
|
+
const notLinked = (why) =>
|
|
1796
|
+
say(
|
|
1797
|
+
`\nNot linked: ${redirect.path} forces PREMAN_BACKEND=${redirect.actual} for anything\n` +
|
|
1798
|
+
`started in this directory, so ${agent.label} cannot check in against ${redirect.wanted}.\n` +
|
|
1799
|
+
` - ${why}\n` +
|
|
1800
|
+
` - Or connect to that backend: ${cliInvocation()} connect ${flag} --backend ${redirect.actual}\n` +
|
|
1801
|
+
` (needs a key issued by it, and that API running).\n` +
|
|
1802
|
+
` - Config written to: ${written.path}\n`
|
|
1803
|
+
);
|
|
1804
|
+
notes.push(
|
|
1805
|
+
`start ${agent.label} from another project, or connect to the backend this ` +
|
|
1806
|
+
`directory forces: ${cliInvocation()} connect ${flag} --backend ${redirect.actual} ` +
|
|
1807
|
+
`(needs a key issued by it, and that API running).`
|
|
1808
|
+
);
|
|
1809
|
+
|
|
1810
|
+
// The config we wrote is correct; the directory around it is not. Since the
|
|
1811
|
+
// server only reads the file it is standing in, everything this does next
|
|
1812
|
+
// happens somewhere the override cannot reach.
|
|
1813
|
+
const outside = neutralCwd();
|
|
1814
|
+
if (!outside) {
|
|
1815
|
+
notLinked(`Run '${cliInvocation()} connect' from your own project instead of this directory.`);
|
|
1816
|
+
return false;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
say(
|
|
1820
|
+
(projectInstall
|
|
1821
|
+
? `\nThe key and backend written are right: ${redirect.path}\n` +
|
|
1822
|
+
`only redirects what starts in this directory. `
|
|
1823
|
+
: `\nThe config is written and works anywhere else: ${redirect.path}\n` +
|
|
1824
|
+
`only redirects agents started in this directory. `) +
|
|
1825
|
+
`Trying again from\n${outside}, which is out of that file's reach.\n`
|
|
1826
|
+
);
|
|
1827
|
+
// Nothing was wrong with the self-test but where it stood, and a self-test
|
|
1828
|
+
// is what completes the link — so re-run it before spending an agent window
|
|
1829
|
+
// on the same question. This works under --project too: it runs the config
|
|
1830
|
+
// in memory rather than loading it from disk.
|
|
1831
|
+
const retry = await mcpSelfTest(serverConfig, { cwd: outside });
|
|
1832
|
+
if (
|
|
1833
|
+
retry.ok &&
|
|
1834
|
+
retry.status?.authenticated &&
|
|
1835
|
+
(await waitForConnection(args, apiKey, { timeoutMs: 15000 }))
|
|
1836
|
+
) {
|
|
1837
|
+
return true;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (projectInstall) {
|
|
1841
|
+
// --project put the server in this directory and nowhere else, so there
|
|
1842
|
+
// is no config to load anywhere the override cannot reach, and no agent
|
|
1843
|
+
// worth starting to look for one.
|
|
1844
|
+
notLinked(
|
|
1845
|
+
`This --project config exists only here; started anywhere else, ` +
|
|
1846
|
+
`${agent.label} has no ${serverName} server to call.`
|
|
1847
|
+
);
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
elsewhere = outside;
|
|
1851
|
+
}
|
|
1563
1852
|
}
|
|
1564
1853
|
|
|
1565
1854
|
const blocker = agentBlocker(agent.id);
|
|
1855
|
+
let launch = {};
|
|
1566
1856
|
if (!args.has("--no-auto-checkin") && !blocker) {
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
process.
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1857
|
+
launch = await autoCheckIn(args, agent, apiKey, {
|
|
1858
|
+
serverName,
|
|
1859
|
+
cwd: elsewhere || process.cwd(),
|
|
1860
|
+
onPoll: ticker.tick,
|
|
1861
|
+
onLaunch: ({ mode, terminal }) =>
|
|
1862
|
+
say(
|
|
1863
|
+
mode === "interactive"
|
|
1864
|
+
? `\nOpened a new ${terminal} window running ${agent.label}.\n`
|
|
1865
|
+
: `\nStarting ${agent.label} to finish the link…\n`
|
|
1866
|
+
),
|
|
1867
|
+
});
|
|
1868
|
+
if (launch.connected) {
|
|
1869
|
+
ticker.end();
|
|
1574
1870
|
return true;
|
|
1575
1871
|
}
|
|
1576
|
-
if (
|
|
1577
|
-
const why = lastLine(
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
);
|
|
1581
|
-
} else if (auto.reason) {
|
|
1582
|
-
process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
|
|
1872
|
+
if (launch.ran && !launch.interactive) {
|
|
1873
|
+
const why = lastLine(launch.output);
|
|
1874
|
+
say(`${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`);
|
|
1875
|
+
} else if (!launch.ran && launch.reason) {
|
|
1876
|
+
say(`Could not run ${agent.label}: ${launch.reason}\n`);
|
|
1583
1877
|
}
|
|
1584
1878
|
} else if (blocker) {
|
|
1585
1879
|
// Starting an agent that cannot authenticate spends two minutes to learn
|
|
1586
1880
|
// what one status probe already knows.
|
|
1587
|
-
|
|
1881
|
+
say(`${MARK.skip()} ${blocker}\n`);
|
|
1588
1882
|
notes.push(blocker);
|
|
1589
1883
|
}
|
|
1590
1884
|
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1885
|
+
say(
|
|
1886
|
+
launch.interactive
|
|
1887
|
+
? `\nAnswer it in the ${agent.label} window — it links on its first PreMan call.\n` +
|
|
1888
|
+
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
1889
|
+
: // Restarting it here would read the same redirecting file again, so the
|
|
1890
|
+
// one instruction that works is to start it somewhere else.
|
|
1891
|
+
(elsewhere
|
|
1892
|
+
? `\nStart ${agent.label} in another project and ask it: "run preman_status"\n`
|
|
1893
|
+
: `\nRestart ${agent.label} and ask it: "run preman_status"\n`) +
|
|
1894
|
+
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
1594
1895
|
);
|
|
1595
1896
|
|
|
1596
|
-
if (await waitForConnection(args, apiKey)) {
|
|
1597
|
-
|
|
1897
|
+
if (await waitForConnection(args, apiKey, { onPoll: ticker.tick })) {
|
|
1898
|
+
ticker.end();
|
|
1598
1899
|
return true;
|
|
1599
1900
|
}
|
|
1600
1901
|
|
|
1601
|
-
|
|
1902
|
+
say(
|
|
1602
1903
|
"No check-in yet. Troubleshooting:\n" +
|
|
1603
|
-
notes.map((
|
|
1904
|
+
notes.map((entry) => ` - ${entry}\n`).join("") +
|
|
1604
1905
|
` - ${agent.restartHint}\n` +
|
|
1605
1906
|
` - Config written to: ${written.path}\n` +
|
|
1606
1907
|
` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
|
package/bin/runner.js
CHANGED
|
@@ -612,6 +612,32 @@ function defaultLog(message) {
|
|
|
612
612
|
process.stdout.write(`[preman runner] ${new Date().toISOString()} ${message}\n`);
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
+
/**
|
|
616
|
+
* One heartbeat. Never throws.
|
|
617
|
+
*
|
|
618
|
+
* It runs on a timer, and a rejected fetch inside a timer callback has nobody to
|
|
619
|
+
* catch it — Node turns that unhandled rejection into process exit, so a single
|
|
620
|
+
* connect timeout to the backend used to take the whole daemon down mid-run and
|
|
621
|
+
* leave a machine that reads as paired and never runs anything again. A missed
|
|
622
|
+
* heartbeat only costs one TTL window: the next one puts this runner back online.
|
|
623
|
+
*/
|
|
624
|
+
export async function sendHeartbeat(args, state, { busy = false, log = () => {} } = {}) {
|
|
625
|
+
try {
|
|
626
|
+
// Reported honestly because the matcher prefers idle runners: a busy device
|
|
627
|
+
// claiming to be idle wins work it will only sit on until the lease expires.
|
|
628
|
+
const result = await callBackendJson(
|
|
629
|
+
args,
|
|
630
|
+
"POST",
|
|
631
|
+
"/workbench/coding-agent/local-runner/heartbeat",
|
|
632
|
+
{ token: state.runner_token, json: { state: busy ? "busy" : "idle" } }
|
|
633
|
+
);
|
|
634
|
+
return { revoked: result.status_code === 401 };
|
|
635
|
+
} catch (error) {
|
|
636
|
+
log(`heartbeat failed: ${error.message}`);
|
|
637
|
+
return { revoked: false };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
615
641
|
/**
|
|
616
642
|
* Hold the stream and run what it leases, until stopped.
|
|
617
643
|
*
|
|
@@ -633,17 +659,12 @@ export async function runnerLoop(
|
|
|
633
659
|
`${state.backend_url || backendUrl(args)}/`
|
|
634
660
|
);
|
|
635
661
|
|
|
636
|
-
const heartbeat = setInterval(
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const result = await callBackendJson(args, "POST", "/workbench/coding-agent/local-runner/heartbeat", {
|
|
640
|
-
token: state.runner_token,
|
|
641
|
-
json: { state: busy ? "busy" : "idle" },
|
|
642
|
-
});
|
|
643
|
-
if (result.status_code === 401) {
|
|
662
|
+
const heartbeat = setInterval(() => {
|
|
663
|
+
void sendHeartbeat(args, state, { busy, log }).then(({ revoked }) => {
|
|
664
|
+
if (!revoked) return;
|
|
644
665
|
log("runner registration is no longer active; stopping");
|
|
645
666
|
stopped = true;
|
|
646
|
-
}
|
|
667
|
+
});
|
|
647
668
|
}, HEARTBEAT_MS);
|
|
648
669
|
|
|
649
670
|
try {
|
|
@@ -842,6 +863,16 @@ async function startForeground(args, commandArgs) {
|
|
|
842
863
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
843
864
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
844
865
|
|
|
866
|
+
// A daemon that dies must not leave its pid file behind claiming otherwise.
|
|
867
|
+
// Nothing is swallowed: the reason is logged and the exit code says it failed.
|
|
868
|
+
const die = (kind) => (error) => {
|
|
869
|
+
log(`${kind}: ${error?.stack || error}`);
|
|
870
|
+
rmSync(RUNNER_PID_FILE, { force: true });
|
|
871
|
+
process.exit(1);
|
|
872
|
+
};
|
|
873
|
+
process.on("uncaughtException", die("uncaught exception"));
|
|
874
|
+
process.on("unhandledRejection", die("unhandled rejection"));
|
|
875
|
+
|
|
845
876
|
try {
|
|
846
877
|
const result = await runnerLoop(args, state, {
|
|
847
878
|
log,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "premanmcp",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
|
|
16
16
|
"test": "npm run build && npm run test:connect && npm run test:node",
|
|
17
17
|
"test:connect": "node scripts/smoke-connect.mjs",
|
|
18
|
-
"test:node": "node --test --test-timeout=30000 scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs"
|
|
18
|
+
"test:node": "node --test --test-timeout=30000 scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@modelcontextprotocol/ext-apps": "^0.1.0",
|