premanmcp 1.1.7 → 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/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");
@@ -659,21 +885,27 @@ export function logo(paint, columns) {
659
885
  /**
660
886
  * Hold the terminal, and give it back whatever happens to this process.
661
887
  *
662
- * A session takes three things that outlive it if nobody puts them back: the
663
- * alternate screen, a scrolling region, and the window title. Until now the
664
- * only thing that returned them was a `finally` around the read loop, which
665
- * covers a clean exit and nothing else -- not SIGTERM, not the tab being
666
- * closed, not an exception thrown from a callback rather than from the loop
667
- * body. And the damage is not self-correcting: the next `preman` sets its
668
- * region on top of the live one, so a shell keeps its clipped bottom rows
669
- * until something writes `ESC[r`.
888
+ * A session takes two things that outlive it if nobody puts them back: a
889
+ * scrolling region and the window title. Until now the only thing that
890
+ * returned them was a `finally` around the read loop, which covers a clean
891
+ * exit and nothing else -- not SIGTERM, not the tab being closed, not an
892
+ * exception thrown from a callback rather than from the loop body. And the
893
+ * damage is not self-correcting: the next `preman` sets its region on top of
894
+ * the live one, so a shell keeps its clipped bottom rows until something
895
+ * writes `ESC[r`.
670
896
  *
671
- * So restoration is one idempotent routine with every exit wired to it. The
672
- * order inside it is the part that matters: the teardowns registered by the
673
- * session run first, while the screen they are writing to is still the one
674
- * they painted; then the region is released; then the alternate screen is left,
675
- * because releasing margins after switching buffers would leave them set on the
676
- * shell's screen -- `ESC[?1049h` does not save or reset them.
897
+ * It used to be three things, the alternate screen among them, and the order
898
+ * of the hand-back was mostly about that -- margins had to come off before the
899
+ * buffer was left, because `ESC[?1049h` neither saves nor resets them. The
900
+ * session shares the shell's screen now, so there is no buffer to leave and
901
+ * that constraint is gone with it.
902
+ *
903
+ * So restoration is one idempotent routine with every exit wired to it, and
904
+ * the order still matters for what remains: the teardowns registered by the
905
+ * session run first, while the rows they are writing to are still the rows
906
+ * they painted, and the region comes off after them -- released first, the
907
+ * furniture they are erasing would no longer be addressable, because inside a
908
+ * scrolling region the bottom rows cannot be cursored to.
677
909
  *
678
910
  * `exit` is registered too, as the last resort, which is why every write here
679
911
  * is synchronous and every step is wrapped: an exit handler that throws turns a
@@ -705,7 +937,14 @@ export function holdTerminal({ stream = process.stdout, footer = () => "" } = {}
705
937
  if (restored) return;
706
938
  restored = true;
707
939
  for (const fn of teardown.splice(0)) safely(fn);
708
- if (live) safely(() => stream.write(`${RELEASE_REGION}${LEAVE_ALT}${SHOW_CURSOR}${CLEAR_TITLE}`));
940
+ // No `ESC[?1049l` here any more, and its absence is load-bearing rather
941
+ // than tidying. The session never enters the alternate screen, so leaving
942
+ // it would be addressed to a buffer nobody is on -- and the sequence is
943
+ // not inert: it restores the cursor as DECRC does, from the same saved
944
+ // slot the dock writes the transcript's position into with `ESC7`. Sent on
945
+ // the shell's own screen it would drag the cursor back up into the
946
+ // transcript and print the resume footer over the conversation.
947
+ if (live) safely(() => stream.write(`${RELEASE_REGION}${SHOW_CURSOR}${CLEAR_TITLE}`));
709
948
  safely(() => {
710
949
  const text = footer();
711
950
  if (text) stream.write(text);
@@ -1236,7 +1475,32 @@ export function createDock({
1236
1475
  at = null;
1237
1476
  const { rows } = size();
1238
1477
  stream.write("\u001b[r");
1239
- stream.write(`\u001b[${rows};1H\u001b[2K`);
1478
+ // Every row the furniture owned, not just the last one. On the alternate
1479
+ // screen clearing one row was enough because leaving the buffer threw the
1480
+ // whole screen away; on the shell's own screen the chrome is real output,
1481
+ // and a composer box and a status strip left sitting under the final
1482
+ // prompt are the last thing the session shows.
1483
+ //
1484
+ // Guarded on having painted, because that is the only thing that says
1485
+ // these rows were ever the dock's. In a window too short to dock in
1486
+ // nothing is drawn at all -- the session is a plain prompt -- and erasing
1487
+ // four rows of a five-row window there would take somebody's transcript
1488
+ // with it. Clamped to the live window for the same reason `resize` is:
1489
+ // after a shrink, `painted.rows` names rows that no longer exist.
1490
+ if (painted) {
1491
+ const bottom = Math.min(painted.rows, rows);
1492
+ const top = Math.max(1, bottom - DOCK_ROWS + 1);
1493
+ for (let row = top; row <= bottom; row += 1) {
1494
+ stream.write(`\u001b[${row};1H\u001b[2K`);
1495
+ }
1496
+ // Carry on immediately below the transcript rather than at the bottom
1497
+ // of the window, so the resume footer and the shell prompt follow the
1498
+ // conversation instead of a gap where the dock used to be.
1499
+ stream.write(`\u001b[${top};1H`);
1500
+ } else {
1501
+ stream.write(`\u001b[${rows};1H\u001b[2K`);
1502
+ }
1503
+ painted = null;
1240
1504
  stream.write(CLEAR_TITLE);
1241
1505
  },
1242
1506
  };
@@ -1738,10 +2002,27 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1738
2002
  if (args.has("--print") || args.has("-p") || !process.stdin.isTTY) return;
1739
2003
  }
1740
2004
 
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);
2005
+ // The session shares the shell's screen rather than taking one of its own.
2006
+ //
2007
+ // It used to enter the alternate screen, the way a full-screen app does, and
2008
+ // that cost the one thing a chat transcript needs: the alternate buffer keeps
2009
+ // no history, so a reply that ran past the top of the window was not scrolled
2010
+ // away, it was discarded. There was nothing to scroll back to in any
2011
+ // terminal, because the buffer holds exactly one screen.
2012
+ //
2013
+ // On the shell's own screen the transcript is ordinary output, so the wheel,
2014
+ // Page Up, find and select-to-copy all work on a conversation the way they
2015
+ // work on any other command's output. Keeping the composer docked costs none
2016
+ // of it: the dock's scrolling region starts at row 1, and a region whose top
2017
+ // margin is the first row is the case where a terminal pushes the lines it
2018
+ // scrolls past into the scrollback instead of dropping them.
2019
+ //
2020
+ // What this gives up is what the alternate screen was for -- a new session
2021
+ // can now be scrolled back into a finished one. That is the trade every
2022
+ // other command makes with its own output, and it is the cheaper way round:
2023
+ // scrolling into an old session is something a reader can see and scroll out
2024
+ // of, and a transcript that cannot be scrolled at all is not.
2025
+ const interactive = Boolean(process.stdout.isTTY);
1745
2026
 
1746
2027
  // Watching before the screen is taken, not after: the handlers have to be up
1747
2028
  // for every moment the terminal is in a state somebody else has to live with.
@@ -1755,13 +2036,11 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1755
2036
  // A conversation whose id never arrived stringifies to "undefined", and a
1756
2037
  // resume line naming that is worse than no resume line.
1757
2038
  const id = String(conversation?.id || "");
1758
- if (!fullScreen || !id || id === "undefined") return "";
2039
+ if (!interactive || !id || id === "undefined") return "";
1759
2040
  return `${paint.dim("Resume this session with:")}\n ${cliInvocation()} --conversation ${id}\n`;
1760
2041
  },
1761
2042
  }).watch();
1762
2043
 
1763
- if (fullScreen) process.stdout.write(`${ENTER_ALT}${CLEAR_SCREEN}`);
1764
-
1765
2044
  const dock = createDock({
1766
2045
  stream: process.stdout,
1767
2046
  paint,
@@ -1770,13 +2049,13 @@ export async function agentCommand(commandArgs = [], { authenticate = authentica
1770
2049
  const prompt = `${paint.caret("\u276f")} `;
1771
2050
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt });
1772
2051
  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);
2052
+ // No start row, because there is no cleared screen to start at the top of any
2053
+ // more. Told nothing, the dock makes room by scrolling the shell up by its
2054
+ // own height and carries on at the floor -- which keeps whatever was on the
2055
+ // screen before, the prompt the session was launched from included, instead
2056
+ // of erasing it. The banner still goes through the dock rather than around
2057
+ // it, which is what keeps the two in step.
2058
+ dock.enable();
1780
2059
 
1781
2060
  dock.write(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1782
2061
  const onResize = () => dock.resize();
package/bin/desktop.js CHANGED
@@ -445,7 +445,7 @@ const BYTES_PER_MB = 1024 * 1024;
445
445
  * through. Redirected output gets the summary line that already existed rather
446
446
  * than a few hundred carriage returns, which is why this is TTY-only.
447
447
  */
448
- function progressLine() {
448
+ export function progressLine() {
449
449
  if (!process.stdout.isTTY) return { tick: () => {}, clear: () => {} };
450
450
  let lastDrawn = 0;
451
451
  let width = 0;
@@ -470,7 +470,7 @@ function progressLine() {
470
470
  return { tick, clear };
471
471
  }
472
472
 
473
- async function download(url, destination, onProgress) {
473
+ export async function download(url, destination, onProgress) {
474
474
  const controller = new AbortController();
475
475
  const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
476
476
  try {
package/bin/detect.js CHANGED
@@ -51,6 +51,35 @@ function readIfExists(filePath) {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * The repository's own dotenv values, later files winning.
56
+ *
57
+ * An agent that reads its provider key with a bare `os.getenv` works in a
58
+ * terminal where somebody exported it and fails under a runner that did not --
59
+ * even though the key is sitting in the repository it was started from. Reading
60
+ * these files closes that gap without asking anybody to re-export anything.
61
+ *
62
+ * Callers must treat the result as secret: it belongs in the agent's own child
63
+ * process and nowhere else. It is never logged and never sent to the backend.
64
+ */
65
+ export function repoEnv(projectPath) {
66
+ const out = {};
67
+ for (const name of ENV_FILES) {
68
+ for (const [key, value] of Object.entries(
69
+ parseEnvFile(readIfExists(path.join(projectPath, name)))
70
+ )) {
71
+ // An empty value is a placeholder, not a value. `OPENAI_API_KEY=` is the
72
+ // normal shape of a committed `.env.example`-style line, and carrying the
73
+ // empty string forward spread it over a real key saved in the dashboard
74
+ // and left the variable unset -- so a run failed for want of a key that
75
+ // was available. Skipping empties keeps the documented precedence
76
+ // (shell, then repository, then stored) true at every layer.
77
+ if (value) out[key] = value;
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
54
83
  /** Minimal dotenv reader: KEY=value, ignoring comments, exports, and quotes. */
55
84
  export function parseEnvFile(text) {
56
85
  const out = {};