premanmcp 1.1.4 → 1.1.6
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 +59 -0
- package/bin/agent.js +1925 -0
- package/bin/agent_dialects.js +385 -0
- package/bin/cli.js +136 -17
- package/bin/eval.js +247 -15
- package/bin/eval_harness.py +6 -2
- package/bin/eval_target.js +102 -18
- package/bin/link.js +40 -13
- package/bin/predict.js +857 -0
- package/bin/runner.js +4 -1
- package/bin/target.js +505 -0
- package/dist/server.js +6 -0
- package/package.json +2 -2
package/bin/runner.js
CHANGED
|
@@ -470,7 +470,10 @@ export async function runLeasedEval(args, state, job, { log = () => {}, headless
|
|
|
470
470
|
surface: { ...surface, open: () => openUrl(surface.href, { app: surface.app }) },
|
|
471
471
|
});
|
|
472
472
|
if (result.reason === "lease_lost") log(`eval ${job.id} was taken over or cancelled`);
|
|
473
|
-
else log(`eval ${job.id} finished:
|
|
473
|
+
else if (result.ok) log(`eval ${job.id} finished: ok`);
|
|
474
|
+
// The reason, on the line that says it failed. Without it this was the whole
|
|
475
|
+
// of what a terminal learned about a run that did not work.
|
|
476
|
+
else log(`eval ${job.id} finished: failed${result.error ? ` — ${result.error}` : ""}`);
|
|
474
477
|
return result;
|
|
475
478
|
}
|
|
476
479
|
|
package/bin/target.js
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `preman target` — teach PreMan which agent to test, from the repo it lives in.
|
|
3
|
+
*
|
|
4
|
+
* The eval stack can measure an agent it does not own: a session renders
|
|
5
|
+
* `target: {kind: "endpoint", url}` against whatever address the device reports.
|
|
6
|
+
* What was missing is everything before that — the customer's agent is on their
|
|
7
|
+
* machine, behind a route only they know, speaking a shape nobody wrote down,
|
|
8
|
+
* and the CLI's own discovery only ever looked for *PreMan's* agent.
|
|
9
|
+
*
|
|
10
|
+
* So this is the step that turns "point the eval at a URL" into "PreMan found
|
|
11
|
+
* your agent". It probes what is running (`agent_dialects.js` decides how to
|
|
12
|
+
* talk to it) and writes the answer into the repository's `.preman.json`, which
|
|
13
|
+
* is where the pre-push hook already reads its team-level decisions from. In the
|
|
14
|
+
* repo rather than under `~/.preman` on purpose: the next person to clone this,
|
|
15
|
+
* and the CI job that will run these evals on every push, need the same answer
|
|
16
|
+
* without repeating the discovery.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here starts a run or spends a provider bill. The worst outcome is a
|
|
19
|
+
* saved address that turns out to be wrong, and `preman target try` is the
|
|
20
|
+
* cheap way to find that out before anything is measured.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { askAgent, dialectNamed, probeAgent, PROBE_MESSAGE } from "./agent_dialects.js";
|
|
28
|
+
import { detectCandidates } from "./detect.js";
|
|
29
|
+
import { cliInvocation } from "./shared.js";
|
|
30
|
+
|
|
31
|
+
export const TARGET_HELP = `
|
|
32
|
+
Agent under test:
|
|
33
|
+
target detect Find the agent running for this project and remember it
|
|
34
|
+
target try [message] Send one message through what was remembered
|
|
35
|
+
target show What this repository has saved
|
|
36
|
+
target forget Remove it
|
|
37
|
+
--url <base> Skip discovery; the agent is here
|
|
38
|
+
--path <path> Skip path discovery; the agent answers on this path
|
|
39
|
+
--port <n> Also try this port when discovering
|
|
40
|
+
--start Start the project first, using the command it names
|
|
41
|
+
--start-with <cmd> Start it with this command instead
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
/** Committed to the repository, so the answer travels with the code. */
|
|
45
|
+
export const REPO_CONFIG = ".preman.json";
|
|
46
|
+
|
|
47
|
+
export function configPath(root = process.cwd()) {
|
|
48
|
+
return path.join(root, REPO_CONFIG);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The nearest directory at or above `from` that holds a `.preman.json`.
|
|
53
|
+
*
|
|
54
|
+
* Sold as "your team and CI find it", and until this it was found only by a
|
|
55
|
+
* command run from the repository root -- so an eval started from `services/api`
|
|
56
|
+
* discovered nothing and fell back to probing for PreMan's own agent. Stops at
|
|
57
|
+
* the filesystem root, and stops early at a `.git` directory, because a config
|
|
58
|
+
* above the repository belongs to a different project.
|
|
59
|
+
*/
|
|
60
|
+
export function findConfigRoot(from = process.cwd()) {
|
|
61
|
+
let dir = path.resolve(from);
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (existsSync(path.join(dir, REPO_CONFIG))) return dir;
|
|
64
|
+
if (existsSync(path.join(dir, ".git"))) return null;
|
|
65
|
+
const parent = path.dirname(dir);
|
|
66
|
+
if (parent === dir) return null;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readConfig(root = process.cwd()) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(readFileSync(configPath(root), "utf8"));
|
|
74
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
75
|
+
} catch {
|
|
76
|
+
// Absent or malformed reads the same way: nothing has been decided here.
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Merge rather than overwrite, and keep the file readable.
|
|
83
|
+
*
|
|
84
|
+
* `.preman.json` already carries `blockOnBreaking`, a decision somebody made
|
|
85
|
+
* about their pushes. Rewriting the file as "the agent" would take that with it.
|
|
86
|
+
*/
|
|
87
|
+
export function saveAgent(agent, root = process.cwd()) {
|
|
88
|
+
const config = readConfig(root);
|
|
89
|
+
const next = { ...config, agent };
|
|
90
|
+
writeFileSync(configPath(root), `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function savedAgent(root = process.cwd()) {
|
|
95
|
+
const agent = readConfig(findConfigRoot(root) || root).agent;
|
|
96
|
+
if (!agent || typeof agent !== "object") return null;
|
|
97
|
+
const url = String(agent.url || "").trim();
|
|
98
|
+
if (!url) return null;
|
|
99
|
+
return {
|
|
100
|
+
url,
|
|
101
|
+
path: String(agent.path || ""),
|
|
102
|
+
dialect: String(agent.dialect || "message"),
|
|
103
|
+
start: String(agent.start || ""),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Where this project's agent might be listening.
|
|
109
|
+
*
|
|
110
|
+
* `detectCandidates` reads the things a project already says about itself --
|
|
111
|
+
* env files, package scripts, compose, Procfile, framework defaults -- and it
|
|
112
|
+
* was written for finding an API. An agent is served by the same kinds of
|
|
113
|
+
* process, so the list is the same list; what differs is the question asked of
|
|
114
|
+
* each address, which is `probeAgent`'s job rather than this one's.
|
|
115
|
+
*/
|
|
116
|
+
export function candidateUrls(root = process.cwd(), { extraPorts = [] } = {}) {
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const urls = [];
|
|
119
|
+
const add = (url, why) => {
|
|
120
|
+
const clean = String(url || "").replace(/\/+$/, "");
|
|
121
|
+
if (!clean || seen.has(clean)) return;
|
|
122
|
+
seen.add(clean);
|
|
123
|
+
urls.push({ url: clean, why });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const candidate of detectCandidates(root, { extraPorts })) {
|
|
127
|
+
// `signals` is what a candidate carries -- ".env:PORT", "package.json:dev".
|
|
128
|
+
// Reading a `detail` it has never had printed the raw enum instead, so
|
|
129
|
+
// every address said "env_file" whatever had actually named it.
|
|
130
|
+
add(candidate.url, candidate.signals?.[0] || candidate.source || "");
|
|
131
|
+
}
|
|
132
|
+
// Then the ports things listen on anyway. A project that says nothing about
|
|
133
|
+
// itself is the common case, not the edge one -- `node agent.mjs` names no
|
|
134
|
+
// port, and a repo with no compose file, no Procfile and no framework in its
|
|
135
|
+
// dependencies leaves detection with nothing to read. Guessing is cheap here
|
|
136
|
+
// because a port with nothing behind it refuses the connection immediately.
|
|
137
|
+
for (const port of COMMON_PORTS) add(`http://127.0.0.1:${port}`, `port ${port}`);
|
|
138
|
+
return urls;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Where an agent ends up when nobody decided.
|
|
143
|
+
*
|
|
144
|
+
* Framework defaults first (Next and Vite on 3000 and 5173, FastAPI and Django
|
|
145
|
+
* on 8000), then the ports people reach for when writing a server by hand.
|
|
146
|
+
*/
|
|
147
|
+
export const COMMON_PORTS = [3000, 8000, 8080, 5000, 5173, 4000, 8001, 9000, 7860];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* How long a project gets to come up before this gives up on it.
|
|
151
|
+
*
|
|
152
|
+
* A minute because the first run of a dev server is the slow one -- installing,
|
|
153
|
+
* compiling, warming a model client -- and a developer who has just been told
|
|
154
|
+
* PreMan will start their agent will wait through it once. It polls every
|
|
155
|
+
* second rather than sleeping the whole minute, so a fast project costs a
|
|
156
|
+
* second and not a minute.
|
|
157
|
+
*/
|
|
158
|
+
export const WAIT_FOR_START_MS = 60_000;
|
|
159
|
+
|
|
160
|
+
/** How long a project gets to stop politely before it is killed. */
|
|
161
|
+
export const STOP_GRACE_MS = 5_000;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* How this project starts itself.
|
|
165
|
+
*
|
|
166
|
+
* Read from the files a project already uses to say so, in the order of how
|
|
167
|
+
* much of a decision each one is: a command somebody saved here beats a script
|
|
168
|
+
* in `package.json`, which beats a Procfile's web process, which beats a guess
|
|
169
|
+
* from whichever framework's files are lying around.
|
|
170
|
+
*
|
|
171
|
+
* Guesses are included and labelled as guesses, because the alternative for a
|
|
172
|
+
* developer whose repo says nothing is to type the command themselves -- which
|
|
173
|
+
* is fine, and is what `--start "<cmd>"` is for, but should not be the first
|
|
174
|
+
* thing asked of somebody who ran `preman target detect` expecting it to work.
|
|
175
|
+
*/
|
|
176
|
+
export function startCommands(root = process.cwd()) {
|
|
177
|
+
const found = [];
|
|
178
|
+
const add = (command, why) => {
|
|
179
|
+
const clean = String(command || "").trim();
|
|
180
|
+
if (clean && !found.some((entry) => entry.command === clean)) found.push({ command: clean, why });
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const saved = savedAgent(root);
|
|
184
|
+
if (saved?.start) add(saved.start, `${REPO_CONFIG} remembers it`);
|
|
185
|
+
|
|
186
|
+
const pkg = readJson(path.join(root, "package.json"));
|
|
187
|
+
for (const name of ["dev", "start", "serve", "dev:server", "server"]) {
|
|
188
|
+
if (pkg?.scripts?.[name]) add(`npm run ${name}`, `package.json has a ${name} script`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const procfile = readText(path.join(root, "Procfile"));
|
|
192
|
+
const web = /^web:\s*(.+)$/m.exec(procfile);
|
|
193
|
+
if (web) add(web[1], "the Procfile's web process");
|
|
194
|
+
|
|
195
|
+
for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
|
|
196
|
+
if (existsSync(path.join(root, name))) {
|
|
197
|
+
add("docker compose up", `${name} is here`);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Python projects rarely write the command down anywhere a machine can read,
|
|
203
|
+
// so these are read off the files that exist rather than off configuration.
|
|
204
|
+
if (existsSync(path.join(root, "manage.py"))) add("python manage.py runserver", "a guess, from manage.py");
|
|
205
|
+
if (existsSync(path.join(root, "main.py"))) add("python main.py", "a guess, from main.py");
|
|
206
|
+
if (existsSync(path.join(root, "app.py"))) add("python app.py", "a guess, from app.py");
|
|
207
|
+
|
|
208
|
+
return found;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readJson(file) {
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readText(file) {
|
|
220
|
+
try {
|
|
221
|
+
return readFileSync(file, "utf8");
|
|
222
|
+
} catch {
|
|
223
|
+
return "";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Run the project, wait for its agent to answer, and hand back a way to stop it.
|
|
229
|
+
*
|
|
230
|
+
* Started in its own process group and stopped by group, because the command is
|
|
231
|
+
* nearly always a wrapper -- `npm run dev` is npm, which is a shell, which is
|
|
232
|
+
* the server -- and killing the pid we spawned leaves the server holding the
|
|
233
|
+
* port. A developer whose next run says "something is already on 3000" would
|
|
234
|
+
* have PreMan to thank for it.
|
|
235
|
+
*
|
|
236
|
+
* Output is captured rather than inherited: a dev server's log is not this
|
|
237
|
+
* command's output, and interleaving it with discovery makes both unreadable.
|
|
238
|
+
* It is kept, and printed only when the wait fails, which is the one moment it
|
|
239
|
+
* is the most useful thing on the screen.
|
|
240
|
+
*/
|
|
241
|
+
export async function startProject(command, { root = process.cwd(), waitMs = 60_000, log = () => {} } = {}) {
|
|
242
|
+
const child = spawn(command, {
|
|
243
|
+
cwd: root,
|
|
244
|
+
shell: true,
|
|
245
|
+
detached: true,
|
|
246
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
let output = "";
|
|
250
|
+
const keep = (chunk) => {
|
|
251
|
+
if (output.length < 8000) output += chunk.toString("utf8");
|
|
252
|
+
};
|
|
253
|
+
child.stdout.on("data", keep);
|
|
254
|
+
child.stderr.on("data", keep);
|
|
255
|
+
|
|
256
|
+
let exited = null;
|
|
257
|
+
child.on("exit", (code, signal) => {
|
|
258
|
+
exited = signal || code;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const signal = (name) => {
|
|
262
|
+
try {
|
|
263
|
+
// Negative pid: the group, not just the wrapper.
|
|
264
|
+
process.kill(-child.pid, name);
|
|
265
|
+
} catch {
|
|
266
|
+
try {
|
|
267
|
+
child.kill(name);
|
|
268
|
+
} catch {
|
|
269
|
+
// Already gone, which is the state being asked for.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const stop = async () => {
|
|
275
|
+
if (exited !== null) return;
|
|
276
|
+
signal("SIGTERM");
|
|
277
|
+
// Then SIGKILL, and the pipes closed behind it. A dev server that ignores
|
|
278
|
+
// SIGTERM keeps this process alive forever otherwise: its stdout is still
|
|
279
|
+
// open here, so Node has a reason to stay, and `preman target detect`
|
|
280
|
+
// simply never returns.
|
|
281
|
+
const deadline = Date.now() + STOP_GRACE_MS;
|
|
282
|
+
while (exited === null && Date.now() < deadline) {
|
|
283
|
+
await new Promise((resume) => setTimeout(resume, 100));
|
|
284
|
+
}
|
|
285
|
+
if (exited === null) {
|
|
286
|
+
log(`${command.split(" ")[0]} did not stop when asked; killing it`);
|
|
287
|
+
signal("SIGKILL");
|
|
288
|
+
}
|
|
289
|
+
child.stdout?.destroy();
|
|
290
|
+
child.stderr?.destroy();
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return { child, stop, output: () => output, exited: () => exited };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A base address and the path on it, from whichever form somebody typed. */
|
|
297
|
+
export function splitUrl(value) {
|
|
298
|
+
const raw = String(value || "").trim();
|
|
299
|
+
if (!raw) return { base: "", path: "" };
|
|
300
|
+
// `localhost:3000` is a URL to a person and a `localhost:` scheme to the
|
|
301
|
+
// parser, which turns the port into the path. Anything that is not http(s)
|
|
302
|
+
// is treated as the host somebody meant.
|
|
303
|
+
const candidate = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
304
|
+
try {
|
|
305
|
+
const url = new URL(candidate);
|
|
306
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
|
|
307
|
+
return { base: `${url.protocol}//${url.host}`, path };
|
|
308
|
+
} catch {
|
|
309
|
+
return { base: raw.replace(/\/+$/, ""), path: "" };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Try each candidate until one answers like an agent. */
|
|
314
|
+
export async function findAgent(root, { url = "", paths = null, extraPorts = [], log = () => {} } = {}) {
|
|
315
|
+
const candidates = url
|
|
316
|
+
? [{ url: String(url).replace(/\/+$/, ""), why: "you named it" }]
|
|
317
|
+
: candidateUrls(root, { extraPorts });
|
|
318
|
+
|
|
319
|
+
if (!candidates.length) return { found: null, tried: [] };
|
|
320
|
+
|
|
321
|
+
const tried = [];
|
|
322
|
+
for (const candidate of candidates) {
|
|
323
|
+
log(`looking at ${candidate.url}${candidate.why ? ` (${candidate.why})` : ""}`);
|
|
324
|
+
const result = await probeAgent(candidate.url, paths ? { paths } : {});
|
|
325
|
+
if (result.dialect) return { found: result, tried };
|
|
326
|
+
tried.push({ url: candidate.url, why: summarise(result.tried) });
|
|
327
|
+
}
|
|
328
|
+
return { found: null, tried };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The one sentence a failed sweep can honestly offer about an address. */
|
|
332
|
+
function summarise(attempts = []) {
|
|
333
|
+
if (!attempts.length) return "nothing answered";
|
|
334
|
+
const statuses = attempts.filter((a) => /^\d{3}/.test(a.why || ""));
|
|
335
|
+
if (statuses.length === attempts.length) return "answered, but not like an agent";
|
|
336
|
+
const first = attempts.find((a) => a.why && !/^\d{3}/.test(a.why));
|
|
337
|
+
return first?.why || "nothing answered";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function targetCommand(commandArgs = [], { makeArgs, root = process.cwd() } = {}) {
|
|
341
|
+
const args = makeArgs(commandArgs);
|
|
342
|
+
const sub = commandArgs.find((value) => !value.startsWith("-")) || "detect";
|
|
343
|
+
const say = (line = "") => process.stdout.write(`${line}\n`);
|
|
344
|
+
const cli = cliInvocation();
|
|
345
|
+
|
|
346
|
+
if (sub === "show") {
|
|
347
|
+
const agent = savedAgent(root);
|
|
348
|
+
if (!agent) {
|
|
349
|
+
say(`Nothing saved in ${REPO_CONFIG}. Run \`${cli} target detect\`.`);
|
|
350
|
+
return { ok: false, reason: "nothing_saved" };
|
|
351
|
+
}
|
|
352
|
+
say(`${agent.url}${agent.path}`);
|
|
353
|
+
say(` speaks ${agent.dialect}`);
|
|
354
|
+
if (agent.start) say(` started with ${agent.start}`);
|
|
355
|
+
return { ok: true, agent };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (sub === "forget") {
|
|
359
|
+
const config = readConfig(root);
|
|
360
|
+
if (!config.agent) {
|
|
361
|
+
say(`Nothing saved in ${REPO_CONFIG}.`);
|
|
362
|
+
return { ok: true, forgot: false };
|
|
363
|
+
}
|
|
364
|
+
delete config.agent;
|
|
365
|
+
writeFileSync(configPath(root), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
366
|
+
say(`Forgotten. ${REPO_CONFIG} no longer names an agent.`);
|
|
367
|
+
return { ok: true, forgot: true };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (sub === "try") {
|
|
371
|
+
const agent = savedAgent(root);
|
|
372
|
+
if (!agent) {
|
|
373
|
+
say(`Nothing saved to try. Run \`${cli} target detect\` first.`);
|
|
374
|
+
return { ok: false, reason: "nothing_saved" };
|
|
375
|
+
}
|
|
376
|
+
const message = commandArgs.filter((value) => value !== "try" && !value.startsWith("-")).join(" ") || PROBE_MESSAGE;
|
|
377
|
+
say(`> ${message}`);
|
|
378
|
+
try {
|
|
379
|
+
const { text, events } = await askAgent(agent, message);
|
|
380
|
+
say("");
|
|
381
|
+
say(text);
|
|
382
|
+
if (events.length) {
|
|
383
|
+
say("");
|
|
384
|
+
say(`tools it used: ${events.map((event) => event.tool_name).join(", ")}`);
|
|
385
|
+
} else {
|
|
386
|
+
// Said plainly rather than left blank: a run against an agent whose
|
|
387
|
+
// tool calls are invisible measures what it said and not what it did,
|
|
388
|
+
// and that is a different product promise.
|
|
389
|
+
say("");
|
|
390
|
+
say("no tool calls in the answer, so an eval will score what it says, not what it does");
|
|
391
|
+
}
|
|
392
|
+
return { ok: true, text, events };
|
|
393
|
+
} catch (error) {
|
|
394
|
+
say(`could not get an answer: ${error.message}`);
|
|
395
|
+
return { ok: false, reason: "no_answer" };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (sub !== "detect") {
|
|
400
|
+
say(`Unknown target subcommand: ${sub}${TARGET_HELP}`);
|
|
401
|
+
return { ok: false, reason: "unknown_subcommand" };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// `--url http://localhost:3000/api/chat` is what people type, and it is the
|
|
405
|
+
// same thing as `--url http://localhost:3000 --path /api/chat`. Splitting it
|
|
406
|
+
// here rather than appending to it is the difference between probing the
|
|
407
|
+
// address they named and probing `/api/chat/chat`.
|
|
408
|
+
const named = splitUrl(args.value("--url", ""));
|
|
409
|
+
const explicit = named.base;
|
|
410
|
+
const explicitPath = args.value("--path", "") || named.path;
|
|
411
|
+
const port = args.value("--port", "");
|
|
412
|
+
say("Looking for the agent this project runs...");
|
|
413
|
+
|
|
414
|
+
let { found, tried } = await findAgent(root, {
|
|
415
|
+
url: explicit,
|
|
416
|
+
paths: explicitPath ? [explicitPath] : null,
|
|
417
|
+
extraPorts: port ? [port] : [],
|
|
418
|
+
log: (line) => say(` ${line}`),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Nothing running, and permission to do something about it. Starting is
|
|
422
|
+
// opt-in and never inferred: this runs a command out of somebody's repository,
|
|
423
|
+
// which is the one thing in this CLI that executes code nobody typed here.
|
|
424
|
+
const startWith = args.value("--start-with", "");
|
|
425
|
+
const mayStart = startWith || args.has("--start");
|
|
426
|
+
let started = null;
|
|
427
|
+
let startCommand = "";
|
|
428
|
+
|
|
429
|
+
if (!found && mayStart) {
|
|
430
|
+
const commands = startCommands(root);
|
|
431
|
+
startCommand = startWith || commands[0]?.command || "";
|
|
432
|
+
if (!startCommand) {
|
|
433
|
+
say("");
|
|
434
|
+
say("Nothing here says how this project starts.");
|
|
435
|
+
say(` ${cli} target detect --start-with "npm run dev"`);
|
|
436
|
+
return { ok: false, reason: "no_start_command" };
|
|
437
|
+
}
|
|
438
|
+
const why = startWith ? "you named it" : commands[0].why;
|
|
439
|
+
say("");
|
|
440
|
+
say(`Starting the project: ${startCommand} (${why})`);
|
|
441
|
+
started = await startProject(startCommand, { root });
|
|
442
|
+
|
|
443
|
+
const deadline = Date.now() + WAIT_FOR_START_MS;
|
|
444
|
+
while (Date.now() < deadline && started.exited() === null) {
|
|
445
|
+
await new Promise((resume) => setTimeout(resume, 1000));
|
|
446
|
+
const retry = await findAgent(root, {
|
|
447
|
+
url: explicit,
|
|
448
|
+
paths: explicitPath ? [explicitPath] : null,
|
|
449
|
+
extraPorts: port ? [port] : [],
|
|
450
|
+
});
|
|
451
|
+
if (retry.found) {
|
|
452
|
+
found = retry.found;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
await started.stop();
|
|
458
|
+
if (!found) {
|
|
459
|
+
say("");
|
|
460
|
+
say(
|
|
461
|
+
started.exited() === null
|
|
462
|
+
? `It started, but nothing answered like an agent within ${Math.round(WAIT_FOR_START_MS / 1000)}s.`
|
|
463
|
+
: `That command exited (${started.exited()}) before anything answered.`
|
|
464
|
+
);
|
|
465
|
+
const log = started.output().trim();
|
|
466
|
+
// The log is the useful half of this failure and is otherwise thrown
|
|
467
|
+
// away: a missing env var says so in line three of a dev server's output.
|
|
468
|
+
if (log) {
|
|
469
|
+
say("");
|
|
470
|
+
say(log.split("\n").slice(-12).join("\n"));
|
|
471
|
+
}
|
|
472
|
+
return { ok: false, reason: "start_failed" };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!found) {
|
|
477
|
+
say("");
|
|
478
|
+
say("No agent answered here.");
|
|
479
|
+
for (const attempt of tried) say(` ${attempt.url} — ${attempt.why}`);
|
|
480
|
+
say("");
|
|
481
|
+
say("Start it yourself and run this again, or let PreMan start it:");
|
|
482
|
+
say(` ${cli} target detect --start`);
|
|
483
|
+
say(` ${cli} target detect --url http://localhost:3000 --path /api/chat`);
|
|
484
|
+
return { ok: false, reason: "not_found", tried };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const agent = { url: found.url, path: found.path, dialect: found.dialect };
|
|
488
|
+
if (startCommand) agent.start = startCommand;
|
|
489
|
+
saveAgent(agent, root);
|
|
490
|
+
|
|
491
|
+
say("");
|
|
492
|
+
say(`Found it: ${agent.url}${agent.path}`);
|
|
493
|
+
say(` it speaks ${dialectNamed(agent.dialect)?.summary || agent.dialect}`);
|
|
494
|
+
say(` it said: ${found.sample}`);
|
|
495
|
+
if (found.events?.length) {
|
|
496
|
+
say(` and it reported using ${found.events.map((event) => event.tool_name).join(", ")}`);
|
|
497
|
+
}
|
|
498
|
+
say("");
|
|
499
|
+
if (agent.start) say(` it starts with ${agent.start}`);
|
|
500
|
+
say("");
|
|
501
|
+
say(`Saved to ${REPO_CONFIG}, so your team and CI find it without repeating this.`);
|
|
502
|
+
say(` ${cli} target try "I want a refund" to send it something`);
|
|
503
|
+
say(` ${cli} eval to measure it`);
|
|
504
|
+
return { ok: true, agent };
|
|
505
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,12 @@ import { createInterface } from "node:readline";
|
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
9
|
import os from "node:os";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
// The stdio bridge is the process an MCP host launches and nobody started on
|
|
12
|
+
// purpose, so it is the one most likely to be found later as an unexplained
|
|
13
|
+
// `node` in a process list. Named apart from the CLI and the session, because
|
|
14
|
+
// "which of these three is the one my editor spawned" is the question being
|
|
15
|
+
// asked when somebody goes looking.
|
|
16
|
+
process.title = "preman-mcp";
|
|
11
17
|
let sessionId = "";
|
|
12
18
|
let protocolVersion = "2025-06-18";
|
|
13
19
|
async function credentials() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "premanmcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test": "npm run build && npm run test:proxy",
|
|
16
16
|
"test:proxy": "node --test scripts/smoke-proxy.mjs",
|
|
17
17
|
"test:connect": "node scripts/smoke-connect.mjs",
|
|
18
|
-
"test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-eval.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.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 scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs",
|
|
18
|
+
"test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-agent-session.mjs scripts/smoke-agent-dock.mjs scripts/smoke-terminal-lifecycle.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-cli-update.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-eval.mjs scripts/smoke-eval-behaviors.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-agent-target.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 scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs scripts/smoke-process-title.mjs scripts/smoke-predict.mjs",
|
|
19
19
|
"test:dmg": "node --test --test-timeout=300000 scripts/smoke-install-desktop-volume.mjs"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|