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 +315 -36
- package/bin/desktop.js +2 -2
- package/bin/detect.js +29 -0
- package/bin/eval.js +430 -22
- package/bin/eval_entry.js +762 -0
- package/bin/eval_harness.py +166 -5
- package/bin/eval_runtime.js +156 -0
- package/bin/eval_target.js +160 -113
- package/bin/eval_templates.js +684 -0
- package/bin/runner.js +42 -3
- package/bin/shared.js +2 -1
- package/bin/target.js +105 -3
- package/package.json +2 -2
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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
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
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
1774
|
-
//
|
|
1775
|
-
//
|
|
1776
|
-
//
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
dock.enable(
|
|
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 = {};
|