premanmcp 1.1.7 → 1.1.8

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.
Files changed (2) hide show
  1. package/bin/agent.js +295 -22
  2. package/package.json +1 -1
package/bin/agent.js CHANGED
@@ -84,12 +84,13 @@ const THINK_MS = 220;
84
84
  const CLEAR_LINE = "\r\u001b[2K";
85
85
  const CLEAR_SCREEN = "\u001b[2J\u001b[H";
86
86
 
87
- // The alternate screen buffer: what makes a session a place rather than a
88
- // stretch of shell output. Entering gives it a screen of its own, so a second
89
- // `preman` cannot be scrolled back into the first; leaving hands back the
90
- // screen the shell had, untouched, the way `vim` and `htop` do.
91
- const ENTER_ALT = "\u001b[?1049h";
92
- const LEAVE_ALT = "\u001b[?1049l";
87
+ // The alternate screen buffer is deliberately not used, and the sequences for
88
+ // it are deliberately not defined, so that reaching for one is a decision
89
+ // rather than an autocomplete. It gave a session a screen of its own -- a
90
+ // second `preman` could not be scrolled back into the first -- by holding
91
+ // exactly one screen and keeping no history, which meant a reply that ran past
92
+ // the top of the window could not be scrolled back to either. A transcript is
93
+ // the wrong thing to put in a buffer that forgets.
93
94
  const SHOW_CURSOR = "\u001b[?25h";
94
95
  const RELEASE_REGION = "\u001b[r";
95
96
 
@@ -217,6 +218,188 @@ export function artifactLine(artifact) {
217
218
  return url ? `${truncate(label, 72)} — ${url}` : truncate(label, 100);
218
219
  }
219
220
 
221
+ /**
222
+ * The route an endpoint sits on, normalised so two saved requests that hit the
223
+ * same place collapse onto one line.
224
+ *
225
+ * The same rule the app groups by, deliberately: a workspace that reads as 84
226
+ * routes in the browser and 284 rows here is two different answers to one
227
+ * question. A saved URL is often a template (`{{baseUrl}}/v1/users`) rather
228
+ * than something `URL` will parse, which is why the fallback strips the
229
+ * template head by hand instead of giving up.
230
+ */
231
+ export function endpointRoutePath(endpoint) {
232
+ const raw = String(endpoint?.url || endpoint?.name || "/").trim();
233
+ let path = raw;
234
+ try {
235
+ path = new URL(raw).pathname || "/";
236
+ } catch {
237
+ path = raw.replace(/^\{\{[^}]+\}\}/, "").split(/[?#]/, 1)[0] || "/";
238
+ }
239
+ if (!path.startsWith("/")) path = `/${path}`;
240
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
241
+ }
242
+
243
+ // The three buckets the server counts in and the model writes its reply from.
244
+ // Matching them exactly is what keeps "87 failing" in the prose and the heading
245
+ // above the rows from being two different numbers.
246
+ const FAILING_RUNS = new Set(["failed", "error", "fail"]);
247
+ const PASSING_RUNS = new Set(["passed", "pass", "ok", "success"]);
248
+
249
+ /** Which of the three buckets a last-run status falls in. */
250
+ export function endpointBucket(lastRun) {
251
+ const status = String(lastRun || "").trim().toLowerCase();
252
+ if (!status) return "untested";
253
+ if (FAILING_RUNS.has(status)) return "failing";
254
+ if (PASSING_RUNS.has(status)) return "passing";
255
+ return "untested";
256
+ }
257
+
258
+ // Failing first because it is the only bucket anybody acts on, then untested,
259
+ // then passing -- the order the model already writes its own summary in.
260
+ const BUCKET_ORDER = ["failing", "untested", "passing"];
261
+ const BUCKET_LABELS = { failing: "Failing", untested: "Untested", passing: "Passing" };
262
+ const METHOD_ORDER = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
263
+
264
+ const methodRank = (method) => {
265
+ const at = METHOD_ORDER.indexOf(String(method || "GET").trim().toUpperCase());
266
+ return at === -1 ? METHOD_ORDER.length : at;
267
+ };
268
+
269
+ /**
270
+ * Every endpoint the turn returned, once, as routes.
271
+ *
272
+ * A list of 284 arrives as thirteen separate tool calls, because the tool caps
273
+ * a page at 25 rows and the model pages through it. Ids are deduplicated
274
+ * because overlapping pages are a model's decision and not a thing to render
275
+ * twice; routes are collapsed because that is what turns request history back
276
+ * into an API. A route whose saved requests disagree about their last run
277
+ * takes the worst of them, since "one of these is failing" is the fact worth
278
+ * putting on a line somebody is scanning for failures.
279
+ */
280
+ export function mergeEndpoints(artifacts = []) {
281
+ const seen = new Set();
282
+ const endpoints = [];
283
+ // The furthest page reached in each sweep the model made. A turn that lists
284
+ // everything is several sweeps -- one per status -- and every page but the
285
+ // last of each carries has_more, so believing all of them would claim the
286
+ // catalogue was cut short on exactly the turns that read all of it.
287
+ const sweeps = new Map();
288
+ let saved = 0;
289
+
290
+ for (const artifact of artifacts) {
291
+ saved = Math.max(saved, Number(artifact?.total_count) || 0);
292
+ const key = `${artifact?.status ?? ""}|${artifact?.query ?? ""}`;
293
+ const offset = Number(artifact?.offset) || 0;
294
+ const reached = offset + (artifact?.endpoints?.length || 0);
295
+ const furthest = sweeps.get(key);
296
+ if (!furthest || offset >= furthest.offset) {
297
+ sweeps.set(key, {
298
+ offset,
299
+ more: Boolean(artifact?.has_more) || (Number(artifact?.matched_count) || 0) > reached,
300
+ });
301
+ }
302
+ for (const endpoint of artifact?.endpoints || []) {
303
+ const id = String(endpoint?.id ?? "");
304
+ if (id && seen.has(id)) continue;
305
+ if (id) seen.add(id);
306
+ endpoints.push(endpoint);
307
+ }
308
+ }
309
+
310
+ const routes = new Map();
311
+ const counts = { failing: 0, untested: 0, passing: 0 };
312
+ for (const endpoint of endpoints) {
313
+ const method = String(endpoint?.method || "GET").trim().toUpperCase();
314
+ const path = endpointRoutePath(endpoint);
315
+ const bucket = endpointBucket(endpoint?.last_run);
316
+ counts[bucket] += 1;
317
+ const key = `${method} ${path}`;
318
+ const found = routes.get(key);
319
+ if (!found) {
320
+ routes.set(key, { method, path, bucket, count: 1 });
321
+ continue;
322
+ }
323
+ found.count += 1;
324
+ if (BUCKET_ORDER.indexOf(bucket) < BUCKET_ORDER.indexOf(found.bucket)) found.bucket = bucket;
325
+ }
326
+
327
+ const ordered = [...routes.values()].sort(
328
+ (left, right) => methodRank(left.method) - methodRank(right.method) || left.path.localeCompare(right.path)
329
+ );
330
+ return { endpoints, routes: ordered, counts, saved, more: [...sweeps.values()].some((s) => s.more) };
331
+ }
332
+
333
+ /**
334
+ * The endpoints of a turn, drawn for a terminal.
335
+ *
336
+ * Until now this surface printed the artifact's title and dropped its payload,
337
+ * so a request to list 284 endpoints produced thirteen lines reading "showing
338
+ * 51-75 of 197" and not one endpoint. The model's reply said the list was
339
+ * above, and in the app it is -- the card renders the rows. Here there is no
340
+ * card and no page to send anybody to: the transcript is the whole surface, so
341
+ * the rows have to land in it.
342
+ *
343
+ * Piped output is a different contract and gets a different shape: one line per
344
+ * endpoint, ungrouped and never truncated, because on the other end of a pipe
345
+ * this is something being grepped rather than read.
346
+ */
347
+ export function endpointsBlock(artifacts, { columns = 80, live = true, paint } = {}) {
348
+ const ink = paint || makePaint(false);
349
+ const { endpoints, routes, counts, saved, more } = mergeEndpoints(artifacts);
350
+ if (!endpoints.length) return "";
351
+
352
+ if (!live) {
353
+ const lines = endpoints.map((endpoint) => {
354
+ const method = String(endpoint?.method || "GET").trim().toUpperCase();
355
+ const where = String(endpoint?.url || "").trim() || endpointRoutePath(endpoint);
356
+ return ` ${endpointBucket(endpoint?.last_run).padEnd(8)} ${method.padEnd(7)} ${where}`;
357
+ });
358
+ return `${lines.join("\n")}\n`;
359
+ }
360
+
361
+ const shown = endpoints.length;
362
+ const head = [`${shown} endpoint${shown === 1 ? "" : "s"}`];
363
+ if (saved > shown) head[0] = `${shown} of ${saved} endpoints`;
364
+ if (routes.length !== shown) head.push(`${routes.length} route${routes.length === 1 ? "" : "s"}`);
365
+ for (const bucket of BUCKET_ORDER) {
366
+ if (counts[bucket]) head.push(`${counts[bucket]} ${bucket}`);
367
+ }
368
+
369
+ const width = Math.max(24, columns);
370
+ // Truncated before it is painted, not after: the colour runs are bytes that
371
+ // `truncate` would count and cut through, and half an escape sequence on a
372
+ // narrow window is a terminal left in the wrong colour.
373
+ const lines = [
374
+ ` ${ink.bold("Endpoints")} ${ink.dim(`\u00b7 ${truncate(head.join(" \u00b7 "), Math.max(8, width - 14))}`)}`,
375
+ ];
376
+ const method = Math.min(7, Math.max(...routes.map((route) => route.method.length)));
377
+ const room = Math.max(8, width - 6 - method - 2);
378
+
379
+ for (const bucket of BUCKET_ORDER) {
380
+ const group = routes.filter((route) => route.bucket === bucket);
381
+ if (!group.length) continue;
382
+ lines.push("", ` ${ink.bold(BUCKET_LABELS[bucket])} ${ink.dim(`(${counts[bucket]})`)}`);
383
+ for (const route of group) {
384
+ // A hollow mark for untested, because "no run yet" is an absence rather
385
+ // than a result, and a filled dot in any colour reads as one.
386
+ const dot =
387
+ bucket === "failing" ? ink.red("\u25cf") : bucket === "passing" ? ink.green("\u25cf") : ink.dim("\u25cb");
388
+ const also = route.count > 1 ? ink.dim(` (${route.count})`) : "";
389
+ lines.push(` ${dot} ${ink.dim(route.method.padEnd(method))} ${truncate(route.path, room)}${also}`);
390
+ }
391
+ }
392
+
393
+ // Said once, at the end, rather than thirteen times on the way: the model
394
+ // stopped paging before the catalogue ran out, and the rest is a command
395
+ // away rather than a page in a browser this session cannot open.
396
+ if (more) {
397
+ const rest = `More were not fetched. ${cliInvocation()} endpoints list for the rest.`;
398
+ lines.push("", ` ${ink.dim(truncate(rest, width - 2))}`);
399
+ }
400
+ return `${lines.join("\n")}\n`;
401
+ }
402
+
220
403
  /** The permission questions in a finished turn, in the order they were asked. */
221
404
  export function permissionQuestions(artifacts = []) {
222
405
  return (Array.isArray(artifacts) ? artifacts : []).filter(
@@ -268,6 +451,13 @@ function makePaint(enabled) {
268
451
  */
269
452
  export function createRenderer({ stream = process.stdout, paint, live } = {}) {
270
453
  const isLive = live ?? Boolean(stream.isTTY);
454
+ // Whether there is a window here, which is a different question from whether
455
+ // it may be coloured. `NO_COLOR=1` is a widespread way of saying "no colour",
456
+ // and it used to reach layout as "this is a pipe" -- so somebody who set it
457
+ // and sat down at a terminal got the shape meant for `grep`. Colour is the
458
+ // palette's business; how wide the window is and whether rows may be grouped
459
+ // and clipped is this.
460
+ const isTerminal = Boolean(stream.isTTY);
271
461
  const ink = paint || makePaint(isLive);
272
462
  let timer = null;
273
463
  let frame = 0;
@@ -277,6 +467,8 @@ export function createRenderer({ stream = process.stdout, paint, live } = {}) {
277
467
  let stallTick = 0;
278
468
  let wroteReply = false;
279
469
  let atLineStart = true;
470
+ // Endpoint pages, held until the turn has something else to say.
471
+ let pages = [];
280
472
 
281
473
  function stopStall() {
282
474
  if (!stall) return false;
@@ -312,6 +504,26 @@ export function createRenderer({ stream = process.stdout, paint, live } = {}) {
312
504
  stream.write(`${CLEAR_LINE}${tint(`${word}${runOn} ${dots}`)}`);
313
505
  }
314
506
 
507
+ /**
508
+ * Draw the endpoint pages collected so far, as one table.
509
+ *
510
+ * Held rather than printed on arrival because a single "list my endpoints"
511
+ * is thirteen tool calls, and thirteen tables of 25 rows is not a list --
512
+ * it is the pagination read aloud. Flushed by whatever speaks next, so the
513
+ * rows still land *above* the reply that refers to them.
514
+ */
515
+ function flushPages() {
516
+ if (!pages.length) return;
517
+ const held = pages;
518
+ pages = [];
519
+ const block = endpointsBlock(held, { columns: stream.columns || 80, live: isTerminal, paint: ink });
520
+ if (!block) return;
521
+ clearSpinner();
522
+ if (!atLineStart) stream.write("\n");
523
+ stream.write(block);
524
+ atLineStart = true;
525
+ }
526
+
315
527
  function draw() {
316
528
  if (!isLive || !label) return;
317
529
  const spin = FRAMES[frame % FRAMES.length];
@@ -347,8 +559,16 @@ export function createRenderer({ stream = process.stdout, paint, live } = {}) {
347
559
  }
348
560
  },
349
561
  artifact(item) {
562
+ // Endpoint pages are the one artifact whose payload is the point, and
563
+ // they arrive one page at a time, so they are collected rather than
564
+ // drawn. Everything else is a card this surface summarises in a line.
565
+ if (item && item.type === "endpoints" && Array.isArray(item.endpoints)) {
566
+ pages.push(item);
567
+ return;
568
+ }
350
569
  const line = artifactLine(item);
351
570
  if (!line) return;
571
+ flushPages();
352
572
  clearSpinner();
353
573
  if (!atLineStart) stream.write("\n");
354
574
  stream.write(` ${ink.cyan("●")} ${line}\n`);
@@ -357,6 +577,7 @@ export function createRenderer({ stream = process.stdout, paint, live } = {}) {
357
577
  delta(text) {
358
578
  const chunk = String(text ?? "");
359
579
  if (!chunk) return;
580
+ flushPages();
360
581
  clearSpinner();
361
582
  if (!wroteReply) {
362
583
  stream.write("\n");
@@ -373,18 +594,23 @@ export function createRenderer({ stream = process.stdout, paint, live } = {}) {
373
594
  this.delta(body);
374
595
  },
375
596
  note(text) {
597
+ flushPages();
376
598
  clearSpinner();
377
599
  if (!atLineStart) stream.write("\n");
378
600
  stream.write(`${ink.dim(String(text))}\n`);
379
601
  atLineStart = true;
380
602
  },
381
603
  error(text) {
604
+ flushPages();
382
605
  clearSpinner();
383
606
  if (!atLineStart) stream.write("\n");
384
607
  stream.write(`${ink.red(String(text))}\n`);
385
608
  atLineStart = true;
386
609
  },
387
610
  end() {
611
+ // The backstop: a turn that listed endpoints and then said nothing at
612
+ // all still has to put them on the screen.
613
+ flushPages();
388
614
  clearSpinner();
389
615
  if (!atLineStart) stream.write("\n");
390
616
  if (wroteReply) stream.write("\n");
@@ -705,7 +931,14 @@ export function holdTerminal({ stream = process.stdout, footer = () => "" } = {}
705
931
  if (restored) return;
706
932
  restored = true;
707
933
  for (const fn of teardown.splice(0)) safely(fn);
708
- if (live) safely(() => stream.write(`${RELEASE_REGION}${LEAVE_ALT}${SHOW_CURSOR}${CLEAR_TITLE}`));
934
+ // No `ESC[?1049l` here any more, and its absence is load-bearing rather
935
+ // than tidying. The session never enters the alternate screen, so leaving
936
+ // it would be addressed to a buffer nobody is on -- and the sequence is
937
+ // not inert: it restores the cursor as DECRC does, from the same saved
938
+ // slot the dock writes the transcript's position into with `ESC7`. Sent on
939
+ // the shell's own screen it would drag the cursor back up into the
940
+ // transcript and print the resume footer over the conversation.
941
+ if (live) safely(() => stream.write(`${RELEASE_REGION}${SHOW_CURSOR}${CLEAR_TITLE}`));
709
942
  safely(() => {
710
943
  const text = footer();
711
944
  if (text) stream.write(text);
@@ -1236,7 +1469,32 @@ export function createDock({
1236
1469
  at = null;
1237
1470
  const { rows } = size();
1238
1471
  stream.write("\u001b[r");
1239
- stream.write(`\u001b[${rows};1H\u001b[2K`);
1472
+ // Every row the furniture owned, not just the last one. On the alternate
1473
+ // screen clearing one row was enough because leaving the buffer threw the
1474
+ // whole screen away; on the shell's own screen the chrome is real output,
1475
+ // and a composer box and a status strip left sitting under the final
1476
+ // prompt are the last thing the session shows.
1477
+ //
1478
+ // Guarded on having painted, because that is the only thing that says
1479
+ // these rows were ever the dock's. In a window too short to dock in
1480
+ // nothing is drawn at all -- the session is a plain prompt -- and erasing
1481
+ // four rows of a five-row window there would take somebody's transcript
1482
+ // with it. Clamped to the live window for the same reason `resize` is:
1483
+ // after a shrink, `painted.rows` names rows that no longer exist.
1484
+ if (painted) {
1485
+ const bottom = Math.min(painted.rows, rows);
1486
+ const top = Math.max(1, bottom - DOCK_ROWS + 1);
1487
+ for (let row = top; row <= bottom; row += 1) {
1488
+ stream.write(`\u001b[${row};1H\u001b[2K`);
1489
+ }
1490
+ // Carry on immediately below the transcript rather than at the bottom
1491
+ // of the window, so the resume footer and the shell prompt follow the
1492
+ // conversation instead of a gap where the dock used to be.
1493
+ stream.write(`\u001b[${top};1H`);
1494
+ } else {
1495
+ stream.write(`\u001b[${rows};1H\u001b[2K`);
1496
+ }
1497
+ painted = null;
1240
1498
  stream.write(CLEAR_TITLE);
1241
1499
  },
1242
1500
  };
@@ -1738,10 +1996,27 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1738
1996
  if (args.has("--print") || args.has("-p") || !process.stdin.isTTY) return;
1739
1997
  }
1740
1998
 
1741
- // The session's own screen, entered before anything is printed so the banner
1742
- // lands at the top of it. A terminal that does not understand the sequence
1743
- // ignores it and gets exactly what it got before.
1744
- const fullScreen = Boolean(process.stdout.isTTY);
1999
+ // The session shares the shell's screen rather than taking one of its own.
2000
+ //
2001
+ // It used to enter the alternate screen, the way a full-screen app does, and
2002
+ // that cost the one thing a chat transcript needs: the alternate buffer keeps
2003
+ // no history, so a reply that ran past the top of the window was not scrolled
2004
+ // away, it was discarded. There was nothing to scroll back to in any
2005
+ // terminal, because the buffer holds exactly one screen.
2006
+ //
2007
+ // On the shell's own screen the transcript is ordinary output, so the wheel,
2008
+ // Page Up, find and select-to-copy all work on a conversation the way they
2009
+ // work on any other command's output. Keeping the composer docked costs none
2010
+ // of it: the dock's scrolling region starts at row 1, and a region whose top
2011
+ // margin is the first row is the case where a terminal pushes the lines it
2012
+ // scrolls past into the scrollback instead of dropping them.
2013
+ //
2014
+ // What this gives up is what the alternate screen was for -- a new session
2015
+ // can now be scrolled back into a finished one. That is the trade every
2016
+ // other command makes with its own output, and it is the cheaper way round:
2017
+ // scrolling into an old session is something a reader can see and scroll out
2018
+ // of, and a transcript that cannot be scrolled at all is not.
2019
+ const interactive = Boolean(process.stdout.isTTY);
1745
2020
 
1746
2021
  // Watching before the screen is taken, not after: the handlers have to be up
1747
2022
  // for every moment the terminal is in a state somebody else has to live with.
@@ -1755,13 +2030,11 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1755
2030
  // A conversation whose id never arrived stringifies to "undefined", and a
1756
2031
  // resume line naming that is worse than no resume line.
1757
2032
  const id = String(conversation?.id || "");
1758
- if (!fullScreen || !id || id === "undefined") return "";
2033
+ if (!interactive || !id || id === "undefined") return "";
1759
2034
  return `${paint.dim("Resume this session with:")}\n ${cliInvocation()} --conversation ${id}\n`;
1760
2035
  },
1761
2036
  }).watch();
1762
2037
 
1763
- if (fullScreen) process.stdout.write(`${ENTER_ALT}${CLEAR_SCREEN}`);
1764
-
1765
2038
  const dock = createDock({
1766
2039
  stream: process.stdout,
1767
2040
  paint,
@@ -1770,13 +2043,13 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1770
2043
  const prompt = `${paint.caret("\u276f")} `;
1771
2044
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt });
1772
2045
  dock.attach(rl);
1773
- // The screen was just cleared, so the transcript starts at the top of it and
1774
- // the dock is told so. The banner then goes through the dock rather than
1775
- // around it, which is what keeps the two in step: printing it to stdout first
1776
- // and docking afterwards left the transcript anchored at the bottom of the
1777
- // window, eleven blank rows below a banner that the first line of the first
1778
- // reply then scrolled a row closer to the top.
1779
- dock.enable(fullScreen ? { row: 1 } : undefined);
2046
+ // No start row, because there is no cleared screen to start at the top of any
2047
+ // more. Told nothing, the dock makes room by scrolling the shell up by its
2048
+ // own height and carries on at the floor -- which keeps whatever was on the
2049
+ // screen before, the prompt the session was launched from included, instead
2050
+ // of erasing it. The banner still goes through the dock rather than around
2051
+ // it, which is what keeps the two in step.
2052
+ dock.enable();
1780
2053
 
1781
2054
  dock.write(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1782
2055
  const onResize = () => dock.resize();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {