premanmcp 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/agent.js ADDED
@@ -0,0 +1,1925 @@
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
+
833
+ const size = () => ({
834
+ columns: stream.columns || 80,
835
+ rows: stream.rows || 24,
836
+ });
837
+
838
+ /** Last row the transcript may use; everything below belongs to the dock. */
839
+ const floor = () => Math.max(1, size().rows - DOCK_ROWS);
840
+
841
+ /**
842
+ * Is there a window here to dock in at all?
843
+ *
844
+ * Four reserved rows and no check against the window's height meant a short
845
+ * one produced arithmetic rather than layout: three rows emitted
846
+ * `ESC[0;1H` and two emitted `ESC[-1;1H`, a malformed CSI whose bytes some
847
+ * terminals print. Below the floor the dock simply does not paint -- the
848
+ * session falls back to being a plain prompt, which is what it already is on
849
+ * a pipe -- and `resize` brings it back the moment the window can hold it.
850
+ */
851
+ const roomy = () => size().rows >= DOCK_ROWS + 2;
852
+
853
+ /** The rule above the composer. */
854
+ function chrome() {
855
+ const { columns, rows } = size();
856
+ painted = { columns, rows };
857
+ stream.write(`\u001b[${rows - 3};1H\u001b[2K${paint.dim("\u2500".repeat(columns))}`);
858
+ }
859
+
860
+ /**
861
+ * The rule under the composer, and the status strip under that.
862
+ *
863
+ * These are painted *after* the composer rather than with the rule above it,
864
+ * because readline redraws its line with an erase-to-end-of-screen: anything
865
+ * written below the input row before `prompt()` is wiped the moment the
866
+ * prompt refreshes, which left the session showing a bare rule above the
867
+ * caret and nothing -- no lower rule, no model name -- beneath it.
868
+ */
869
+ function under() {
870
+ const { columns, rows } = size();
871
+ const strip = note ? `${label} \u00b7 ${note}` : label;
872
+ stream.write(`\u001b[${rows - 1};1H\u001b[2K${paint.dim("\u2500".repeat(columns))}`);
873
+ const tint = paint.lime || paint.dim;
874
+ stream.write(`\u001b[${rows};1H\u001b[2K${tint(truncate(strip, columns))}`);
875
+ }
876
+
877
+ /**
878
+ * Put the composer back under the cursor readline thinks it has.
879
+ *
880
+ * `prompt(true)` is what redraws the prompt *and* whatever has been typed so
881
+ * far, which is the whole point: a line half-typed while the agent was
882
+ * talking has to survive every write that happened underneath it.
883
+ */
884
+ function composer() {
885
+ const { rows } = size();
886
+ stream.write(`\u001b[${rows - 2};1H\u001b[2K`);
887
+ // A closed readline is the ordinary state on the way out: quitting mid-turn
888
+ // closes it while the answer is still arriving, and every fragment after
889
+ // that would ask it to redraw a prompt it no longer has. Node answers that
890
+ // with a throw, which surfaced as a bare "readline was closed" on a session
891
+ // that had in fact just done what it was told.
892
+ if (rl && !rl.closed) rl.prompt(true);
893
+ under();
894
+ caret();
895
+ }
896
+
897
+ /**
898
+ * Put the cursor back where the typist left it.
899
+ *
900
+ * The furniture below the composer is addressed absolutely, and readline goes
901
+ * on typing wherever the cursor was last put -- so every repaint has to end
902
+ * by handing the position back.
903
+ */
904
+ function caret() {
905
+ const { columns, rows } = size();
906
+ const at = (rl && !rl.closed ? rl.getCursorPos?.() : null) || { rows: 0, cols: 0 };
907
+ // Bounded by the window, not pinned to the composer's row: a line long
908
+ // enough to wrap genuinely occupies the rows under it -- readline drew it
909
+ // there -- so the caret has to follow it or the next keystroke lands
910
+ // somewhere else entirely. What this stops is the row running off the
911
+ // bottom of the screen, which is where an unbounded offset sent it.
912
+ const row = Math.min(rows, Math.max(1, rows - 2 + Math.max(0, at.rows || 0)));
913
+ const column = Math.min(Math.max(1, (at.cols || 0) + 1), Math.max(1, columns));
914
+ stream.write(`\u001b[${row};${column}H`);
915
+ }
916
+
917
+ /**
918
+ * Repaint the furniture, at most once a tick.
919
+ *
920
+ * A streamed reply arrives as dozens of fragments a second and every one of
921
+ * them used to repaint two full-width rules, the status strip, readline's
922
+ * line and the cursor -- six hundred bytes of scenery per token on a wide
923
+ * window, for a screen that looks identical each time. That is the shape of
924
+ * the corruption seen in real sessions: lines arriving with their middles
925
+ * missing, which is what an emulator does when it cannot keep up.
926
+ *
927
+ * So the transcript is written immediately -- it is the thing somebody is
928
+ * reading -- and the scenery is marked dirty and painted once when the tick
929
+ * drains. Callers that change what the furniture *says* still paint
930
+ * synchronously, because their contract is that the change is on screen when
931
+ * they return.
932
+ */
933
+ function repaint() {
934
+ if (!open || dirty) return;
935
+ dirty = true;
936
+ setImmediate(() => {
937
+ dirty = false;
938
+ if (!open) return;
939
+ frame();
940
+ });
941
+ }
942
+
943
+ /** Every row of furniture, in the order the terminal needs them. */
944
+ function frame() {
945
+ if (!roomy()) return;
946
+ chrome();
947
+ composer();
948
+ }
949
+
950
+ function listen() {
951
+ if (!live || typing) return;
952
+ // Typing is the other thing that erases the furniture, and it was the
953
+ // hole this dock had: `write`, `resize` and the rest all repaint, but a
954
+ // keystroke goes straight into readline, which redraws its line with an
955
+ // erase-to-end-of-screen and takes the lower rule and the status strip
956
+ // with it. Nothing here is told, so the rows stayed blank until the next
957
+ // event -- which is why the bottom line vanished mid-sentence and came
958
+ // back when the agent next said something.
959
+ //
960
+ // readline has no "I have just redrawn" hook, so the repaint rides the
961
+ // keypress that caused it. This listener is registered after readline's
962
+ // own, so it runs after the line has been redrawn, and it is coalesced
963
+ // to one repaint per tick: a pasted line is hundreds of keypresses, and
964
+ // repainting a full-width rule for each of them is a lot of bytes to
965
+ // send a terminal for one visual result.
966
+ typing = () => {
967
+ if (!open || queued) return;
968
+ queued = true;
969
+ setImmediate(() => {
970
+ queued = false;
971
+ // `roomy` for the same reason every other painter checks it: a window
972
+ // too short to hold the dock is one where these rows do not exist, and
973
+ // painting them anyway was the one path that could still address row
974
+ // zero after the floor was added.
975
+ if (!open || !roomy()) return;
976
+ under();
977
+ caret();
978
+ });
979
+ };
980
+ input?.on?.("keypress", typing);
981
+ }
982
+
983
+ return {
984
+ live,
985
+
986
+ /** Reserve the bottom rows and remember where the transcript is. */
987
+ enable() {
988
+ if (!live || open) return;
989
+ open = true;
990
+ listen();
991
+ stream.write(SET_TITLE);
992
+ if (!roomy()) return;
993
+ stream.write("\n".repeat(DOCK_ROWS));
994
+ stream.write(`\u001b[1;${floor()}r`);
995
+ stream.write(`\u001b[${floor()};1H`);
996
+ stream.write("\u001b7");
997
+ frame();
998
+ },
999
+
1000
+ attach(readline) {
1001
+ rl = readline;
1002
+ listen();
1003
+ },
1004
+
1005
+ /** Repaint furniture and composer, e.g. after the prompt text changes. */
1006
+ refresh() {
1007
+ if (!open) return;
1008
+ frame();
1009
+ },
1010
+
1011
+ setStatus(text) {
1012
+ label = text;
1013
+ if (open) frame();
1014
+ },
1015
+
1016
+ /**
1017
+ * Every byte of transcript goes through here.
1018
+ *
1019
+ * The saved cursor (DECSC/DECRC) is the transcript's own position, which
1020
+ * matters because a streamed reply arrives as fragments with no newline —
1021
+ * forcing column 1 between them would overprint the line instead of
1022
+ * continuing it. So: restore, write, save, then repaint the furniture.
1023
+ */
1024
+ write(chunk) {
1025
+ if (!open || !roomy()) {
1026
+ stream.write(chunk);
1027
+ return;
1028
+ }
1029
+ // One write rather than three: the restore, the chunk and the save are a
1030
+ // single sequence to the terminal, so nothing can land between them.
1031
+ stream.write(`\u001b8${chunk}\u001b7`);
1032
+ repaint();
1033
+ },
1034
+
1035
+ /** Leave the same trail a normal REPL would for a line just submitted. */
1036
+ echo(text) {
1037
+ if (!open) return;
1038
+ // Plain inside the band, deliberately: every painter here ends with a
1039
+ // reset, and a reset mid-line takes the background off with it -- a
1040
+ // half-highlighted row reads as a rendering fault. Padded to the window
1041
+ // so the band is a row rather than a highlight around the words. A line
1042
+ // long enough to wrap carries the background across every row it takes,
1043
+ // the last of them ending where the words do, because the band opens
1044
+ // once and closes once and the terminal wraps what is between.
1045
+ const { columns } = size();
1046
+ const line = `\u276f ${text}`;
1047
+ const pad = " ".repeat(Math.max(0, columns - printable(line)));
1048
+ this.write(`${paint.band(`${line}${pad}`)}\n`);
1049
+ },
1050
+
1051
+ /** A stdout stand-in for the renderer, so its writes land in the region. */
1052
+ proxy() {
1053
+ if (!open) return stream;
1054
+ const dock = this;
1055
+ return {
1056
+ write(chunk) {
1057
+ dock.write(chunk);
1058
+ return true;
1059
+ },
1060
+ get isTTY() {
1061
+ return stream.isTTY;
1062
+ },
1063
+ get columns() {
1064
+ return stream.columns;
1065
+ },
1066
+ get rows() {
1067
+ return stream.rows;
1068
+ },
1069
+ };
1070
+ },
1071
+
1072
+ /**
1073
+ * Follow a resize.
1074
+ *
1075
+ * Dragging a window edge fires this many times a second, and each event
1076
+ * arrives *after* the terminal has already reflowed -- so the rows the
1077
+ * furniture was last drawn on are now ordinary content sitting in the
1078
+ * scrollback. Painting the new chrome without erasing the old is what
1079
+ * smears rules and carets down the screen. Releasing the region first is
1080
+ * what makes the old rows addressable at all: inside a scrolling region
1081
+ * the bottom rows cannot be cursored to.
1082
+ */
1083
+ resize() {
1084
+ if (!open) return;
1085
+ stream.write(RELEASE_REGION);
1086
+ if (painted) {
1087
+ // Only the rows the furniture was on. It used to erase from there to
1088
+ // the end of the screen, which is fine when a window shrinks and
1089
+ // destroys live transcript when it grows: the rows between the old
1090
+ // dock and the new floor hold the answer somebody is reading.
1091
+ const top = Math.max(1, Math.min(painted.rows, size().rows) - DOCK_ROWS + 1);
1092
+ for (let row = top; row <= Math.min(painted.rows, size().rows); row++) {
1093
+ stream.write(`\u001b[${row};1H\u001b[2K`);
1094
+ }
1095
+ }
1096
+ painted = null;
1097
+ if (!roomy()) return;
1098
+ stream.write(`\u001b[1;${floor()}r`);
1099
+ // The transcript's saved position, re-anchored rather than replaced. A
1100
+ // mid-line `delta` resumed at column 1 of the floor row before this, so a
1101
+ // sentence in flight during a window drag was orphaned.
1102
+ stream.write(`\u001b[${floor()};1H`);
1103
+ stream.write("\u001b7");
1104
+ frame();
1105
+ },
1106
+
1107
+ /** What the agent is doing right now, shown on the strip during a turn. */
1108
+ setNote(text) {
1109
+ note = String(text || "");
1110
+ if (open) frame();
1111
+ },
1112
+
1113
+ /** Give the rows back, or the shell prompt inherits a clipped screen. */
1114
+ disable() {
1115
+ // The listener goes first and outside the open check: `attach` arms it,
1116
+ // and a dock that was attached and never enabled would otherwise keep a
1117
+ // `keypress` listener on stdin for the life of the process -- which is
1118
+ // the hazard this method exists to prevent, arriving by another door.
1119
+ if (typing) {
1120
+ input?.off?.("keypress", typing);
1121
+ typing = null;
1122
+ }
1123
+ if (!open) return;
1124
+ open = false;
1125
+ const { rows } = size();
1126
+ stream.write("\u001b[r");
1127
+ stream.write(`\u001b[${rows};1H\u001b[2K`);
1128
+ stream.write(CLEAR_TITLE);
1129
+ },
1130
+ };
1131
+ }
1132
+
1133
+ /** Printable width, ignoring the colour codes wrapped around the text. */
1134
+ function printable(text) {
1135
+ return [...text.replace(/\u001b\[[0-9;]*m/g, "")].length;
1136
+ }
1137
+
1138
+ /**
1139
+ * Set two blocks of rows side by side, the left one padded to its own width.
1140
+ *
1141
+ * Measuring has to ignore the escape codes: `paint.bold(row)` is several
1142
+ * bytes longer than the glyphs it wraps, and padding to `.length` would step
1143
+ * the right-hand column in and out by that much on every row.
1144
+ */
1145
+ function beside(left, right, gap = 3) {
1146
+ const width = Math.max(...left.map(printable));
1147
+ const rows = [];
1148
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
1149
+ const mark = left[i] ?? "";
1150
+ const detail = right[i] ?? "";
1151
+ if (!detail) {
1152
+ rows.push(mark);
1153
+ continue;
1154
+ }
1155
+ rows.push(`${mark}${" ".repeat(width - printable(mark) + gap)}${detail}`);
1156
+ }
1157
+ return rows;
1158
+ }
1159
+
1160
+ export function banner({ paint, workspace, backend, conversation, columns }) {
1161
+ const version = packageVersion();
1162
+ const cli = cliInvocation();
1163
+ const width = columns ?? process.stdout.columns ?? 80;
1164
+ const details = [
1165
+ `${paint.bold("PreMan")}${version ? paint.dim(` v${version}`) : ""}`,
1166
+ paint.dim(`${workspace.name} - ${backend.replace(/^https?:\/\//, "")}`),
1167
+ ];
1168
+ if (conversation?.title) details.push(paint.dim(`Conversation: ${conversation.title}`));
1169
+ const mark = logo(paint, width).split("\n");
1170
+ // Side by side while both fit; stacked when the window is too narrow, which
1171
+ // is the same answer the wordmark used to give -- just at a far lower bar.
1172
+ const widest = Math.max(...details.map(printable));
1173
+ const lines =
1174
+ width >= LOGO_WIDTH + 3 + widest ? beside(mark, details) : [...mark, "", ...details];
1175
+ lines.push(
1176
+ "",
1177
+ "Ask PreMan to discover endpoints, generate and run tests, or deploy a hosted MCP.",
1178
+ paint.dim(`/help for commands - /exit to leave - ${cli} --help for the rest of the CLI`),
1179
+ ""
1180
+ );
1181
+ return `${lines.join("\n")}\n`;
1182
+ }
1183
+
1184
+ /**
1185
+ * Read a line, resolving to null when stdin ends (EOF, a pipe, Ctrl+D).
1186
+ *
1187
+ * Both guards are for the same moment: stdin running out while a turn was in
1188
+ * flight. By the time we ask again the interface is already closed, so a
1189
+ * `close` listener registered now would never fire and `question` rejects
1190
+ * outright -- which used to surface as a bare "readline was closed" error on a
1191
+ * session that had in fact just ended normally.
1192
+ */
1193
+ async function nextLine(rl, prompt) {
1194
+ if (rl.closed) return null;
1195
+ const closed = new Promise((resolve) => rl.once("close", () => resolve(null)));
1196
+ try {
1197
+ return await Promise.race([rl.question(prompt), closed]);
1198
+ } catch {
1199
+ return null;
1200
+ }
1201
+ }
1202
+
1203
+ /** The catalog of what this account can measure, with its on/off state. */
1204
+ async function behaviorCatalog(args, token) {
1205
+ const result = await callBackendJson(args, "GET", "/eval-behaviors", { token });
1206
+ if (!result.ok) {
1207
+ throw new Error(
1208
+ `could not read what this account measures (${result.status_code}): ${describeFailure(result)}`
1209
+ );
1210
+ }
1211
+ return result;
1212
+ }
1213
+
1214
+ /**
1215
+ * One line per behaviour: number, state, id, and what it is.
1216
+ *
1217
+ * Numbered because the catalog is fifty-odd entries deep and
1218
+ * `library:explicit_constraint_violation_failures` is not something anybody is
1219
+ * going to type correctly at a prompt. The number is the row's position in this
1220
+ * same response, so `/eval on 12` re-reads the catalog and counts to twelve --
1221
+ * the list is files that ship with the deployment plus this account's own
1222
+ * entries, so it is the same twelve unless the account changed in between.
1223
+ */
1224
+ function behaviorLines(catalog, paint) {
1225
+ const rows = catalog.behaviors || [];
1226
+ if (!rows.length) return [paint.dim(" nothing in the catalog for this account.")];
1227
+ const width = Math.max(...rows.map((entry) => String(entry.id).length));
1228
+ const digits = String(rows.length).length;
1229
+ return rows.map((entry, index) => {
1230
+ const number = paint.dim(String(index + 1).padStart(digits));
1231
+ const mark = entry.enabled ? paint.green("●") : paint.dim("○");
1232
+ const id = String(entry.id).padEnd(width);
1233
+ const title = entry.title || entry.name || "";
1234
+ const source = entry.source ? paint.dim(` (${entry.source})`) : "";
1235
+ return ` ${number} ${mark} ${entry.enabled ? id : paint.dim(id)} ${title}${source}`;
1236
+ });
1237
+ }
1238
+
1239
+ /**
1240
+ * Work out which behaviours somebody meant.
1241
+ *
1242
+ * Four ways of saying it, because both the ids and the list are long: `12`,
1243
+ * `3-7`, `all`, and any text that names one -- the full id, the bare name, or
1244
+ * enough of either to be unambiguous. `sycophancy` is one behaviour;
1245
+ * `failures` is thirty, and thirty is reported as ambiguous rather than picking
1246
+ * the first, because what follows spends a provider bill per behaviour.
1247
+ */
1248
+ export function pickBehaviors(rows, tokens) {
1249
+ const picked = new Map();
1250
+ const unknown = [];
1251
+ const ambiguous = [];
1252
+ const take = (entry) => {
1253
+ if (entry) picked.set(entry.id, entry);
1254
+ };
1255
+
1256
+ for (const raw of tokens) {
1257
+ const token = String(raw || "").trim();
1258
+ if (!token) continue;
1259
+
1260
+ if (token.toLowerCase() === "all") {
1261
+ rows.forEach(take);
1262
+ continue;
1263
+ }
1264
+
1265
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
1266
+ if (range) {
1267
+ const [from, to] = [Number(range[1]), Number(range[2])];
1268
+ const [low, high] = from <= to ? [from, to] : [to, from];
1269
+ let hit = false;
1270
+ for (let n = low; n <= high; n++) {
1271
+ if (rows[n - 1]) {
1272
+ take(rows[n - 1]);
1273
+ hit = true;
1274
+ }
1275
+ }
1276
+ if (!hit) unknown.push(token);
1277
+ continue;
1278
+ }
1279
+
1280
+ if (/^\d+$/.test(token)) {
1281
+ const entry = rows[Number(token) - 1];
1282
+ if (entry) take(entry);
1283
+ else unknown.push(token);
1284
+ continue;
1285
+ }
1286
+
1287
+ const needle = token.toLowerCase();
1288
+ const exact = rows.find(
1289
+ (entry) =>
1290
+ String(entry.id).toLowerCase() === needle ||
1291
+ String(entry.name || "").toLowerCase() === needle
1292
+ );
1293
+ if (exact) {
1294
+ take(exact);
1295
+ continue;
1296
+ }
1297
+ const matches = rows.filter(
1298
+ (entry) =>
1299
+ String(entry.id).toLowerCase().includes(needle) ||
1300
+ String(entry.title || "").toLowerCase().includes(needle)
1301
+ );
1302
+ if (matches.length === 1) take(matches[0]);
1303
+ else if (matches.length) ambiguous.push({ token, matches });
1304
+ else unknown.push(token);
1305
+ }
1306
+
1307
+ // Catalog order, not the order the words were typed: what gets switched on is
1308
+ // then reported in the same order as the numbered list it was read off.
1309
+ const at = new Map(rows.map((entry, index) => [entry.id, index]));
1310
+ return {
1311
+ picked: [...picked.values()].sort((a, b) => at.get(a.id) - at.get(b.id)),
1312
+ unknown,
1313
+ ambiguous,
1314
+ };
1315
+ }
1316
+
1317
+ /**
1318
+ * Turn behaviours on and off without leaving the terminal.
1319
+ *
1320
+ * The catalog, the toggles and the custom descriptions are all already HTTP --
1321
+ * the dashboard has no privileged path to them. What was missing was a way to
1322
+ * reach them from here, which is why `/eval` could only ever report that
1323
+ * nothing was switched on and then send you to a web page to fix it.
1324
+ */
1325
+ async function manageBehaviors(argv, { args, token, paint, say }) {
1326
+ const [sub, ...rest] = argv;
1327
+
1328
+ if (sub === "list") {
1329
+ const catalog = await behaviorCatalog(args, token);
1330
+ const on = (catalog.behaviors || []).filter((entry) => entry.enabled).length;
1331
+ say(`${on} of ${(catalog.behaviors || []).length} switched on\n`);
1332
+ say(`${behaviorLines(catalog, paint).join("\n")}\n`);
1333
+ say(
1334
+ paint.dim(
1335
+ ` /eval on 3 12 or /eval on sycophancy to switch those on, /eval to run what is on\n\n`
1336
+ )
1337
+ );
1338
+ return;
1339
+ }
1340
+
1341
+ if (sub === "on" || sub === "off") {
1342
+ if (!rest.length) {
1343
+ say(paint.dim(`Usage: /eval ${sub} <number, range, id or all>\n\n`));
1344
+ return;
1345
+ }
1346
+ const enabled = sub === "on";
1347
+ const catalog = await behaviorCatalog(args, token);
1348
+ const { picked, unknown, ambiguous } = pickBehaviors(catalog.behaviors || [], rest);
1349
+
1350
+ for (const { token: word, matches } of ambiguous) {
1351
+ const names = matches.slice(0, 4).map((entry) => entry.id).join(", ");
1352
+ const more = matches.length > 4 ? `, and ${matches.length - 4} more` : "";
1353
+ say(`${paint.yellow(`${word} matches ${matches.length}:`)} ${names}${more}\n`);
1354
+ }
1355
+ for (const word of unknown) say(`${paint.yellow(`nothing here is called ${word}`)}\n`);
1356
+
1357
+ if (!picked.length) {
1358
+ say(paint.dim(" /eval list to see the numbers\n\n"));
1359
+ return;
1360
+ }
1361
+
1362
+ await applyToggles(picked, enabled, { args, token, paint, say });
1363
+ return;
1364
+ }
1365
+
1366
+ if (sub === "defaults") {
1367
+ await switchOnDefaults({ args, token, paint, say });
1368
+ return;
1369
+ }
1370
+
1371
+ say(paint.dim("Usage: /eval [doctor|list|defaults|on <what>|off <what>|--only <id>]\n\n"));
1372
+ }
1373
+
1374
+ /**
1375
+ * Switch a selection on or off, one PUT each, saying what happened to each.
1376
+ *
1377
+ * One request per behaviour because that is the contract the dashboard uses
1378
+ * too -- there is no bulk toggle, and inventing one here would be a second
1379
+ * place the rule "a toggle is refused for an id not in the catalog" is
1380
+ * enforced. A row already in the state being asked for is said and not sent,
1381
+ * so a selection of twelve does not report twelve changes when it made two.
1382
+ */
1383
+ async function applyToggles(picked, enabled, { args, token, paint, say }) {
1384
+ const word = enabled ? "on" : "off";
1385
+ let changed = 0;
1386
+ for (const entry of picked) {
1387
+ if (Boolean(entry.enabled) === enabled) {
1388
+ say(paint.dim(` ${entry.id} already ${word}\n`));
1389
+ continue;
1390
+ }
1391
+ const result = await callBackendJson(
1392
+ args,
1393
+ "PUT",
1394
+ `/eval-behaviors/${encodeURIComponent(entry.id)}`,
1395
+ { token, json: { enabled } }
1396
+ );
1397
+ if (!result.ok) {
1398
+ say(`${paint.red(describeFailure(result, `could not switch ${entry.id} ${word}`))}\n`);
1399
+ continue;
1400
+ }
1401
+ changed += 1;
1402
+ say(`${paint.green("✓")} ${entry.id} switched ${word}\n`);
1403
+ }
1404
+ if (changed && enabled) say(paint.dim(" /eval to run what is on\n"));
1405
+ say("\n");
1406
+ }
1407
+
1408
+ /**
1409
+ * What to send when asking which behaviours suit this project.
1410
+ *
1411
+ * "Which of fifty behaviours matter here" cannot be answered from an account
1412
+ * id, so something has to describe the agent under test. These are the files a
1413
+ * project already uses to say what it is, bounded hard: a README's prose, a
1414
+ * package or project description, and the directory's own name. Nothing is read
1415
+ * that a repository does not publish about itself -- no source, no env files --
1416
+ * because this text leaves the machine.
1417
+ */
1418
+ export function projectBlurb(root = process.cwd()) {
1419
+ const read = (name) => {
1420
+ try {
1421
+ return readFileSync(join(root, name), "utf8");
1422
+ } catch {
1423
+ return "";
1424
+ }
1425
+ };
1426
+
1427
+ const parts = [`Directory: ${basename(root)}`];
1428
+
1429
+ try {
1430
+ const pkg = JSON.parse(read("package.json") || "{}");
1431
+ if (pkg.name || pkg.description) {
1432
+ parts.push(`Package: ${[pkg.name, pkg.description].filter(Boolean).join(" - ")}`);
1433
+ }
1434
+ } catch {
1435
+ // A package.json that does not parse says nothing about the project.
1436
+ }
1437
+
1438
+ const described = /^\s*description\s*=\s*["'](.+?)["']/m.exec(read("pyproject.toml"));
1439
+ if (described) parts.push(`Project: ${described[1]}`);
1440
+
1441
+ const readme = read("README.md") || read("readme.md");
1442
+ if (readme) {
1443
+ const prose = readme
1444
+ .replace(/```[\s\S]*?```/g, "")
1445
+ .split("\n")
1446
+ // Badges and raw HTML are a header rather than a description, and on a
1447
+ // lot of READMEs they are most of the first screen.
1448
+ .filter((line) => !/^\s*[[!<]/.test(line))
1449
+ .join("\n")
1450
+ .trim();
1451
+ if (prose) parts.push(`README:\n${prose.slice(0, 1500)}`);
1452
+ }
1453
+
1454
+ return parts.join("\n\n").slice(0, 3500);
1455
+ }
1456
+
1457
+ /**
1458
+ * Ask which behaviours suit this project, then switch them on.
1459
+ *
1460
+ * Two requests, deliberately not one: `/eval-behaviors/recommend` chooses and
1461
+ * the existing toggle applies, so an enabled behaviour is always something this
1462
+ * client asked for by id rather than something a model turn left behind. The
1463
+ * shortlist is printed with its reasons before it is applied, because "a model
1464
+ * picked these" is a claim the person paying for the runs should be able to
1465
+ * read and disagree with.
1466
+ */
1467
+ async function switchOnDefaults({ args, token, paint, say }) {
1468
+ say(paint.dim("Reading this project and choosing what to measure...\n"));
1469
+ const advice = await callBackendJson(args, "POST", "/eval-behaviors/recommend", {
1470
+ token,
1471
+ json: { project: projectBlurb(), limit: 5 },
1472
+ });
1473
+ if (!advice.ok) {
1474
+ say(`${paint.red(describeFailure(advice, "could not work out what to switch on"))}\n\n`);
1475
+ return;
1476
+ }
1477
+
1478
+ const picks = advice.behaviors || [];
1479
+ if (!picks.length) {
1480
+ say(paint.dim("Nothing in the catalog for this account.\n\n"));
1481
+ return;
1482
+ }
1483
+
1484
+ // Which of the two produced this list, said plainly: a curated fallback
1485
+ // presented as a reading of this project would be a claim nobody made.
1486
+ say(
1487
+ advice.source === "model"
1488
+ ? `${picks.length} to start with, chosen for this project:\n`
1489
+ : `${picks.length} to start with. Nothing read this project -- no eval provider ` +
1490
+ `key on this account -- so these are the usual first few:\n`
1491
+ );
1492
+ for (const pick of picks) {
1493
+ const why = pick.reason ? paint.dim(` - ${pick.reason}`) : "";
1494
+ say(` ${pick.title || pick.id}${why}\n ${paint.dim(pick.id)}\n`);
1495
+ }
1496
+ say("\n");
1497
+
1498
+ await applyToggles(picks, true, { args, token, paint, say });
1499
+ }
1500
+
1501
+ /**
1502
+ * Run an eval from inside a session.
1503
+ *
1504
+ * The dock comes down for the duration and goes back up after, which is not
1505
+ * cosmetic: `eval.js` spawns the Python harness as a child process inheriting
1506
+ * this terminal's stdout. A child writing to fd 1 cannot be funnelled through
1507
+ * `dock.write`, so leaving the scrolling region in place would let harness
1508
+ * output shred the composer. Readline is paused for the same reason -- the
1509
+ * harness may want the keyboard, and two readers on stdin is one too many.
1510
+ *
1511
+ * `/eval` is `preman test --agent`, deliberately: the behaviours, the account
1512
+ * and the run history are the same ones, and a session is a place to start one
1513
+ * from, not a second way to define what a run means.
1514
+ */
1515
+ export async function runEval(rest, { args, token, dock, rl, paint, say, running }) {
1516
+ const argv = String(rest || "").trim().split(/\s+/).filter(Boolean);
1517
+ const sub = argv[0];
1518
+
1519
+ // Reading and toggling are plain HTTP, so they stay in the docked transcript
1520
+ // rather than taking the screen the way a harness run has to.
1521
+ if (sub === "list" || sub === "on" || sub === "off" || sub === "defaults") {
1522
+ try {
1523
+ await manageBehaviors(argv, { args, token, paint, say });
1524
+ } catch (error) {
1525
+ say(`${paint.red(error?.message || String(error))}\n\n`);
1526
+ }
1527
+ return;
1528
+ }
1529
+
1530
+ const doctor = sub === "doctor";
1531
+
1532
+ // Starting a run with nothing switched on used to end at an error naming a
1533
+ // web page. The catalog is right here, so show it and say which words fix it.
1534
+ if (!doctor) {
1535
+ try {
1536
+ const catalog = await behaviorCatalog(args, token);
1537
+ const on = (catalog.behaviors || []).filter((entry) => entry.enabled);
1538
+ if (!on.length) {
1539
+ say(`${paint.yellow("Nothing is switched on, so there is nothing to measure.")}\n`);
1540
+ say(`${behaviorLines(catalog, paint).join("\n")}\n`);
1541
+ say(paint.dim(" /eval defaults to pick a few that suit this project and switch them on\n"));
1542
+ say(paint.dim(" /eval on 3 12 to switch those two on, then /eval to run\n\n"));
1543
+ return;
1544
+ }
1545
+ } catch (error) {
1546
+ say(`${paint.red(error?.message || String(error))}\n\n`);
1547
+ return;
1548
+ }
1549
+ }
1550
+
1551
+ dock.disable();
1552
+ rl.pause();
1553
+ // Readline's `pause` stops stdin flowing and leaves the tty in raw mode, so
1554
+ // for the length of a run the terminal generated no signals and a Ctrl+C sat
1555
+ // in the buffer until the run ended -- pressed, ignored, then delivered at
1556
+ // the one moment it was no longer wanted. Out of raw mode the keystroke is a
1557
+ // real SIGINT again, and the session's interrupt handler can stop the run.
1558
+ const wasRaw = Boolean(process.stdin.isRaw);
1559
+ if (wasRaw) process.stdin.setRawMode?.(false);
1560
+ running?.(true);
1561
+ try {
1562
+ const { agentTestCommand, evalCommand } = await import("./eval.js");
1563
+ const runner = await import("./runner.js");
1564
+ const deps = {
1565
+ makeArgs,
1566
+ runnerLoop: runner.runnerLoop,
1567
+ saveRunnerState: runner.saveRunnerState,
1568
+ readRunnerState: runner.readRunnerState,
1569
+ deviceId: runner.deviceId,
1570
+ };
1571
+ if (doctor) await evalCommand(argv, deps);
1572
+ else await agentTestCommand(argv, deps);
1573
+ } catch (error) {
1574
+ process.stdout.write(`${paint.red(error?.message || String(error))}\n`);
1575
+ } finally {
1576
+ running?.(false);
1577
+ if (wasRaw) process.stdin.setRawMode?.(true);
1578
+ rl.resume();
1579
+ dock.enable();
1580
+ }
1581
+ }
1582
+
1583
+ export async function agentCommand(commandArgs = [], { authenticate = authenticateTerminal } = {}) {
1584
+ const args = makeArgs(commandArgs);
1585
+ if (args.has("--help") || args.has("-h")) {
1586
+ process.stdout.write(`Usage: ${cliInvocation()} [message]\n${AGENT_HELP}\n${SLASH_HELP}\n`);
1587
+ return;
1588
+ }
1589
+
1590
+ const paint = makePaint(colourEnabled(args));
1591
+
1592
+ // Signing in is part of starting a session, not a separate command somebody
1593
+ // has to know about first -- this is the whole first-run path for `preman`.
1594
+ // The walk that also installs the app and wires integrations is still there;
1595
+ // it is offered rather than imposed, because somebody who typed `preman` to
1596
+ // ask a question should get to ask it.
1597
+ if (!hasKeyAvailable(args)) {
1598
+ process.stdout.write("Let's connect your PreMan account first.\n\n");
1599
+ await authenticate(args);
1600
+ process.stdout.write(
1601
+ `\n${paint.dim(`Want the desktop app and integrations too? Run \`${cliInvocation()} onboard\`.`)}\n\n`
1602
+ );
1603
+ }
1604
+ const token = resolveApiKey(args);
1605
+ if (!token) throw new Error(`No PreMan API key. Run \`${cliInvocation()} login\`.`);
1606
+
1607
+ const workspace = await resolveWorkspace(args, token);
1608
+ let conversation = await resolveConversation(args, token, workspace.id);
1609
+
1610
+ const oneShot = positionals(args.raw).join(" ").trim();
1611
+ const live = colourEnabled(args) && Boolean(process.stdout.isTTY);
1612
+ const renderer = createRenderer({ paint, live });
1613
+
1614
+ if (oneShot) {
1615
+ await runTurn({
1616
+ args,
1617
+ token,
1618
+ workspaceId: workspace.id,
1619
+ conversationId: conversation.id,
1620
+ content: oneShot,
1621
+ renderer,
1622
+ paint,
1623
+ });
1624
+ // `--print` is the scriptable form: one message, one reply, exit. Without
1625
+ // it a message on the command line is simply how the session opens.
1626
+ if (args.has("--print") || args.has("-p") || !process.stdin.isTTY) return;
1627
+ }
1628
+
1629
+ // The session's own screen, entered before anything is printed so the banner
1630
+ // lands at the top of it. A terminal that does not understand the sequence
1631
+ // ignores it and gets exactly what it got before.
1632
+ const fullScreen = Boolean(process.stdout.isTTY);
1633
+
1634
+ // Watching before the screen is taken, not after: the handlers have to be up
1635
+ // for every moment the terminal is in a state somebody else has to live with.
1636
+ //
1637
+ // What a person needs after the screen is handed back is the one line that
1638
+ // returns them to this conversation. Read at restore time rather than
1639
+ // captured, because `/new` and `/resume` change which conversation that is.
1640
+ const hold = holdTerminal({
1641
+ stream: process.stdout,
1642
+ footer: () => {
1643
+ // A conversation whose id never arrived stringifies to "undefined", and a
1644
+ // resume line naming that is worse than no resume line.
1645
+ const id = String(conversation?.id || "");
1646
+ if (!fullScreen || !id || id === "undefined") return "";
1647
+ return `${paint.dim("Resume this session with:")}\n ${cliInvocation()} --conversation ${id}\n`;
1648
+ },
1649
+ }).watch();
1650
+
1651
+ if (fullScreen) process.stdout.write(`${ENTER_ALT}${CLEAR_SCREEN}`);
1652
+
1653
+ process.stdout.write(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1654
+
1655
+ const dock = createDock({
1656
+ stream: process.stdout,
1657
+ paint,
1658
+ status: `${MODEL_NAME} \u00b7 ${basename(process.cwd())}`,
1659
+ });
1660
+ const prompt = `${paint.caret("\u276f")} `;
1661
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt });
1662
+ dock.attach(rl);
1663
+ dock.enable();
1664
+ const onResize = () => dock.resize();
1665
+ process.stdout.on("resize", onResize);
1666
+
1667
+ // Registered rather than left to the `finally`: these have to run on a
1668
+ // SIGTERM and on a callback that threw, which never reach it.
1669
+ hold.onTeardown(() => {
1670
+ // Only reaches anything when a run is open, and the import is already
1671
+ // resolved by then -- the module was loaded to start the run.
1672
+ if (evaluating) void import("./eval.js").then(({ cancelEvalRuns }) => cancelEvalRuns());
1673
+ });
1674
+ hold.onTeardown(() => process.stdout.off("resize", onResize));
1675
+ hold.onTeardown(() => dock.disable());
1676
+ hold.onTeardown(() => rl.close());
1677
+
1678
+ // Everything the session prints goes through the dock, so a half-typed line
1679
+ // survives whatever lands underneath it.
1680
+ const say = (text) => dock.write(text);
1681
+ const docked = dock.live ? createRenderer({ stream: dock.proxy(), paint, live }) : renderer;
1682
+
1683
+ /**
1684
+ * Typing while the agent works is the whole point of the dock, so lines are
1685
+ * queued instead of read one at a time: readline stays live for the entire
1686
+ * session and anything typed mid-turn waits its turn rather than being
1687
+ * echoed into the transcript by the tty.
1688
+ */
1689
+ const queued = [];
1690
+ let borrow = null; // a permission prompt wants this line, not the queue
1691
+ let wake = null; // the pump, parked until something arrives
1692
+ let ended = false;
1693
+
1694
+ const nudge = () => {
1695
+ if (!wake) return;
1696
+ const resume = wake;
1697
+ wake = null;
1698
+ resume();
1699
+ };
1700
+
1701
+ if (dock.live) rl.on("line", (raw) => {
1702
+ const text = String(raw);
1703
+ if (borrow) {
1704
+ const answer = borrow;
1705
+ borrow = null;
1706
+ answer(text);
1707
+ return;
1708
+ }
1709
+ queued.push(text);
1710
+ nudge();
1711
+ });
1712
+
1713
+ if (dock.live) rl.on("close", () => {
1714
+ ended = true;
1715
+ if (borrow) {
1716
+ const answer = borrow;
1717
+ borrow = null;
1718
+ answer(null);
1719
+ }
1720
+ nudge();
1721
+ });
1722
+
1723
+ /**
1724
+ * What Ctrl+C means, which until now was not what the help text said.
1725
+ *
1726
+ * Readline in terminal mode eats `\x03` and emits its own `SIGINT` event --
1727
+ * raw mode has already turned off the tty's own signal generation -- so the
1728
+ * `process.on("SIGINT")` a turn installs to abort its fetch was unreachable
1729
+ * from the keyboard. A press mid-answer therefore cancelled nothing and quit
1730
+ * nothing: it marked the session ended and the shell came back whenever the
1731
+ * answer happened to finish.
1732
+ *
1733
+ * So both doors lead here: readline's event for a keystroke, and a real
1734
+ * signal for `kill -INT` and for the stretches where readline is not reading.
1735
+ * The first press is always the small, recoverable thing that fits what is
1736
+ * happening -- stop the turn, drop the half-typed line, leave the question
1737
+ * unanswered. Quitting takes a second press, because the one thing a person
1738
+ * cannot undo is the session ending under an answer they wanted.
1739
+ */
1740
+ let turn = null; // the in-flight turn's abort handle, when there is one
1741
+ let evaluating = false; // a run is holding a harness child and a socket open
1742
+ let quitting = false;
1743
+ let armed = 0;
1744
+
1745
+ function quit() {
1746
+ quitting = true;
1747
+ ended = true;
1748
+ turn?.abort();
1749
+ if (borrow) {
1750
+ const answer = borrow;
1751
+ borrow = null;
1752
+ answer(null);
1753
+ }
1754
+ nudge();
1755
+ rl.close();
1756
+ }
1757
+
1758
+ function interrupt() {
1759
+ const now = Date.now();
1760
+ const second = now - armed < QUIT_WINDOW_MS;
1761
+ armed = now;
1762
+ if (second) {
1763
+ quit();
1764
+ return;
1765
+ }
1766
+ if (turn) {
1767
+ // The message belongs to `runTurn`, which knows whether the abort landed
1768
+ // mid-answer or between events.
1769
+ turn.abort();
1770
+ return;
1771
+ }
1772
+ if (evaluating) {
1773
+ // Stopping a run is closing what it holds open -- the harness child and
1774
+ // the adapter's socket. The command itself then unwinds normally and the
1775
+ // dock comes back, which is what "back to the prompt" means here.
1776
+ process.stdout.write("\n Stopping this eval run.\n");
1777
+ void import("./eval.js").then(({ cancelEvalRuns }) => cancelEvalRuns());
1778
+ return;
1779
+ }
1780
+ if (borrow) {
1781
+ const answer = borrow;
1782
+ borrow = null;
1783
+ answer(null);
1784
+ return;
1785
+ }
1786
+ if (!rl.closed && rl.line) {
1787
+ rl.line = "";
1788
+ rl.cursor = 0;
1789
+ dock.refresh();
1790
+ return;
1791
+ }
1792
+ say(paint.dim(" Press Ctrl+C again to exit\n"));
1793
+ }
1794
+
1795
+ rl.on("SIGINT", interrupt);
1796
+ hold.onInterrupt(interrupt);
1797
+ hold.onTeardown(() => hold.onInterrupt(null));
1798
+
1799
+ /** The next typed line, or null once stdin is done and the queue is dry. */
1800
+ async function nextQueued() {
1801
+ for (;;) {
1802
+ if (queued.length) return queued.shift();
1803
+ if (ended) return null;
1804
+ await new Promise((resume) => {
1805
+ wake = resume;
1806
+ });
1807
+ }
1808
+ }
1809
+
1810
+ /** Lend the one reader on stdin to a mid-turn question. */
1811
+ function ask(question) {
1812
+ if (ended) return Promise.resolve(null);
1813
+ return new Promise((answer) => {
1814
+ borrow = answer;
1815
+ rl.setPrompt(question);
1816
+ dock.refresh();
1817
+ }).finally(() => {
1818
+ rl.setPrompt(prompt);
1819
+ dock.refresh();
1820
+ });
1821
+ }
1822
+
1823
+ try {
1824
+ for (;;) {
1825
+ const line = dock.live ? await nextQueued() : await nextLine(rl, prompt);
1826
+ if (line === null || quitting) break;
1827
+ const text = String(line).trim();
1828
+ dock.echo(text);
1829
+ if (!text) continue;
1830
+
1831
+ const slash = parseSlash(text);
1832
+ if (slash) {
1833
+ if (slash.name === "exit" || slash.name === "quit") break;
1834
+ if (slash.name === "help") {
1835
+ say(`${SLASH_HELP}\n\n`);
1836
+ continue;
1837
+ }
1838
+ if (slash.name === "clear") {
1839
+ // The banner comes back with it. On a screen of its own, a cleared
1840
+ // session that says nothing about itself is indistinguishable from a
1841
+ // shell -- and which workspace and conversation this is were the two
1842
+ // things the clear just took away.
1843
+ say(CLEAR_SCREEN);
1844
+ say(banner({ paint, workspace, backend: backendUrl(args), conversation }));
1845
+ continue;
1846
+ }
1847
+ if (slash.name === "new") {
1848
+ conversation = await newConversation(args, token, workspace.id);
1849
+ say(`${paint.dim(`New conversation ${conversation.id}`)}\n\n`);
1850
+ continue;
1851
+ }
1852
+ if (slash.name === "conversations") {
1853
+ const rows = await listConversations(args, token, workspace.id);
1854
+ if (!rows.length) {
1855
+ say(`${paint.dim("No conversations yet.")}\n\n`);
1856
+ continue;
1857
+ }
1858
+ for (const row of rows.slice(0, 15)) {
1859
+ const marker = String(row.id) === conversation.id ? paint.caret("\u276f") : " ";
1860
+ say(`${marker} ${paint.dim(String(row.id))} ${truncate(row.title || "Untitled", 48)}\n`);
1861
+ }
1862
+ say("\n");
1863
+ continue;
1864
+ }
1865
+ if (slash.name === "resume") {
1866
+ if (!slash.rest) {
1867
+ say(`${paint.dim("Usage: /resume <conversation id>")}\n\n`);
1868
+ continue;
1869
+ }
1870
+ try {
1871
+ conversation = await openConversation(args, token, slash.rest);
1872
+ say(`${paint.dim(`Resumed ${conversation.title}`)}\n\n`);
1873
+ } catch (error) {
1874
+ say(`${paint.red(error.message)}\n\n`);
1875
+ }
1876
+ continue;
1877
+ }
1878
+ if (slash.name === "eval") {
1879
+ await runEval(slash.rest, {
1880
+ args,
1881
+ token,
1882
+ dock,
1883
+ rl,
1884
+ paint,
1885
+ say,
1886
+ running: (on) => {
1887
+ evaluating = on;
1888
+ },
1889
+ });
1890
+ continue;
1891
+ }
1892
+ if (slash.name === "app") {
1893
+ const url = `${frontendUrl(args)}/workbench/${conversation.id}`;
1894
+ openUrl(url);
1895
+ say(`${url}\n\n`);
1896
+ continue;
1897
+ }
1898
+ say(`${paint.dim(`Unknown command ${text}. /help for the list.`)}\n\n`);
1899
+ continue;
1900
+ }
1901
+
1902
+ await runTurn({
1903
+ args,
1904
+ token,
1905
+ workspaceId: workspace.id,
1906
+ conversationId: conversation.id,
1907
+ content: text,
1908
+ renderer: docked,
1909
+ paint,
1910
+ ask: dock.live ? ask : undefined,
1911
+ say: dock.live ? say : undefined,
1912
+ onNote: dock.live ? (text) => dock.setNote(text) : undefined,
1913
+ register: (handle) => {
1914
+ turn = handle;
1915
+ },
1916
+ });
1917
+ turn = null;
1918
+ if (quitting) break;
1919
+ }
1920
+ } finally {
1921
+ // One path, and it is the same one a signal takes: the teardowns are
1922
+ // registered on the hold, so this is only the word that they should run.
1923
+ hold.release();
1924
+ }
1925
+ }