premanmcp 1.1.5 → 1.1.7

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 ADDED
@@ -0,0 +1,2043 @@
1
+ /**
2
+ * `preman` — the PreMan agent, in a terminal session.
3
+ *
4
+ * The agent already exists and already has every PreMan tool: it is the one
5
+ * behind the workbench chat. What it did not have was a terminal. A developer
6
+ * who lives in a shell had to open the app, or drive PreMan indirectly through
7
+ * another vendor's coding agent over MCP, to ask it anything.
8
+ *
9
+ * So this is the workbench conversation rendered for a terminal, over the SSE
10
+ * contract that surface already speaks
11
+ * (`POST /workbench/conversations/{id}/messages/stream`). Nothing about the
12
+ * agent, its tools, or its approval rules lives here — this module only turns
13
+ * `status`/`artifact`/`delta`/`done` events into lines and reads lines back.
14
+ * A capability added to chat shows up here on its next reply with no change.
15
+ *
16
+ * Two deliberate consequences of it being *the same conversation*:
17
+ * - It is durable and shared with the app. `/app` opens the very session you
18
+ * are typing into, and work started here is visible there.
19
+ * - Cancelling stops this renderer, not the turn. The server has no idea a
20
+ * reader walked away, so we say that rather than claiming an abort.
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { basename, join } from "node:path";
25
+ import { createInterface } from "node:readline/promises";
26
+
27
+ import { createSseParser } from "./runner.js";
28
+ import {
29
+ authenticateTerminal,
30
+ backendUrl,
31
+ callBackendJson,
32
+ cliInvocation,
33
+ describeFailure,
34
+ frontendUrl,
35
+ hasKeyAvailable,
36
+ makeArgs,
37
+ openUrl,
38
+ packageVersion,
39
+ resolveApiKey,
40
+ truncate,
41
+ } from "./shared.js";
42
+
43
+ export const AGENT_HELP = `
44
+ Agent session options:
45
+ --continue Resume your most recent conversation
46
+ --conversation <id> Resume one conversation by id
47
+ --workspace <id> Workspace to work in. Defaults to your primary workspace
48
+ --print Send the message given on the command line, then exit
49
+ --no-color Disable ANSI colour
50
+ `;
51
+
52
+ const SLASH_HELP = `Commands:
53
+ /help Show this list
54
+ /new Start a fresh conversation
55
+ /conversations List recent conversations
56
+ /resume <id> Switch to another conversation
57
+ /eval Measure your agent against the behaviours you turned on
58
+ /eval list Everything measurable, and what is switched on
59
+ /eval on|off <what> Switch behaviours on or off, by number or by id
60
+ /eval defaults Pick a few that suit this project, and switch them on
61
+ /eval doctor Check this machine can run evals before starting one
62
+ /app Open this conversation in the PreMan app
63
+ /clear Clear the screen
64
+ /exit Leave (Ctrl+D does the same)
65
+
66
+ Anything else you type goes to the PreMan agent.`;
67
+
68
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
69
+ const FRAME_MS = 80;
70
+
71
+ // Something to watch between pressing enter and the first word of an answer.
72
+ // The server only sends `status` once it has work to name, which can be a
73
+ // while, and an empty terminal in that gap reads as a session that has hung.
74
+ const THINKING = [
75
+ "locking in",
76
+ "testing",
77
+ "on it",
78
+ "thinking",
79
+ "digging in",
80
+ "wiring up",
81
+ "reading along",
82
+ ];
83
+ const THINK_MS = 220;
84
+ const CLEAR_LINE = "\r\u001b[2K";
85
+ const CLEAR_SCREEN = "\u001b[2J\u001b[H";
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";
93
+ const SHOW_CURSOR = "\u001b[?25h";
94
+ const RELEASE_REGION = "\u001b[r";
95
+
96
+ // How long a first Ctrl+C stays armed. Long enough to be a deliberate second
97
+ // press, short enough that one an hour ago cannot quit the session.
98
+ const QUIT_WINDOW_MS = 2000;
99
+
100
+ // The mark -- the PreMan "P", its bowl a closed ring with the figure standing
101
+ // in the counter -- and the width it needs. Traced from the brand asset the
102
+ // dashboard ships (PreMan-Dashboard/public/preman-logo-mark.png) rather than
103
+ // drawn by eye: the PNG is cropped to its ink and resampled to a half-block
104
+ // mosaic, two square pixels stacked per cell, which is the one glyph family
105
+ // that keeps the logo's aspect honest in a terminal cell. Twelve columns is
106
+ // the smallest size at which the ring still closes and the head stays clear
107
+ // of the stem; below it the counter fills in and the figure reads as a smudge.
108
+ //
109
+ // Still far narrower than the window, so the session's own details sit beside
110
+ // the mark rather than below a banner nobody reads twice. A terminal too
111
+ // narrow even for this gets the plain bold name.
112
+ const LOGO = [
113
+ " \u2584\u2588\u2588\u2588\u2588\u2588\u2588\u2584",
114
+ "\u2584\u2588\u2588\u2580 \u2580\u2588\u2588\u2584",
115
+ "\u2588\u2588 \u2588\u2588 \u2588\u2588",
116
+ "\u2588\u2588 \u2580\u2580 \u2584\u2588\u2588",
117
+ "\u2588\u2588 \u2584\u2588 \u2584\u2588\u2588\u2588",
118
+ "\u2588\u2588 \u2588\u2588 \u2580\u2580",
119
+ "\u2588\u2588 \u2588\u2588",
120
+ "\u2588\u2588 \u2588\u2588",
121
+ ];
122
+ const LOGO_WIDTH = 12;
123
+
124
+ // The docked composer, held at the bottom of the window while the transcript
125
+ // scrolls above it: the input line boxed between two rules, then the status
126
+ // strip. The lower rule is what separates what you are typing from the state
127
+ // underneath it, rather than letting the two run together.
128
+ const DOCK_ROWS = 4;
129
+
130
+ // What the terminal calls the agent it is talking to. The server decides which
131
+ // provider actually answers, so this is a product name, not a model id.
132
+ const MODEL_NAME = "PreMan Dawg 1.0";
133
+
134
+ // The tab name. Without this an editor's terminal list labels the session by
135
+ // its foreground process, which is `node` -- true, and useless next to three
136
+ // other tabs running the same binary. OSC 0 sets both icon and window title;
137
+ // clearing it on the way out hands naming back to the shell.
138
+ const TITLE = "preman";
139
+ const SET_TITLE = `\u001b]0;${TITLE}\u0007`;
140
+ const CLEAR_TITLE = "\u001b]0;\u0007";
141
+
142
+ // Flags that consume the token after them, so a positional message is not
143
+ // mistaken for a flag value (`preman "check /users" --workspace abc`).
144
+ const VALUE_FLAGS = new Set([
145
+ "--backend",
146
+ "--frontend",
147
+ "--api-key",
148
+ "--key",
149
+ "--workspace",
150
+ "--conversation",
151
+ ]);
152
+
153
+ /** The words that were not flags — the one-shot message, if there is one. */
154
+ export function positionals(raw = []) {
155
+ const out = [];
156
+ for (let i = 0; i < raw.length; i += 1) {
157
+ const token = String(raw[i]);
158
+ if (token.startsWith("-")) {
159
+ if (VALUE_FLAGS.has(token)) i += 1;
160
+ continue;
161
+ }
162
+ out.push(token);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /** `/resume abc` -> {name: "resume", rest: "abc"}; anything else -> null. */
168
+ export function parseSlash(line) {
169
+ const text = String(line || "").trim();
170
+ if (!text.startsWith("/")) return null;
171
+ const [word, ...rest] = text.slice(1).split(/\s+/);
172
+ return { name: word.toLowerCase(), rest: rest.join(" ").trim() };
173
+ }
174
+
175
+ // `llm` is the provider footer the app renders in a corner, and `intents` are
176
+ // replay steps for an editor this surface does not have. Both are noise in a
177
+ // transcript; note intents are surfaced separately because they carry words.
178
+ const SILENT_ARTIFACTS = new Set(["llm", "intents"]);
179
+
180
+ const ARTIFACT_LABELS = {
181
+ endpoints: "Endpoints",
182
+ hosted_mcp: "Hosted MCP",
183
+ connect_link: "Connect",
184
+ handoff: "Task queued",
185
+ report: "Test results",
186
+ test_report: "Test results",
187
+ stress_report: "Stress results",
188
+ test_scenarios: "Scenarios",
189
+ test_campaign: "Campaign",
190
+ agent_brief: "Investigation brief",
191
+ prd_document: "Document",
192
+ needs_api_key: "Action needed",
193
+ members: "Team",
194
+ };
195
+
196
+ function artifactUrl(artifact) {
197
+ for (const key of ["url", "manage_url", "install_url", "app_url"]) {
198
+ const value = artifact[key];
199
+ if (typeof value === "string" && /^https?:\/\//.test(value)) return value;
200
+ }
201
+ return "";
202
+ }
203
+
204
+ /**
205
+ * One line for a card the app would draw.
206
+ *
207
+ * Returns "" for anything this surface deliberately does not show, so callers
208
+ * filter on the return value rather than repeating the skip rules.
209
+ */
210
+ export function artifactLine(artifact) {
211
+ if (!artifact || typeof artifact !== "object") return "";
212
+ const type = String(artifact.type || "");
213
+ if (!type || SILENT_ARTIFACTS.has(type)) return "";
214
+ const label =
215
+ String(artifact.title || "").trim() || ARTIFACT_LABELS[type] || type.replace(/_/g, " ");
216
+ const url = artifactUrl(artifact);
217
+ return url ? `${truncate(label, 72)} — ${url}` : truncate(label, 100);
218
+ }
219
+
220
+ /** The permission questions in a finished turn, in the order they were asked. */
221
+ export function permissionQuestions(artifacts = []) {
222
+ return (Array.isArray(artifacts) ? artifacts : []).filter(
223
+ (item) =>
224
+ item &&
225
+ item.type === "permission_question" &&
226
+ Array.isArray(item.options) &&
227
+ item.options.length
228
+ );
229
+ }
230
+
231
+ function colourEnabled(args) {
232
+ if (args.has("--no-color") || args.has("--no-colour")) return false;
233
+ if (process.env.NO_COLOR) return false;
234
+ if (process.env.FORCE_COLOR) return true;
235
+ return Boolean(process.stdout.isTTY);
236
+ }
237
+
238
+ function makePaint(enabled) {
239
+ const wrap = (code) => (text) =>
240
+ enabled ? `\u001b[${code}m${text}\u001b[0m` : String(text);
241
+ return {
242
+ green: wrap("32"),
243
+ red: wrap("31"),
244
+ yellow: wrap("33"),
245
+ cyan: wrap("36"),
246
+ white: wrap("97"),
247
+ caret: wrap("1;97"),
248
+ // The band a submitted message sits on, so a glance separates what was
249
+ // asked from what came back. A background rather than a colour, because
250
+ // the text is the customer's own words and recolouring those makes them
251
+ // look like output from something. 256-colour: a terminal without it drops
252
+ // the band and prints the line plainly, which is the old behaviour rather
253
+ // than a broken one.
254
+ band: wrap("48;5;236"),
255
+ lime: wrap("92"),
256
+ dim: wrap("2"),
257
+ bold: wrap("1"),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * The live transcript for one turn.
263
+ *
264
+ * Tool activity is a spinner on a single rewritten line, because a turn that
265
+ * runs eight tools would otherwise leave eight stale "Running…" lines in the
266
+ * scrollback above an answer that made them all irrelevant. Everything that
267
+ * outlives the turn — cards, the reply — is printed normally and scrolls.
268
+ */
269
+ export function createRenderer({ stream = process.stdout, paint, live } = {}) {
270
+ const isLive = live ?? Boolean(stream.isTTY);
271
+ const ink = paint || makePaint(isLive);
272
+ let timer = null;
273
+ let frame = 0;
274
+ let label = "";
275
+ let stall = null;
276
+ let stallWord = 0;
277
+ let stallTick = 0;
278
+ let wroteReply = false;
279
+ let atLineStart = true;
280
+
281
+ function stopStall() {
282
+ if (!stall) return false;
283
+ clearInterval(stall);
284
+ stall = null;
285
+ return true;
286
+ }
287
+
288
+ function clearSpinner() {
289
+ if (timer) {
290
+ clearInterval(timer);
291
+ timer = null;
292
+ }
293
+ const stalled = stopStall();
294
+ if (isLive && (label || stalled)) stream.write(CLEAR_LINE);
295
+ label = "";
296
+ }
297
+
298
+ /**
299
+ * The stall animation: a word whose last letter runs on, then trailing dots.
300
+ * Both lengths move on every tick so it reads as something in progress
301
+ * rather than a frozen string, and the word itself changes every few beats.
302
+ */
303
+ function drawStall() {
304
+ if (!isLive) return;
305
+ const word = THINKING[stallWord % THINKING.length];
306
+ const last = word.slice(-1);
307
+ const runOn = last.repeat(2 + (stallTick % 3));
308
+ const dots = ".".repeat(1 + ((stallTick + 1) % 3));
309
+ stallTick += 1;
310
+ if (stallTick % 9 === 0) stallWord += 1;
311
+ const tint = ink.lime || ink.green;
312
+ stream.write(`${CLEAR_LINE}${tint(`${word}${runOn} ${dots}`)}`);
313
+ }
314
+
315
+ function draw() {
316
+ if (!isLive || !label) return;
317
+ const spin = FRAMES[frame % FRAMES.length];
318
+ frame += 1;
319
+ const room = Math.max(20, (stream.columns || 80) - 4);
320
+ stream.write(`${CLEAR_LINE}${ink.cyan(spin)} ${ink.dim(truncate(label, room))}`);
321
+ }
322
+
323
+ return {
324
+ /** Start stalling. Called on submit, before the stream says anything. */
325
+ thinking() {
326
+ if (!isLive || stall || timer || label) return;
327
+ drawStall();
328
+ stall = setInterval(drawStall, THINK_MS);
329
+ stall.unref?.();
330
+ },
331
+ status(text) {
332
+ const next = String(text || "").trim();
333
+ if (!next) return;
334
+ if (!isLive) {
335
+ // Piped output keeps every step: it is usually a log somebody reads
336
+ // after the fact, where the sequence is the whole value.
337
+ stream.write(`${ink.dim(`... ${next}`)}\n`);
338
+ return;
339
+ }
340
+ // A named step is better than a guess, so it replaces the stall.
341
+ if (stopStall()) stream.write(CLEAR_LINE);
342
+ label = next;
343
+ draw();
344
+ if (!timer) {
345
+ timer = setInterval(draw, FRAME_MS);
346
+ timer.unref?.();
347
+ }
348
+ },
349
+ artifact(item) {
350
+ const line = artifactLine(item);
351
+ if (!line) return;
352
+ clearSpinner();
353
+ if (!atLineStart) stream.write("\n");
354
+ stream.write(` ${ink.cyan("●")} ${line}\n`);
355
+ atLineStart = true;
356
+ },
357
+ delta(text) {
358
+ const chunk = String(text ?? "");
359
+ if (!chunk) return;
360
+ clearSpinner();
361
+ if (!wroteReply) {
362
+ stream.write("\n");
363
+ wroteReply = true;
364
+ }
365
+ stream.write(chunk);
366
+ atLineStart = chunk.endsWith("\n");
367
+ },
368
+ /** Print the persisted reply when the provider never streamed one. */
369
+ fallback(text) {
370
+ if (wroteReply) return;
371
+ const body = String(text || "").trim();
372
+ if (!body) return;
373
+ this.delta(body);
374
+ },
375
+ note(text) {
376
+ clearSpinner();
377
+ if (!atLineStart) stream.write("\n");
378
+ stream.write(`${ink.dim(String(text))}\n`);
379
+ atLineStart = true;
380
+ },
381
+ error(text) {
382
+ clearSpinner();
383
+ if (!atLineStart) stream.write("\n");
384
+ stream.write(`${ink.red(String(text))}\n`);
385
+ atLineStart = true;
386
+ },
387
+ end() {
388
+ clearSpinner();
389
+ if (!atLineStart) stream.write("\n");
390
+ if (wroteReply) stream.write("\n");
391
+ atLineStart = true;
392
+ wroteReply = false;
393
+ },
394
+ };
395
+ }
396
+
397
+ async function backendCall(args, method, route, options) {
398
+ const result = await callBackendJson(args, method, route, options);
399
+ if (!result.ok) throw new Error(describeFailure(result, `${method} ${route} failed`));
400
+ return result;
401
+ }
402
+
403
+ /**
404
+ * Stream one turn, handing every SSE payload to `onEvent`.
405
+ *
406
+ * The request is abandoned by signal rather than by dropping the reader, so a
407
+ * cancelled turn does not leave a half-read response holding the socket open.
408
+ */
409
+ export async function streamTurn({
410
+ backend,
411
+ token,
412
+ workspaceId,
413
+ conversationId,
414
+ content,
415
+ signal,
416
+ onEvent,
417
+ }) {
418
+ const url = new URL(
419
+ `workbench/conversations/${encodeURIComponent(conversationId)}/messages/stream`,
420
+ `${backend}/`
421
+ );
422
+ const headers = {
423
+ "Content-Type": "application/json",
424
+ Accept: "text/event-stream",
425
+ Authorization: `Bearer ${token}`,
426
+ };
427
+ if (workspaceId) headers["x-workspace-id"] = workspaceId;
428
+
429
+ const resp = await fetch(url, {
430
+ method: "POST",
431
+ headers,
432
+ body: JSON.stringify({ content, surface: "chat" }),
433
+ signal,
434
+ });
435
+
436
+ if (!resp.ok || !resp.body) {
437
+ const text = await resp.text().catch(() => "");
438
+ let detail = text;
439
+ try {
440
+ detail = describeFailure(JSON.parse(text), text);
441
+ } catch {
442
+ // Not JSON -- an upstream proxy page, most likely. Show what arrived.
443
+ }
444
+ throw new Error(`${resp.status} ${truncate(detail || "chat stream failed", 300)}`);
445
+ }
446
+
447
+ const push = createSseParser((_event, data) => onEvent(data));
448
+ const decoder = new TextDecoder();
449
+ for await (const chunk of resp.body) push(decoder.decode(chunk, { stream: true }));
450
+ }
451
+
452
+ /** Render one turn end to end. Returns the `done` payload, or null. */
453
+ async function runTurn({
454
+ args,
455
+ token,
456
+ workspaceId,
457
+ conversationId,
458
+ content,
459
+ renderer,
460
+ paint,
461
+ ask,
462
+ say,
463
+ onNote,
464
+ register,
465
+ }) {
466
+ const controller = new AbortController();
467
+ let done = null;
468
+ let cancelled = false;
469
+ const onSigint = () => {
470
+ cancelled = true;
471
+ controller.abort();
472
+ };
473
+ // Both doors again: `register` is the session's -- a keystroke readline ate
474
+ // reaches the turn through it -- and the signal handler is for the one-shot
475
+ // and `--print` paths, where there is no session and no readline at all.
476
+ register?.({ abort: onSigint });
477
+ process.on("SIGINT", onSigint);
478
+
479
+ renderer.thinking?.();
480
+
481
+ try {
482
+ await streamTurn({
483
+ backend: backendUrl(args),
484
+ token,
485
+ workspaceId,
486
+ conversationId,
487
+ content,
488
+ signal: controller.signal,
489
+ onEvent: (event) => {
490
+ const type = String(event?.type || "");
491
+ if (type === "status") {
492
+ renderer.status(event.label);
493
+ // The spinner is transient by design, so the strip is where the work
494
+ // stays readable while it happens.
495
+ if (onNote) onNote(event.label);
496
+ }
497
+ else if (type === "artifact") renderer.artifact(event.artifact);
498
+ else if (type === "delta") renderer.delta(event.text);
499
+ else if (type === "intents") {
500
+ // Replay steps belong to the editor. The `note` ones are prose the
501
+ // agent wrote for a person, so those are the only ones worth a line.
502
+ for (const intent of event.intents || []) {
503
+ if (intent?.type === "note" && intent.text) renderer.note(` ${intent.text}`);
504
+ }
505
+ } else if (type === "error") renderer.error(`PreMan: ${event.message || "turn failed"}`);
506
+ else if (type === "done") done = event;
507
+ },
508
+ });
509
+ } catch (error) {
510
+ if (cancelled) {
511
+ renderer.note(
512
+ " Stopped following this turn. PreMan is still working on it -- /app to watch it there."
513
+ );
514
+ } else {
515
+ renderer.error(` ${error.message}`);
516
+ }
517
+ } finally {
518
+ process.off("SIGINT", onSigint);
519
+ register?.(null);
520
+ }
521
+
522
+ if (done?.turn) renderer.fallback(done.turn.content);
523
+ renderer.end();
524
+ if (onNote) onNote("");
525
+ if (done?.turn && !cancelled) {
526
+ await askPermissions({
527
+ args,
528
+ token,
529
+ questions: permissionQuestions(done.turn.artifacts),
530
+ paint,
531
+ ask,
532
+ say,
533
+ });
534
+ }
535
+ return done;
536
+ }
537
+
538
+ /**
539
+ * Put a permission confirmation to the person at the keyboard.
540
+ *
541
+ * The option id is a signed token carrying the permission, the scope and the
542
+ * words it was asked about, so a client can only answer with something the
543
+ * server minted -- there is nothing here to get wrong except which option was
544
+ * pressed. Skipping is always available and is not a "no": an unanswered
545
+ * question can be asked again, a declined one is settled.
546
+ */
547
+ async function askPermissions({ args, token, questions, paint, ask, say }) {
548
+ if (!questions.length || !process.stdin.isTTY) return;
549
+ // In a docked session readline is already live and owns stdin, so a second
550
+ // interface here would fight it for keystrokes. `ask` is that one reader
551
+ // lent out for a question; without it (the one-shot path) we open our own.
552
+ const borrowed = typeof ask === "function";
553
+ const write = say || ((text) => process.stdout.write(text));
554
+ const rl = borrowed ? null : createInterface({ input: process.stdin, output: process.stdout });
555
+ const readAnswer = borrowed ? ask : (text) => rl.question(text);
556
+ try {
557
+ for (const question of questions) {
558
+ write(`\n${paint.yellow("PreMan needs a decision")}\n`);
559
+ if (question.prompt) write(` ${question.prompt}\n`);
560
+ question.options.forEach((option, index) => {
561
+ write(` ${paint.bold(String(index + 1))}. ${option.label}\n`);
562
+ });
563
+ write(paint.dim(" Enter to skip\n"));
564
+ const answer = String((await readAnswer(" > ")) || "").trim();
565
+ const picked = question.options[Number.parseInt(answer, 10) - 1];
566
+ if (!picked) {
567
+ write(paint.dim(" Left unanswered.\n"));
568
+ continue;
569
+ }
570
+ const result = await callBackendJson(args, "POST", "/workbench/permissions/answer", {
571
+ token,
572
+ json: { option_id: picked.id },
573
+ });
574
+ write(
575
+ result.ok
576
+ ? ` ${paint.green("✓")} ${picked.label}\n`
577
+ : ` ${paint.red(describeFailure(result, "could not record that answer"))}\n`
578
+ );
579
+ }
580
+ } finally {
581
+ if (rl) rl.close();
582
+ }
583
+ }
584
+
585
+ async function resolveWorkspace(args, token) {
586
+ const explicit = args.value("--workspace", "");
587
+ const result = await backendCall(args, "GET", "/workbench/workspace", {
588
+ token,
589
+ headers: explicit ? { "x-workspace-id": explicit } : undefined,
590
+ });
591
+ const workspace = result.workspace || {};
592
+ return {
593
+ id: String(workspace.id || explicit || ""),
594
+ name: String(workspace.name || "Personal"),
595
+ };
596
+ }
597
+
598
+ async function newConversation(args, token, workspaceId) {
599
+ const result = await backendCall(args, "POST", "/workbench/conversations", {
600
+ token,
601
+ json: { title: "Terminal session", surface: "chat" },
602
+ headers: workspaceId ? { "x-workspace-id": workspaceId } : undefined,
603
+ });
604
+ return { id: String(result.id), title: String(result.title || "Terminal session") };
605
+ }
606
+
607
+ async function listConversations(args, token, workspaceId) {
608
+ const result = await backendCall(args, "GET", "/workbench/conversations", {
609
+ token,
610
+ headers: workspaceId ? { "x-workspace-id": workspaceId } : undefined,
611
+ });
612
+ return Array.isArray(result.conversations) ? result.conversations : [];
613
+ }
614
+
615
+ async function openConversation(args, token, conversationId) {
616
+ const result = await backendCall(
617
+ args,
618
+ "GET",
619
+ `/workbench/conversations/${encodeURIComponent(conversationId)}`,
620
+ { token }
621
+ );
622
+ return { id: String(result.id), title: String(result.title || "Conversation") };
623
+ }
624
+
625
+ /**
626
+ * Which conversation this session types into.
627
+ *
628
+ * A new one by default, because a terminal session is a new piece of work far
629
+ * more often than it is a continuation, and a fresh transcript is the one thing
630
+ * a scrollback cannot give back.
631
+ */
632
+ async function resolveConversation(args, token, workspaceId) {
633
+ const explicit = args.value("--conversation", "");
634
+ if (explicit) return openConversation(args, token, explicit);
635
+ if (args.has("--continue") || args.has("-c")) {
636
+ const [latest] = await listConversations(args, token, workspaceId);
637
+ if (latest) return { id: String(latest.id), title: String(latest.title || "Conversation") };
638
+ }
639
+ return newConversation(args, token, workspaceId);
640
+ }
641
+
642
+ /**
643
+ * The mark, or the plain name when the window is too narrow for it.
644
+ *
645
+ * Monochrome, like the brand asset it is traced from: bold only, so the mark
646
+ * takes the terminal's own foreground rather than a hue of its own. That is
647
+ * the one choice that cannot come out invisible -- a fixed black disappears on
648
+ * a dark theme and a fixed white disappears on a light one, and a terminal
649
+ * does not say which it is. Bold also means there is no colour depth to
650
+ * negotiate, so the mark looks the same in a 16-colour terminal as in a
651
+ * truecolor one.
652
+ */
653
+ export function logo(paint, columns) {
654
+ const width = columns || 80;
655
+ if (width < LOGO_WIDTH + 2) return paint.bold("PreMan");
656
+ return LOGO.map((row) => paint.bold(row)).join("\n");
657
+ }
658
+
659
+ /**
660
+ * Hold the terminal, and give it back whatever happens to this process.
661
+ *
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`.
670
+ *
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.
677
+ *
678
+ * `exit` is registered too, as the last resort, which is why every write here
679
+ * is synchronous and every step is wrapped: an exit handler that throws turns a
680
+ * clean shutdown into a crash, and a stream that has already closed is an
681
+ * ordinary state at that point rather than an error.
682
+ */
683
+ export function holdTerminal({ stream = process.stdout, footer = () => "" } = {}) {
684
+ const live = Boolean(stream.isTTY);
685
+ const teardown = [];
686
+ const installed = [];
687
+ let restored = false;
688
+ // Who answers Ctrl+C. Null until the session has a keyboard of its own, and
689
+ // the default until then is the only safe one: put the terminal back and go.
690
+ let interrupted = null;
691
+
692
+ const safely = (fn) => {
693
+ try {
694
+ fn();
695
+ } catch {
696
+ // Nothing here can be reported anywhere useful: the screen is mid-repair
697
+ // and stderr may be the same broken terminal.
698
+ }
699
+ };
700
+
701
+ function restore() {
702
+ // Re-entrant on purpose. A second signal arriving while the first is being
703
+ // handled is normal -- somebody pressing Ctrl+C twice, or a SIGHUP landing
704
+ // behind a SIGTERM -- and the second must not replay half a teardown.
705
+ if (restored) return;
706
+ restored = true;
707
+ for (const fn of teardown.splice(0)) safely(fn);
708
+ if (live) safely(() => stream.write(`${RELEASE_REGION}${LEAVE_ALT}${SHOW_CURSOR}${CLEAR_TITLE}`));
709
+ safely(() => {
710
+ const text = footer();
711
+ if (text) stream.write(text);
712
+ });
713
+ }
714
+
715
+ const on = (event, handler) => {
716
+ process.on(event, handler);
717
+ installed.push([event, handler]);
718
+ };
719
+
720
+ return {
721
+ live,
722
+
723
+ /** Something to put back before the screen is handed over. */
724
+ onTeardown(fn) {
725
+ if (typeof fn === "function") teardown.push(fn);
726
+ },
727
+
728
+ /**
729
+ * Wire the deaths this process can see.
730
+ *
731
+ * SIGINT is deliberately not here: it is a conversation with the person at
732
+ * the keyboard rather than a death, and the session owns that. Everything
733
+ * else is the same answer -- put the terminal back, then die the way the
734
+ * signal says, because a process killed by SIGTERM should exit 143 and not
735
+ * pretend it finished.
736
+ */
737
+ watch() {
738
+ // SIGINT is installed here rather than left to the session because of the
739
+ // gap in between: from the moment the alternate screen is entered until
740
+ // readline exists, a press is the tty's own SIGINT and Node's default
741
+ // action kills the process by signal -- which does not run an `exit`
742
+ // handler, so the terminal was left on the alternate screen with no
743
+ // region released and no line saying what had happened. It is a window a
744
+ // few milliseconds wide, and it was the one death that did not restore.
745
+ on("SIGINT", () => {
746
+ if (interrupted) {
747
+ interrupted();
748
+ return;
749
+ }
750
+ restore();
751
+ process.exit(130);
752
+ });
753
+ on("SIGTERM", () => {
754
+ restore();
755
+ process.exit(143);
756
+ });
757
+ on("SIGHUP", () => {
758
+ restore();
759
+ process.exit(129);
760
+ });
761
+ const fatal = (error) => {
762
+ restore();
763
+ const message = error instanceof Error ? error.stack || error.message : String(error);
764
+ safely(() => process.stderr.write(`[preman] ${message}\n`));
765
+ process.exit(1);
766
+ };
767
+ on("uncaughtException", fatal);
768
+ on("unhandledRejection", fatal);
769
+ on("exit", () => restore());
770
+ return this;
771
+ },
772
+
773
+ /**
774
+ * Hand Ctrl+C to the session, now that there is somebody to answer it.
775
+ *
776
+ * Lent rather than added, so the two never both fire: a dispatcher that
777
+ * quits and a default that exits 130 would race to restore the same screen.
778
+ */
779
+ onInterrupt(fn) {
780
+ interrupted = typeof fn === "function" ? fn : null;
781
+ },
782
+
783
+ restore,
784
+
785
+ /** Give the terminal back and stop listening. Safe to call twice. */
786
+ release() {
787
+ restore();
788
+ for (const [event, handler] of installed.splice(0)) process.off(event, handler);
789
+ },
790
+ };
791
+ }
792
+
793
+ /**
794
+ * Hold the composer at the bottom of the window.
795
+ *
796
+ * A terminal has one cursor, so "input pinned below, transcript scrolling
797
+ * above" is not two panes — it is a scrolling region (DECSTBM) covering every
798
+ * row but the last two, plus the discipline of always leaving the cursor
799
+ * inside that region when a turn is printing. The bottom two rows sit outside
800
+ * the region, so nothing the agent writes can scroll them away.
801
+ *
802
+ * The reason this can be done with readline at all is that reading and
803
+ * printing never overlap here: the loop awaits a line, *then* runs the turn.
804
+ * So the composer only has to be live while `question` is pending, and the
805
+ * submitted line is echoed into the transcript on the way past -- otherwise it
806
+ * would vanish when the input row is cleared for the next prompt.
807
+ *
808
+ * Not a TTY (a pipe, `--print`, CI) means none of this applies and every method
809
+ * is a no-op, which keeps piped output byte-for-byte what it was.
810
+ */
811
+ export function createDock({
812
+ stream = process.stdout,
813
+ input = process.stdin,
814
+ paint,
815
+ status = "",
816
+ } = {}) {
817
+ const live = Boolean(stream.isTTY);
818
+ let open = false;
819
+ let rl = null;
820
+ // The keypress listener that keeps the furniture alive while somebody types,
821
+ // held so it can be taken off again -- a session that ends with a listener
822
+ // still on stdin keeps the process alive after the dock is gone.
823
+ let typing = null;
824
+ let queued = false;
825
+ let dirty = false;
826
+ let label = status;
827
+ let note = "";
828
+ // The window size the furniture was last drawn at. A resize has to erase the
829
+ // old rows before painting new ones, and after the event they are no longer
830
+ // derivable from the terminal -- it has already reflowed.
831
+ let painted = null;
832
+ // Where the transcript has got to, in screen coordinates. The terminal keeps
833
+ // this too, in its saved cursor, and that copy is the one the writes use --
834
+ // but a resize destroys it, so it is mirrored here to be put back. Null means
835
+ // nobody knows: before `enable`, and after any stretch the dock did not paint.
836
+ let at = null;
837
+
838
+ const size = () => ({
839
+ columns: stream.columns || 80,
840
+ rows: stream.rows || 24,
841
+ });
842
+
843
+ /** Last row the transcript may use; everything below belongs to the dock. */
844
+ const floor = () => Math.max(1, size().rows - DOCK_ROWS);
845
+
846
+ /**
847
+ * Follow the transcript's cursor through a chunk.
848
+ *
849
+ * The terminal is tracking the same thing in its saved cursor and does it
850
+ * perfectly, so this exists for one moment only: a resize, after which that
851
+ * saved position no longer means anything and the dock has to name a row to
852
+ * carry on from. It used to name the floor, which is why a session that had
853
+ * printed ten lines onto a fifty-row window jumped to the bottom of it and
854
+ * scrolled the banner away to say the eleventh.
855
+ *
856
+ * Only the finals that move the cursor are read. The erases and the colour
857
+ * runs that make up most of a transcript leave it exactly where it was, and
858
+ * a sequence this does not recognise is likelier to be one of those than a
859
+ * jump -- so the unknown case is "no movement" rather than a guess.
860
+ */
861
+ function advance(chunk) {
862
+ if (!at) return;
863
+ const { columns } = size();
864
+ const limit = floor();
865
+ let { row, column } = at;
866
+ // At the floor the region scrolls under the cursor rather than moving it:
867
+ // everything already printed goes up a row and the cursor stays put.
868
+ const down = () => {
869
+ if (row < limit) row += 1;
870
+ };
871
+ const text = String(chunk);
872
+ for (let i = 0; i < text.length; i += 1) {
873
+ const ch = text[i];
874
+ if (ch === "\u001b") {
875
+ const next = text[i + 1];
876
+ if (next === "[") {
877
+ let j = i + 2;
878
+ while (j < text.length && !/[@-~]/.test(text[j])) j += 1;
879
+ const params = text.slice(i + 2, j);
880
+ const final = text[j];
881
+ if (final === "H" || final === "f") {
882
+ const [r, c] = params.split(";");
883
+ row = Math.min(Math.max(Number(r) || 1, 1), limit);
884
+ column = Math.min(Math.max(Number(c) || 1, 1), columns);
885
+ } else if (final === "G") {
886
+ column = Math.min(Math.max(Number(params) || 1, 1), columns);
887
+ }
888
+ i = j;
889
+ continue;
890
+ }
891
+ if (next === "]") {
892
+ while (i < text.length && text[i] !== "\u0007") i += 1;
893
+ continue;
894
+ }
895
+ i += 1;
896
+ continue;
897
+ }
898
+ if (ch === "\r") {
899
+ column = 1;
900
+ continue;
901
+ }
902
+ if (ch === "\n") {
903
+ // ONLCR: stdout to a terminal turns a bare newline into CR+LF, so the
904
+ // column goes back to one. Modelling it as a pure index down would
905
+ // stair-step every line of the transcript to the right.
906
+ column = 1;
907
+ down();
908
+ continue;
909
+ }
910
+ if (ch === "\u0007") continue;
911
+ // Deferred wrap, the way a terminal does it: the glyph in the last column
912
+ // leaves the cursor on that column, and the *next* one moves the line on.
913
+ if (column > columns) {
914
+ column = 1;
915
+ down();
916
+ }
917
+ column += 1;
918
+ }
919
+ at = { row, column };
920
+ }
921
+
922
+ /**
923
+ * Is there a window here to dock in at all?
924
+ *
925
+ * Four reserved rows and no check against the window's height meant a short
926
+ * one produced arithmetic rather than layout: three rows emitted
927
+ * `ESC[0;1H` and two emitted `ESC[-1;1H`, a malformed CSI whose bytes some
928
+ * terminals print. Below the floor the dock simply does not paint -- the
929
+ * session falls back to being a plain prompt, which is what it already is on
930
+ * a pipe -- and `resize` brings it back the moment the window can hold it.
931
+ */
932
+ const roomy = () => size().rows >= DOCK_ROWS + 2;
933
+
934
+ /** The rule above the composer. */
935
+ function chrome() {
936
+ const { columns, rows } = size();
937
+ painted = { columns, rows };
938
+ stream.write(`\u001b[${rows - 3};1H\u001b[2K${paint.dim("\u2500".repeat(columns))}`);
939
+ }
940
+
941
+ /**
942
+ * The rule under the composer, and the status strip under that.
943
+ *
944
+ * These are painted *after* the composer rather than with the rule above it,
945
+ * because readline redraws its line with an erase-to-end-of-screen: anything
946
+ * written below the input row before `prompt()` is wiped the moment the
947
+ * prompt refreshes, which left the session showing a bare rule above the
948
+ * caret and nothing -- no lower rule, no model name -- beneath it.
949
+ */
950
+ function under() {
951
+ const { columns, rows } = size();
952
+ const strip = note ? `${label} \u00b7 ${note}` : label;
953
+ stream.write(`\u001b[${rows - 1};1H\u001b[2K${paint.dim("\u2500".repeat(columns))}`);
954
+ const tint = paint.lime || paint.dim;
955
+ stream.write(`\u001b[${rows};1H\u001b[2K${tint(truncate(strip, columns))}`);
956
+ }
957
+
958
+ /**
959
+ * Put the composer back under the cursor readline thinks it has.
960
+ *
961
+ * `prompt(true)` is what redraws the prompt *and* whatever has been typed so
962
+ * far, which is the whole point: a line half-typed while the agent was
963
+ * talking has to survive every write that happened underneath it.
964
+ */
965
+ function composer() {
966
+ const { rows } = size();
967
+ stream.write(`\u001b[${rows - 2};1H\u001b[2K`);
968
+ // A closed readline is the ordinary state on the way out: quitting mid-turn
969
+ // closes it while the answer is still arriving, and every fragment after
970
+ // that would ask it to redraw a prompt it no longer has. Node answers that
971
+ // with a throw, which surfaced as a bare "readline was closed" on a session
972
+ // that had in fact just done what it was told.
973
+ if (rl && !rl.closed) rl.prompt(true);
974
+ under();
975
+ caret();
976
+ }
977
+
978
+ /**
979
+ * Put the cursor back where the typist left it.
980
+ *
981
+ * The furniture below the composer is addressed absolutely, and readline goes
982
+ * on typing wherever the cursor was last put -- so every repaint has to end
983
+ * by handing the position back.
984
+ */
985
+ function caret() {
986
+ const { columns, rows } = size();
987
+ const at = (rl && !rl.closed ? rl.getCursorPos?.() : null) || { rows: 0, cols: 0 };
988
+ // Bounded by the window, not pinned to the composer's row: a line long
989
+ // enough to wrap genuinely occupies the rows under it -- readline drew it
990
+ // there -- so the caret has to follow it or the next keystroke lands
991
+ // somewhere else entirely. What this stops is the row running off the
992
+ // bottom of the screen, which is where an unbounded offset sent it.
993
+ const row = Math.min(rows, Math.max(1, rows - 2 + Math.max(0, at.rows || 0)));
994
+ const column = Math.min(Math.max(1, (at.cols || 0) + 1), Math.max(1, columns));
995
+ stream.write(`\u001b[${row};${column}H`);
996
+ }
997
+
998
+ /**
999
+ * Repaint the furniture, at most once a tick.
1000
+ *
1001
+ * A streamed reply arrives as dozens of fragments a second and every one of
1002
+ * them used to repaint two full-width rules, the status strip, readline's
1003
+ * line and the cursor -- six hundred bytes of scenery per token on a wide
1004
+ * window, for a screen that looks identical each time. That is the shape of
1005
+ * the corruption seen in real sessions: lines arriving with their middles
1006
+ * missing, which is what an emulator does when it cannot keep up.
1007
+ *
1008
+ * So the transcript is written immediately -- it is the thing somebody is
1009
+ * reading -- and the scenery is marked dirty and painted once when the tick
1010
+ * drains. Callers that change what the furniture *says* still paint
1011
+ * synchronously, because their contract is that the change is on screen when
1012
+ * they return.
1013
+ */
1014
+ function repaint() {
1015
+ if (!open || dirty) return;
1016
+ dirty = true;
1017
+ setImmediate(() => {
1018
+ dirty = false;
1019
+ if (!open) return;
1020
+ frame();
1021
+ });
1022
+ }
1023
+
1024
+ /** Every row of furniture, in the order the terminal needs them. */
1025
+ function frame() {
1026
+ if (!roomy()) return;
1027
+ chrome();
1028
+ composer();
1029
+ }
1030
+
1031
+ function listen() {
1032
+ if (!live || typing) return;
1033
+ // Typing is the other thing that erases the furniture, and it was the
1034
+ // hole this dock had: `write`, `resize` and the rest all repaint, but a
1035
+ // keystroke goes straight into readline, which redraws its line with an
1036
+ // erase-to-end-of-screen and takes the lower rule and the status strip
1037
+ // with it. Nothing here is told, so the rows stayed blank until the next
1038
+ // event -- which is why the bottom line vanished mid-sentence and came
1039
+ // back when the agent next said something.
1040
+ //
1041
+ // readline has no "I have just redrawn" hook, so the repaint rides the
1042
+ // keypress that caused it. This listener is registered after readline's
1043
+ // own, so it runs after the line has been redrawn, and it is coalesced
1044
+ // to one repaint per tick: a pasted line is hundreds of keypresses, and
1045
+ // repainting a full-width rule for each of them is a lot of bytes to
1046
+ // send a terminal for one visual result.
1047
+ typing = () => {
1048
+ if (!open || queued) return;
1049
+ queued = true;
1050
+ setImmediate(() => {
1051
+ queued = false;
1052
+ // `roomy` for the same reason every other painter checks it: a window
1053
+ // too short to hold the dock is one where these rows do not exist, and
1054
+ // painting them anyway was the one path that could still address row
1055
+ // zero after the floor was added.
1056
+ if (!open || !roomy()) return;
1057
+ under();
1058
+ caret();
1059
+ });
1060
+ };
1061
+ input?.on?.("keypress", typing);
1062
+ }
1063
+
1064
+ return {
1065
+ live,
1066
+
1067
+ /**
1068
+ * Reserve the bottom rows and remember where the transcript is.
1069
+ *
1070
+ * `row` is where the transcript has got to on a screen the caller knows the
1071
+ * state of -- a session that has just cleared the screen and printed a
1072
+ * banner knows exactly that. Without it the screen is assumed to be full,
1073
+ * which is the honest reading when the dock is coming back up after
1074
+ * something else owned the terminal: scroll to make room for the furniture
1075
+ * and carry on at the floor.
1076
+ */
1077
+ enable({ row } = {}) {
1078
+ if (!live || open) return;
1079
+ open = true;
1080
+ listen();
1081
+ stream.write(SET_TITLE);
1082
+ // Whatever a previous life left here says nothing about this screen.
1083
+ at = null;
1084
+ if (!roomy()) return;
1085
+ if (row == null) {
1086
+ stream.write("\n".repeat(DOCK_ROWS));
1087
+ at = { row: floor(), column: 1 };
1088
+ } else {
1089
+ at = { row: Math.min(Math.max(1, row), floor()), column: 1 };
1090
+ }
1091
+ stream.write(`\u001b[1;${floor()}r`);
1092
+ stream.write(`\u001b[${at.row};${at.column}H`);
1093
+ stream.write("\u001b7");
1094
+ frame();
1095
+ },
1096
+
1097
+ attach(readline) {
1098
+ rl = readline;
1099
+ listen();
1100
+ },
1101
+
1102
+ /** Repaint furniture and composer, e.g. after the prompt text changes. */
1103
+ refresh() {
1104
+ if (!open) return;
1105
+ frame();
1106
+ },
1107
+
1108
+ setStatus(text) {
1109
+ label = text;
1110
+ if (open) frame();
1111
+ },
1112
+
1113
+ /**
1114
+ * Every byte of transcript goes through here.
1115
+ *
1116
+ * The saved cursor (DECSC/DECRC) is the transcript's own position, which
1117
+ * matters because a streamed reply arrives as fragments with no newline —
1118
+ * forcing column 1 between them would overprint the line instead of
1119
+ * continuing it. So: restore, write, save, then repaint the furniture.
1120
+ */
1121
+ write(chunk) {
1122
+ if (!open || !roomy()) {
1123
+ // Nothing is following the cursor down a screen the dock does not own,
1124
+ // so whatever was tracked is now a guess. Said rather than kept: a
1125
+ // window that grows back is better off assuming the transcript filled
1126
+ // it than resuming at a row from before it went blind.
1127
+ at = null;
1128
+ stream.write(chunk);
1129
+ return;
1130
+ }
1131
+ // One write rather than three: the restore, the chunk and the save are a
1132
+ // single sequence to the terminal, so nothing can land between them.
1133
+ stream.write(`\u001b8${chunk}\u001b7`);
1134
+ advance(chunk);
1135
+ repaint();
1136
+ },
1137
+
1138
+ /** Leave the same trail a normal REPL would for a line just submitted. */
1139
+ echo(text) {
1140
+ if (!open) return;
1141
+ // Plain inside the band, deliberately: every painter here ends with a
1142
+ // reset, and a reset mid-line takes the background off with it -- a
1143
+ // half-highlighted row reads as a rendering fault. Padded to the window
1144
+ // so the band is a row rather than a highlight around the words. A line
1145
+ // long enough to wrap carries the background across every row it takes,
1146
+ // the last of them ending where the words do, because the band opens
1147
+ // once and closes once and the terminal wraps what is between.
1148
+ const { columns } = size();
1149
+ const line = `\u276f ${text}`;
1150
+ const pad = " ".repeat(Math.max(0, columns - printable(line)));
1151
+ this.write(`${paint.band(`${line}${pad}`)}\n`);
1152
+ },
1153
+
1154
+ /** A stdout stand-in for the renderer, so its writes land in the region. */
1155
+ proxy() {
1156
+ if (!open) return stream;
1157
+ const dock = this;
1158
+ return {
1159
+ write(chunk) {
1160
+ dock.write(chunk);
1161
+ return true;
1162
+ },
1163
+ get isTTY() {
1164
+ return stream.isTTY;
1165
+ },
1166
+ get columns() {
1167
+ return stream.columns;
1168
+ },
1169
+ get rows() {
1170
+ return stream.rows;
1171
+ },
1172
+ };
1173
+ },
1174
+
1175
+ /**
1176
+ * Follow a resize.
1177
+ *
1178
+ * Dragging a window edge fires this many times a second, and each event
1179
+ * arrives *after* the terminal has already reflowed -- so the rows the
1180
+ * furniture was last drawn on are now ordinary content sitting in the
1181
+ * scrollback. Painting the new chrome without erasing the old is what
1182
+ * smears rules and carets down the screen. Releasing the region first is
1183
+ * what makes the old rows addressable at all: inside a scrolling region
1184
+ * the bottom rows cannot be cursored to.
1185
+ */
1186
+ resize() {
1187
+ if (!open) return;
1188
+ stream.write(RELEASE_REGION);
1189
+ if (painted) {
1190
+ // Only the rows the furniture was on. It used to erase from there to
1191
+ // the end of the screen, which is fine when a window shrinks and
1192
+ // destroys live transcript when it grows: the rows between the old
1193
+ // dock and the new floor hold the answer somebody is reading.
1194
+ const top = Math.max(1, Math.min(painted.rows, size().rows) - DOCK_ROWS + 1);
1195
+ for (let row = top; row <= Math.min(painted.rows, size().rows); row++) {
1196
+ stream.write(`\u001b[${row};1H\u001b[2K`);
1197
+ }
1198
+ }
1199
+ painted = null;
1200
+ if (!roomy()) return;
1201
+ stream.write(`\u001b[1;${floor()}r`);
1202
+ // Where the transcript actually is, clamped into the new window -- not
1203
+ // the floor, which is what this used to say. Naming the floor meant every
1204
+ // drag of a window edge moved the transcript to the bottom of it, so the
1205
+ // next line printed scrolled the region and the banner climbed a row
1206
+ // towards the top and off. On a session that had barely started, that is
1207
+ // the whole screen going blank above a reply pinned to the bottom.
1208
+ const { columns } = size();
1209
+ at = {
1210
+ row: Math.min(Math.max(1, at?.row ?? floor()), floor()),
1211
+ column: Math.min(Math.max(1, at?.column ?? 1), columns),
1212
+ };
1213
+ stream.write(`\u001b[${at.row};${at.column}H`);
1214
+ stream.write("\u001b7");
1215
+ frame();
1216
+ },
1217
+
1218
+ /** What the agent is doing right now, shown on the strip during a turn. */
1219
+ setNote(text) {
1220
+ note = String(text || "");
1221
+ if (open) frame();
1222
+ },
1223
+
1224
+ /** Give the rows back, or the shell prompt inherits a clipped screen. */
1225
+ disable() {
1226
+ // The listener goes first and outside the open check: `attach` arms it,
1227
+ // and a dock that was attached and never enabled would otherwise keep a
1228
+ // `keypress` listener on stdin for the life of the process -- which is
1229
+ // the hazard this method exists to prevent, arriving by another door.
1230
+ if (typing) {
1231
+ input?.off?.("keypress", typing);
1232
+ typing = null;
1233
+ }
1234
+ if (!open) return;
1235
+ open = false;
1236
+ at = null;
1237
+ const { rows } = size();
1238
+ stream.write("\u001b[r");
1239
+ stream.write(`\u001b[${rows};1H\u001b[2K`);
1240
+ stream.write(CLEAR_TITLE);
1241
+ },
1242
+ };
1243
+ }
1244
+
1245
+ /** Printable width, ignoring the colour codes wrapped around the text. */
1246
+ function printable(text) {
1247
+ return [...text.replace(/\u001b\[[0-9;]*m/g, "")].length;
1248
+ }
1249
+
1250
+ /**
1251
+ * Set two blocks of rows side by side, the left one padded to its own width.
1252
+ *
1253
+ * Measuring has to ignore the escape codes: `paint.bold(row)` is several
1254
+ * bytes longer than the glyphs it wraps, and padding to `.length` would step
1255
+ * the right-hand column in and out by that much on every row.
1256
+ */
1257
+ function beside(left, right, gap = 3) {
1258
+ const width = Math.max(...left.map(printable));
1259
+ const rows = [];
1260
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
1261
+ const mark = left[i] ?? "";
1262
+ const detail = right[i] ?? "";
1263
+ if (!detail) {
1264
+ rows.push(mark);
1265
+ continue;
1266
+ }
1267
+ rows.push(`${mark}${" ".repeat(width - printable(mark) + gap)}${detail}`);
1268
+ }
1269
+ return rows;
1270
+ }
1271
+
1272
+ export function banner({ paint, workspace, backend, conversation, columns }) {
1273
+ const version = packageVersion();
1274
+ const cli = cliInvocation();
1275
+ const width = columns ?? process.stdout.columns ?? 80;
1276
+ const details = [
1277
+ `${paint.bold("PreMan")}${version ? paint.dim(` v${version}`) : ""}`,
1278
+ paint.dim(`${workspace.name} - ${backend.replace(/^https?:\/\//, "")}`),
1279
+ ];
1280
+ if (conversation?.title) details.push(paint.dim(`Conversation: ${conversation.title}`));
1281
+ const mark = logo(paint, width).split("\n");
1282
+ // Side by side while both fit; stacked when the window is too narrow, which
1283
+ // is the same answer the wordmark used to give -- just at a far lower bar.
1284
+ const widest = Math.max(...details.map(printable));
1285
+ const lines =
1286
+ width >= LOGO_WIDTH + 3 + widest ? beside(mark, details) : [...mark, "", ...details];
1287
+ lines.push(
1288
+ "",
1289
+ "Ask PreMan to discover endpoints, generate and run tests, or deploy a hosted MCP.",
1290
+ paint.dim(`/help for commands - /exit to leave - ${cli} --help for the rest of the CLI`),
1291
+ ""
1292
+ );
1293
+ return `${lines.join("\n")}\n`;
1294
+ }
1295
+
1296
+ /**
1297
+ * Read a line, resolving to null when stdin ends (EOF, a pipe, Ctrl+D).
1298
+ *
1299
+ * Both guards are for the same moment: stdin running out while a turn was in
1300
+ * flight. By the time we ask again the interface is already closed, so a
1301
+ * `close` listener registered now would never fire and `question` rejects
1302
+ * outright -- which used to surface as a bare "readline was closed" error on a
1303
+ * session that had in fact just ended normally.
1304
+ */
1305
+ async function nextLine(rl, prompt) {
1306
+ if (rl.closed) return null;
1307
+ const closed = new Promise((resolve) => rl.once("close", () => resolve(null)));
1308
+ try {
1309
+ return await Promise.race([rl.question(prompt), closed]);
1310
+ } catch {
1311
+ return null;
1312
+ }
1313
+ }
1314
+
1315
+ /** The catalog of what this account can measure, with its on/off state. */
1316
+ async function behaviorCatalog(args, token) {
1317
+ const result = await callBackendJson(args, "GET", "/eval-behaviors", { token });
1318
+ if (!result.ok) {
1319
+ throw new Error(
1320
+ `could not read what this account measures (${result.status_code}): ${describeFailure(result)}`
1321
+ );
1322
+ }
1323
+ return result;
1324
+ }
1325
+
1326
+ /**
1327
+ * One line per behaviour: number, state, id, and what it is.
1328
+ *
1329
+ * Numbered because the catalog is fifty-odd entries deep and
1330
+ * `library:explicit_constraint_violation_failures` is not something anybody is
1331
+ * going to type correctly at a prompt. The number is the row's position in this
1332
+ * same response, so `/eval on 12` re-reads the catalog and counts to twelve --
1333
+ * the list is files that ship with the deployment plus this account's own
1334
+ * entries, so it is the same twelve unless the account changed in between.
1335
+ */
1336
+ function behaviorLines(catalog, paint) {
1337
+ const rows = catalog.behaviors || [];
1338
+ if (!rows.length) return [paint.dim(" nothing in the catalog for this account.")];
1339
+ const width = Math.max(...rows.map((entry) => String(entry.id).length));
1340
+ const digits = String(rows.length).length;
1341
+ return rows.map((entry, index) => {
1342
+ const number = paint.dim(String(index + 1).padStart(digits));
1343
+ const mark = entry.enabled ? paint.green("●") : paint.dim("○");
1344
+ const id = String(entry.id).padEnd(width);
1345
+ const title = entry.title || entry.name || "";
1346
+ const source = entry.source ? paint.dim(` (${entry.source})`) : "";
1347
+ return ` ${number} ${mark} ${entry.enabled ? id : paint.dim(id)} ${title}${source}`;
1348
+ });
1349
+ }
1350
+
1351
+ /**
1352
+ * Work out which behaviours somebody meant.
1353
+ *
1354
+ * Four ways of saying it, because both the ids and the list are long: `12`,
1355
+ * `3-7`, `all`, and any text that names one -- the full id, the bare name, or
1356
+ * enough of either to be unambiguous. `sycophancy` is one behaviour;
1357
+ * `failures` is thirty, and thirty is reported as ambiguous rather than picking
1358
+ * the first, because what follows spends a provider bill per behaviour.
1359
+ */
1360
+ export function pickBehaviors(rows, tokens) {
1361
+ const picked = new Map();
1362
+ const unknown = [];
1363
+ const ambiguous = [];
1364
+ const take = (entry) => {
1365
+ if (entry) picked.set(entry.id, entry);
1366
+ };
1367
+
1368
+ for (const raw of tokens) {
1369
+ const token = String(raw || "").trim();
1370
+ if (!token) continue;
1371
+
1372
+ if (token.toLowerCase() === "all") {
1373
+ rows.forEach(take);
1374
+ continue;
1375
+ }
1376
+
1377
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
1378
+ if (range) {
1379
+ const [from, to] = [Number(range[1]), Number(range[2])];
1380
+ const [low, high] = from <= to ? [from, to] : [to, from];
1381
+ let hit = false;
1382
+ for (let n = low; n <= high; n++) {
1383
+ if (rows[n - 1]) {
1384
+ take(rows[n - 1]);
1385
+ hit = true;
1386
+ }
1387
+ }
1388
+ if (!hit) unknown.push(token);
1389
+ continue;
1390
+ }
1391
+
1392
+ if (/^\d+$/.test(token)) {
1393
+ const entry = rows[Number(token) - 1];
1394
+ if (entry) take(entry);
1395
+ else unknown.push(token);
1396
+ continue;
1397
+ }
1398
+
1399
+ const needle = token.toLowerCase();
1400
+ const exact = rows.find(
1401
+ (entry) =>
1402
+ String(entry.id).toLowerCase() === needle ||
1403
+ String(entry.name || "").toLowerCase() === needle
1404
+ );
1405
+ if (exact) {
1406
+ take(exact);
1407
+ continue;
1408
+ }
1409
+ const matches = rows.filter(
1410
+ (entry) =>
1411
+ String(entry.id).toLowerCase().includes(needle) ||
1412
+ String(entry.title || "").toLowerCase().includes(needle)
1413
+ );
1414
+ if (matches.length === 1) take(matches[0]);
1415
+ else if (matches.length) ambiguous.push({ token, matches });
1416
+ else unknown.push(token);
1417
+ }
1418
+
1419
+ // Catalog order, not the order the words were typed: what gets switched on is
1420
+ // then reported in the same order as the numbered list it was read off.
1421
+ const at = new Map(rows.map((entry, index) => [entry.id, index]));
1422
+ return {
1423
+ picked: [...picked.values()].sort((a, b) => at.get(a.id) - at.get(b.id)),
1424
+ unknown,
1425
+ ambiguous,
1426
+ };
1427
+ }
1428
+
1429
+ /**
1430
+ * Turn behaviours on and off without leaving the terminal.
1431
+ *
1432
+ * The catalog, the toggles and the custom descriptions are all already HTTP --
1433
+ * the dashboard has no privileged path to them. What was missing was a way to
1434
+ * reach them from here, which is why `/eval` could only ever report that
1435
+ * nothing was switched on and then send you to a web page to fix it.
1436
+ */
1437
+ async function manageBehaviors(argv, { args, token, paint, say }) {
1438
+ const [sub, ...rest] = argv;
1439
+
1440
+ if (sub === "list") {
1441
+ const catalog = await behaviorCatalog(args, token);
1442
+ const on = (catalog.behaviors || []).filter((entry) => entry.enabled).length;
1443
+ say(`${on} of ${(catalog.behaviors || []).length} switched on\n`);
1444
+ say(`${behaviorLines(catalog, paint).join("\n")}\n`);
1445
+ say(
1446
+ paint.dim(
1447
+ ` /eval on 3 12 or /eval on sycophancy to switch those on, /eval to run what is on\n\n`
1448
+ )
1449
+ );
1450
+ return;
1451
+ }
1452
+
1453
+ if (sub === "on" || sub === "off") {
1454
+ if (!rest.length) {
1455
+ say(paint.dim(`Usage: /eval ${sub} <number, range, id or all>\n\n`));
1456
+ return;
1457
+ }
1458
+ const enabled = sub === "on";
1459
+ const catalog = await behaviorCatalog(args, token);
1460
+ const { picked, unknown, ambiguous } = pickBehaviors(catalog.behaviors || [], rest);
1461
+
1462
+ for (const { token: word, matches } of ambiguous) {
1463
+ const names = matches.slice(0, 4).map((entry) => entry.id).join(", ");
1464
+ const more = matches.length > 4 ? `, and ${matches.length - 4} more` : "";
1465
+ say(`${paint.yellow(`${word} matches ${matches.length}:`)} ${names}${more}\n`);
1466
+ }
1467
+ for (const word of unknown) say(`${paint.yellow(`nothing here is called ${word}`)}\n`);
1468
+
1469
+ if (!picked.length) {
1470
+ say(paint.dim(" /eval list to see the numbers\n\n"));
1471
+ return;
1472
+ }
1473
+
1474
+ await applyToggles(picked, enabled, { args, token, paint, say });
1475
+ return;
1476
+ }
1477
+
1478
+ if (sub === "defaults") {
1479
+ await switchOnDefaults({ args, token, paint, say });
1480
+ return;
1481
+ }
1482
+
1483
+ say(paint.dim("Usage: /eval [doctor|list|defaults|on <what>|off <what>|--only <id>]\n\n"));
1484
+ }
1485
+
1486
+ /**
1487
+ * Switch a selection on or off, one PUT each, saying what happened to each.
1488
+ *
1489
+ * One request per behaviour because that is the contract the dashboard uses
1490
+ * too -- there is no bulk toggle, and inventing one here would be a second
1491
+ * place the rule "a toggle is refused for an id not in the catalog" is
1492
+ * enforced. A row already in the state being asked for is said and not sent,
1493
+ * so a selection of twelve does not report twelve changes when it made two.
1494
+ */
1495
+ async function applyToggles(picked, enabled, { args, token, paint, say }) {
1496
+ const word = enabled ? "on" : "off";
1497
+ let changed = 0;
1498
+ for (const entry of picked) {
1499
+ if (Boolean(entry.enabled) === enabled) {
1500
+ say(paint.dim(` ${entry.id} already ${word}\n`));
1501
+ continue;
1502
+ }
1503
+ const result = await callBackendJson(
1504
+ args,
1505
+ "PUT",
1506
+ `/eval-behaviors/${encodeURIComponent(entry.id)}`,
1507
+ { token, json: { enabled } }
1508
+ );
1509
+ if (!result.ok) {
1510
+ say(`${paint.red(describeFailure(result, `could not switch ${entry.id} ${word}`))}\n`);
1511
+ continue;
1512
+ }
1513
+ changed += 1;
1514
+ say(`${paint.green("✓")} ${entry.id} switched ${word}\n`);
1515
+ }
1516
+ if (changed && enabled) say(paint.dim(" /eval to run what is on\n"));
1517
+ say("\n");
1518
+ }
1519
+
1520
+ /**
1521
+ * What to send when asking which behaviours suit this project.
1522
+ *
1523
+ * "Which of fifty behaviours matter here" cannot be answered from an account
1524
+ * id, so something has to describe the agent under test. These are the files a
1525
+ * project already uses to say what it is, bounded hard: a README's prose, a
1526
+ * package or project description, and the directory's own name. Nothing is read
1527
+ * that a repository does not publish about itself -- no source, no env files --
1528
+ * because this text leaves the machine.
1529
+ */
1530
+ export function projectBlurb(root = process.cwd()) {
1531
+ const read = (name) => {
1532
+ try {
1533
+ return readFileSync(join(root, name), "utf8");
1534
+ } catch {
1535
+ return "";
1536
+ }
1537
+ };
1538
+
1539
+ const parts = [`Directory: ${basename(root)}`];
1540
+
1541
+ try {
1542
+ const pkg = JSON.parse(read("package.json") || "{}");
1543
+ if (pkg.name || pkg.description) {
1544
+ parts.push(`Package: ${[pkg.name, pkg.description].filter(Boolean).join(" - ")}`);
1545
+ }
1546
+ } catch {
1547
+ // A package.json that does not parse says nothing about the project.
1548
+ }
1549
+
1550
+ const described = /^\s*description\s*=\s*["'](.+?)["']/m.exec(read("pyproject.toml"));
1551
+ if (described) parts.push(`Project: ${described[1]}`);
1552
+
1553
+ const readme = read("README.md") || read("readme.md");
1554
+ if (readme) {
1555
+ const prose = readme
1556
+ .replace(/```[\s\S]*?```/g, "")
1557
+ .split("\n")
1558
+ // Badges and raw HTML are a header rather than a description, and on a
1559
+ // lot of READMEs they are most of the first screen.
1560
+ .filter((line) => !/^\s*[[!<]/.test(line))
1561
+ .join("\n")
1562
+ .trim();
1563
+ if (prose) parts.push(`README:\n${prose.slice(0, 1500)}`);
1564
+ }
1565
+
1566
+ return parts.join("\n\n").slice(0, 3500);
1567
+ }
1568
+
1569
+ /**
1570
+ * Ask which behaviours suit this project, then switch them on.
1571
+ *
1572
+ * Two requests, deliberately not one: `/eval-behaviors/recommend` chooses and
1573
+ * the existing toggle applies, so an enabled behaviour is always something this
1574
+ * client asked for by id rather than something a model turn left behind. The
1575
+ * shortlist is printed with its reasons before it is applied, because "a model
1576
+ * picked these" is a claim the person paying for the runs should be able to
1577
+ * read and disagree with.
1578
+ */
1579
+ async function switchOnDefaults({ args, token, paint, say }) {
1580
+ say(paint.dim("Reading this project and choosing what to measure...\n"));
1581
+ const advice = await callBackendJson(args, "POST", "/eval-behaviors/recommend", {
1582
+ token,
1583
+ json: { project: projectBlurb(), limit: 5 },
1584
+ });
1585
+ if (!advice.ok) {
1586
+ say(`${paint.red(describeFailure(advice, "could not work out what to switch on"))}\n\n`);
1587
+ return;
1588
+ }
1589
+
1590
+ const picks = advice.behaviors || [];
1591
+ if (!picks.length) {
1592
+ say(paint.dim("Nothing in the catalog for this account.\n\n"));
1593
+ return;
1594
+ }
1595
+
1596
+ // Which of the two produced this list, said plainly: a curated fallback
1597
+ // presented as a reading of this project would be a claim nobody made.
1598
+ say(
1599
+ advice.source === "model"
1600
+ ? `${picks.length} to start with, chosen for this project:\n`
1601
+ : `${picks.length} to start with. Nothing read this project -- no eval provider ` +
1602
+ `key on this account -- so these are the usual first few:\n`
1603
+ );
1604
+ for (const pick of picks) {
1605
+ const why = pick.reason ? paint.dim(` - ${pick.reason}`) : "";
1606
+ say(` ${pick.title || pick.id}${why}\n ${paint.dim(pick.id)}\n`);
1607
+ }
1608
+ say("\n");
1609
+
1610
+ await applyToggles(picks, true, { args, token, paint, say });
1611
+ }
1612
+
1613
+ /**
1614
+ * Run an eval from inside a session.
1615
+ *
1616
+ * The dock comes down for the duration and goes back up after, which is not
1617
+ * cosmetic: `eval.js` spawns the Python harness as a child process inheriting
1618
+ * this terminal's stdout. A child writing to fd 1 cannot be funnelled through
1619
+ * `dock.write`, so leaving the scrolling region in place would let harness
1620
+ * output shred the composer. Readline is paused for the same reason -- the
1621
+ * harness may want the keyboard, and two readers on stdin is one too many.
1622
+ *
1623
+ * `/eval` is `preman test --agent`, deliberately: the behaviours, the account
1624
+ * and the run history are the same ones, and a session is a place to start one
1625
+ * from, not a second way to define what a run means.
1626
+ */
1627
+ export async function runEval(rest, { args, token, dock, rl, paint, say, running }) {
1628
+ const argv = String(rest || "").trim().split(/\s+/).filter(Boolean);
1629
+ const sub = argv[0];
1630
+
1631
+ // Reading and toggling are plain HTTP, so they stay in the docked transcript
1632
+ // rather than taking the screen the way a harness run has to.
1633
+ if (sub === "list" || sub === "on" || sub === "off" || sub === "defaults") {
1634
+ try {
1635
+ await manageBehaviors(argv, { args, token, paint, say });
1636
+ } catch (error) {
1637
+ say(`${paint.red(error?.message || String(error))}\n\n`);
1638
+ }
1639
+ return;
1640
+ }
1641
+
1642
+ const doctor = sub === "doctor";
1643
+
1644
+ // Starting a run with nothing switched on used to end at an error naming a
1645
+ // web page. The catalog is right here, so show it and say which words fix it.
1646
+ if (!doctor) {
1647
+ try {
1648
+ const catalog = await behaviorCatalog(args, token);
1649
+ const on = (catalog.behaviors || []).filter((entry) => entry.enabled);
1650
+ if (!on.length) {
1651
+ say(`${paint.yellow("Nothing is switched on, so there is nothing to measure.")}\n`);
1652
+ say(`${behaviorLines(catalog, paint).join("\n")}\n`);
1653
+ say(paint.dim(" /eval defaults to pick a few that suit this project and switch them on\n"));
1654
+ say(paint.dim(" /eval on 3 12 to switch those two on, then /eval to run\n\n"));
1655
+ return;
1656
+ }
1657
+ } catch (error) {
1658
+ say(`${paint.red(error?.message || String(error))}\n\n`);
1659
+ return;
1660
+ }
1661
+ }
1662
+
1663
+ dock.disable();
1664
+ rl.pause();
1665
+ // Readline's `pause` stops stdin flowing and leaves the tty in raw mode, so
1666
+ // for the length of a run the terminal generated no signals and a Ctrl+C sat
1667
+ // in the buffer until the run ended -- pressed, ignored, then delivered at
1668
+ // the one moment it was no longer wanted. Out of raw mode the keystroke is a
1669
+ // real SIGINT again, and the session's interrupt handler can stop the run.
1670
+ const wasRaw = Boolean(process.stdin.isRaw);
1671
+ if (wasRaw) process.stdin.setRawMode?.(false);
1672
+ running?.(true);
1673
+ try {
1674
+ const { agentTestCommand, evalCommand } = await import("./eval.js");
1675
+ const runner = await import("./runner.js");
1676
+ const deps = {
1677
+ makeArgs,
1678
+ runnerLoop: runner.runnerLoop,
1679
+ saveRunnerState: runner.saveRunnerState,
1680
+ readRunnerState: runner.readRunnerState,
1681
+ deviceId: runner.deviceId,
1682
+ };
1683
+ if (doctor) await evalCommand(argv, deps);
1684
+ else await agentTestCommand(argv, deps);
1685
+ } catch (error) {
1686
+ process.stdout.write(`${paint.red(error?.message || String(error))}\n`);
1687
+ } finally {
1688
+ running?.(false);
1689
+ if (wasRaw) process.stdin.setRawMode?.(true);
1690
+ rl.resume();
1691
+ dock.enable();
1692
+ }
1693
+ }
1694
+
1695
+ export async function agentCommand(commandArgs = [], { authenticate = authenticateTerminal } = {}) {
1696
+ const args = makeArgs(commandArgs);
1697
+ if (args.has("--help") || args.has("-h")) {
1698
+ process.stdout.write(`Usage: ${cliInvocation()} [message]\n${AGENT_HELP}\n${SLASH_HELP}\n`);
1699
+ return;
1700
+ }
1701
+
1702
+ const paint = makePaint(colourEnabled(args));
1703
+
1704
+ // Signing in is part of starting a session, not a separate command somebody
1705
+ // has to know about first -- this is the whole first-run path for `preman`.
1706
+ // The walk that also installs the app and wires integrations is still there;
1707
+ // it is offered rather than imposed, because somebody who typed `preman` to
1708
+ // ask a question should get to ask it.
1709
+ if (!hasKeyAvailable(args)) {
1710
+ process.stdout.write("Let's connect your PreMan account first.\n\n");
1711
+ await authenticate(args);
1712
+ process.stdout.write(
1713
+ `\n${paint.dim(`Want the desktop app and integrations too? Run \`${cliInvocation()} onboard\`.`)}\n\n`
1714
+ );
1715
+ }
1716
+ const token = resolveApiKey(args);
1717
+ if (!token) throw new Error(`No PreMan API key. Run \`${cliInvocation()} login\`.`);
1718
+
1719
+ const workspace = await resolveWorkspace(args, token);
1720
+ let conversation = await resolveConversation(args, token, workspace.id);
1721
+
1722
+ const oneShot = positionals(args.raw).join(" ").trim();
1723
+ const live = colourEnabled(args) && Boolean(process.stdout.isTTY);
1724
+ const renderer = createRenderer({ paint, live });
1725
+
1726
+ if (oneShot) {
1727
+ await runTurn({
1728
+ args,
1729
+ token,
1730
+ workspaceId: workspace.id,
1731
+ conversationId: conversation.id,
1732
+ content: oneShot,
1733
+ renderer,
1734
+ paint,
1735
+ });
1736
+ // `--print` is the scriptable form: one message, one reply, exit. Without
1737
+ // it a message on the command line is simply how the session opens.
1738
+ if (args.has("--print") || args.has("-p") || !process.stdin.isTTY) return;
1739
+ }
1740
+
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);
1745
+
1746
+ // Watching before the screen is taken, not after: the handlers have to be up
1747
+ // for every moment the terminal is in a state somebody else has to live with.
1748
+ //
1749
+ // What a person needs after the screen is handed back is the one line that
1750
+ // returns them to this conversation. Read at restore time rather than
1751
+ // captured, because `/new` and `/resume` change which conversation that is.
1752
+ const hold = holdTerminal({
1753
+ stream: process.stdout,
1754
+ footer: () => {
1755
+ // A conversation whose id never arrived stringifies to "undefined", and a
1756
+ // resume line naming that is worse than no resume line.
1757
+ const id = String(conversation?.id || "");
1758
+ if (!fullScreen || !id || id === "undefined") return "";
1759
+ return `${paint.dim("Resume this session with:")}\n ${cliInvocation()} --conversation ${id}\n`;
1760
+ },
1761
+ }).watch();
1762
+
1763
+ if (fullScreen) process.stdout.write(`${ENTER_ALT}${CLEAR_SCREEN}`);
1764
+
1765
+ const dock = createDock({
1766
+ stream: process.stdout,
1767
+ paint,
1768
+ status: `${MODEL_NAME} \u00b7 ${basename(process.cwd())}`,
1769
+ });
1770
+ const prompt = `${paint.caret("\u276f")} `;
1771
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt });
1772
+ 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);
1780
+
1781
+ dock.write(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1782
+ const onResize = () => dock.resize();
1783
+ process.stdout.on("resize", onResize);
1784
+
1785
+ // Registered rather than left to the `finally`: these have to run on a
1786
+ // SIGTERM and on a callback that threw, which never reach it.
1787
+ hold.onTeardown(() => {
1788
+ // Only reaches anything when a run is open, and the import is already
1789
+ // resolved by then -- the module was loaded to start the run.
1790
+ if (evaluating) void import("./eval.js").then(({ cancelEvalRuns }) => cancelEvalRuns());
1791
+ });
1792
+ hold.onTeardown(() => process.stdout.off("resize", onResize));
1793
+ hold.onTeardown(() => dock.disable());
1794
+ hold.onTeardown(() => rl.close());
1795
+
1796
+ // Everything the session prints goes through the dock, so a half-typed line
1797
+ // survives whatever lands underneath it.
1798
+ const say = (text) => dock.write(text);
1799
+ const docked = dock.live ? createRenderer({ stream: dock.proxy(), paint, live }) : renderer;
1800
+
1801
+ /**
1802
+ * Typing while the agent works is the whole point of the dock, so lines are
1803
+ * queued instead of read one at a time: readline stays live for the entire
1804
+ * session and anything typed mid-turn waits its turn rather than being
1805
+ * echoed into the transcript by the tty.
1806
+ */
1807
+ const queued = [];
1808
+ let borrow = null; // a permission prompt wants this line, not the queue
1809
+ let wake = null; // the pump, parked until something arrives
1810
+ let ended = false;
1811
+
1812
+ const nudge = () => {
1813
+ if (!wake) return;
1814
+ const resume = wake;
1815
+ wake = null;
1816
+ resume();
1817
+ };
1818
+
1819
+ if (dock.live) rl.on("line", (raw) => {
1820
+ const text = String(raw);
1821
+ if (borrow) {
1822
+ const answer = borrow;
1823
+ borrow = null;
1824
+ answer(text);
1825
+ return;
1826
+ }
1827
+ queued.push(text);
1828
+ nudge();
1829
+ });
1830
+
1831
+ if (dock.live) rl.on("close", () => {
1832
+ ended = true;
1833
+ if (borrow) {
1834
+ const answer = borrow;
1835
+ borrow = null;
1836
+ answer(null);
1837
+ }
1838
+ nudge();
1839
+ });
1840
+
1841
+ /**
1842
+ * What Ctrl+C means, which until now was not what the help text said.
1843
+ *
1844
+ * Readline in terminal mode eats `\x03` and emits its own `SIGINT` event --
1845
+ * raw mode has already turned off the tty's own signal generation -- so the
1846
+ * `process.on("SIGINT")` a turn installs to abort its fetch was unreachable
1847
+ * from the keyboard. A press mid-answer therefore cancelled nothing and quit
1848
+ * nothing: it marked the session ended and the shell came back whenever the
1849
+ * answer happened to finish.
1850
+ *
1851
+ * So both doors lead here: readline's event for a keystroke, and a real
1852
+ * signal for `kill -INT` and for the stretches where readline is not reading.
1853
+ * The first press is always the small, recoverable thing that fits what is
1854
+ * happening -- stop the turn, drop the half-typed line, leave the question
1855
+ * unanswered. Quitting takes a second press, because the one thing a person
1856
+ * cannot undo is the session ending under an answer they wanted.
1857
+ */
1858
+ let turn = null; // the in-flight turn's abort handle, when there is one
1859
+ let evaluating = false; // a run is holding a harness child and a socket open
1860
+ let quitting = false;
1861
+ let armed = 0;
1862
+
1863
+ function quit() {
1864
+ quitting = true;
1865
+ ended = true;
1866
+ turn?.abort();
1867
+ if (borrow) {
1868
+ const answer = borrow;
1869
+ borrow = null;
1870
+ answer(null);
1871
+ }
1872
+ nudge();
1873
+ rl.close();
1874
+ }
1875
+
1876
+ function interrupt() {
1877
+ const now = Date.now();
1878
+ const second = now - armed < QUIT_WINDOW_MS;
1879
+ armed = now;
1880
+ if (second) {
1881
+ quit();
1882
+ return;
1883
+ }
1884
+ if (turn) {
1885
+ // The message belongs to `runTurn`, which knows whether the abort landed
1886
+ // mid-answer or between events.
1887
+ turn.abort();
1888
+ return;
1889
+ }
1890
+ if (evaluating) {
1891
+ // Stopping a run is closing what it holds open -- the harness child and
1892
+ // the adapter's socket. The command itself then unwinds normally and the
1893
+ // dock comes back, which is what "back to the prompt" means here.
1894
+ process.stdout.write("\n Stopping this eval run.\n");
1895
+ void import("./eval.js").then(({ cancelEvalRuns }) => cancelEvalRuns());
1896
+ return;
1897
+ }
1898
+ if (borrow) {
1899
+ const answer = borrow;
1900
+ borrow = null;
1901
+ answer(null);
1902
+ return;
1903
+ }
1904
+ if (!rl.closed && rl.line) {
1905
+ rl.line = "";
1906
+ rl.cursor = 0;
1907
+ dock.refresh();
1908
+ return;
1909
+ }
1910
+ say(paint.dim(" Press Ctrl+C again to exit\n"));
1911
+ }
1912
+
1913
+ rl.on("SIGINT", interrupt);
1914
+ hold.onInterrupt(interrupt);
1915
+ hold.onTeardown(() => hold.onInterrupt(null));
1916
+
1917
+ /** The next typed line, or null once stdin is done and the queue is dry. */
1918
+ async function nextQueued() {
1919
+ for (;;) {
1920
+ if (queued.length) return queued.shift();
1921
+ if (ended) return null;
1922
+ await new Promise((resume) => {
1923
+ wake = resume;
1924
+ });
1925
+ }
1926
+ }
1927
+
1928
+ /** Lend the one reader on stdin to a mid-turn question. */
1929
+ function ask(question) {
1930
+ if (ended) return Promise.resolve(null);
1931
+ return new Promise((answer) => {
1932
+ borrow = answer;
1933
+ rl.setPrompt(question);
1934
+ dock.refresh();
1935
+ }).finally(() => {
1936
+ rl.setPrompt(prompt);
1937
+ dock.refresh();
1938
+ });
1939
+ }
1940
+
1941
+ try {
1942
+ for (;;) {
1943
+ const line = dock.live ? await nextQueued() : await nextLine(rl, prompt);
1944
+ if (line === null || quitting) break;
1945
+ const text = String(line).trim();
1946
+ dock.echo(text);
1947
+ if (!text) continue;
1948
+
1949
+ const slash = parseSlash(text);
1950
+ if (slash) {
1951
+ if (slash.name === "exit" || slash.name === "quit") break;
1952
+ if (slash.name === "help") {
1953
+ say(`${SLASH_HELP}\n\n`);
1954
+ continue;
1955
+ }
1956
+ if (slash.name === "clear") {
1957
+ // The banner comes back with it. On a screen of its own, a cleared
1958
+ // session that says nothing about itself is indistinguishable from a
1959
+ // shell -- and which workspace and conversation this is were the two
1960
+ // things the clear just took away.
1961
+ say(CLEAR_SCREEN);
1962
+ say(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1963
+ continue;
1964
+ }
1965
+ if (slash.name === "new") {
1966
+ conversation = await newConversation(args, token, workspace.id);
1967
+ say(`${paint.dim(`New conversation ${conversation.id}`)}\n\n`);
1968
+ continue;
1969
+ }
1970
+ if (slash.name === "conversations") {
1971
+ const rows = await listConversations(args, token, workspace.id);
1972
+ if (!rows.length) {
1973
+ say(`${paint.dim("No conversations yet.")}\n\n`);
1974
+ continue;
1975
+ }
1976
+ for (const row of rows.slice(0, 15)) {
1977
+ const marker = String(row.id) === conversation.id ? paint.caret("\u276f") : " ";
1978
+ say(`${marker} ${paint.dim(String(row.id))} ${truncate(row.title || "Untitled", 48)}\n`);
1979
+ }
1980
+ say("\n");
1981
+ continue;
1982
+ }
1983
+ if (slash.name === "resume") {
1984
+ if (!slash.rest) {
1985
+ say(`${paint.dim("Usage: /resume <conversation id>")}\n\n`);
1986
+ continue;
1987
+ }
1988
+ try {
1989
+ conversation = await openConversation(args, token, slash.rest);
1990
+ say(`${paint.dim(`Resumed ${conversation.title}`)}\n\n`);
1991
+ } catch (error) {
1992
+ say(`${paint.red(error.message)}\n\n`);
1993
+ }
1994
+ continue;
1995
+ }
1996
+ if (slash.name === "eval") {
1997
+ await runEval(slash.rest, {
1998
+ args,
1999
+ token,
2000
+ dock,
2001
+ rl,
2002
+ paint,
2003
+ say,
2004
+ running: (on) => {
2005
+ evaluating = on;
2006
+ },
2007
+ });
2008
+ continue;
2009
+ }
2010
+ if (slash.name === "app") {
2011
+ const url = `${frontendUrl(args)}/workbench/${conversation.id}`;
2012
+ openUrl(url);
2013
+ say(`${url}\n\n`);
2014
+ continue;
2015
+ }
2016
+ say(`${paint.dim(`Unknown command ${text}. /help for the list.`)}\n\n`);
2017
+ continue;
2018
+ }
2019
+
2020
+ await runTurn({
2021
+ args,
2022
+ token,
2023
+ workspaceId: workspace.id,
2024
+ conversationId: conversation.id,
2025
+ content: text,
2026
+ renderer: docked,
2027
+ paint,
2028
+ ask: dock.live ? ask : undefined,
2029
+ say: dock.live ? say : undefined,
2030
+ onNote: dock.live ? (text) => dock.setNote(text) : undefined,
2031
+ register: (handle) => {
2032
+ turn = handle;
2033
+ },
2034
+ });
2035
+ turn = null;
2036
+ if (quitting) break;
2037
+ }
2038
+ } finally {
2039
+ // One path, and it is the same one a signal takes: the teardowns are
2040
+ // registered on the hold, so this is only the word that they should run.
2041
+ hold.release();
2042
+ }
2043
+ }