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/README.md +59 -0
- package/bin/agent.js +1925 -0
- package/bin/agent_dialects.js +385 -0
- package/bin/cli.js +136 -17
- package/bin/eval.js +247 -15
- package/bin/eval_harness.py +6 -2
- package/bin/eval_target.js +102 -18
- package/bin/link.js +40 -13
- package/bin/predict.js +857 -0
- package/bin/runner.js +4 -1
- package/bin/target.js +505 -0
- package/dist/server.js +6 -0
- package/package.json +2 -2
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
|
+
}
|