@tsconfigdotjson/vibe-os 0.2.0 → 0.4.0
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 +19 -847
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/web/assets/index-82MhMOB4.css +1 -0
- package/dist/web/assets/index-82MhMOB4.css.br +0 -0
- package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
- package/dist/web/assets/index-DCB2nO0u.js +36 -0
- package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
- package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/scripts/notices.ts +3 -1
- package/server/api.ts +505 -20
- package/server/assets.generated.ts +13 -13
- package/server/attach.ts +65 -16
- package/server/browser.ts +1 -65
- package/server/cli.ts +48 -150
- package/server/client.ts +440 -0
- package/server/config.ts +64 -2
- package/server/db.ts +19 -0
- package/server/doctor.ts +176 -123
- package/server/harness.ts +378 -590
- package/server/harnesses/claude.ts +248 -0
- package/server/harnesses/codex.ts +241 -0
- package/server/harnesses/cursor.ts +202 -0
- package/server/harnesses/hermes.ts +259 -0
- package/server/harnesses/util.ts +181 -0
- package/server/headless.ts +417 -0
- package/server/index.ts +47 -14
- package/server/memory.ts +418 -0
- package/server/profiles.ts +27 -15
- package/server/projects.ts +52 -3
- package/server/reaper.ts +5 -3
- package/server/screens.ts +392 -0
- package/server/session.ts +84 -95
- package/server/setup.ts +73 -7
- package/server/windows.ts +51 -1
- package/shared/blanks.ts +113 -0
- package/shared/harness.ts +375 -0
- package/shared/wire.ts +107 -94
- package/dist/web/assets/index-B77Qk8B5.js +0 -35
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +0 -1
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Reading what a window shows, with no terminal attached.
|
|
2
|
+
//
|
|
3
|
+
// dtach keeps no copy of what a program has drawn. It moves bytes from the pty
|
|
4
|
+
// to whoever is attached and forgets them. So the server attaches as well: for
|
|
5
|
+
// every running session it holds one connection to the dtach socket, speaking
|
|
6
|
+
// dtach's own packet protocol, and feeds the output into a headless xterm. That
|
|
7
|
+
// gives the API a screen it can read as text, and the time of the last output,
|
|
8
|
+
// which is how an agent at work is told from one waiting at its prompt.
|
|
9
|
+
//
|
|
10
|
+
// The connection has to send MSG_ATTACH, because dtach only writes output to
|
|
11
|
+
// attached clients. It never sends a size, except once to ask for a repaint (see
|
|
12
|
+
// `repaint`), so the pty stays the size the browser or `vibe-os attach` gave it.
|
|
13
|
+
// And it is not a `dtach -a` process, so `clientsOn` does not count it and
|
|
14
|
+
// nothing about the one-client rule changes.
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { readdir } from "node:fs/promises";
|
|
18
|
+
import net from "node:net";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import { type IBufferLine, Terminal } from "@xterm/headless";
|
|
22
|
+
import type { Activity } from "../shared/wire.ts";
|
|
23
|
+
import type { Config } from "./config.ts";
|
|
24
|
+
import { sessionTty } from "./headless.ts";
|
|
25
|
+
import { log } from "./log.ts";
|
|
26
|
+
import { DETACHED_COLS, DETACHED_ROWS, socketPath } from "./session.ts";
|
|
27
|
+
|
|
28
|
+
const run = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Output within this long means the program is busy.
|
|
32
|
+
*
|
|
33
|
+
* Every harness animates something while it works (Claude's spinner redraws
|
|
34
|
+
* several times a second) and draws nothing at all while it waits for input,
|
|
35
|
+
* so a second and a half of quiet is a clear line between the two.
|
|
36
|
+
*/
|
|
37
|
+
export const QUIET_MS = 1_500;
|
|
38
|
+
/** History kept per window, beyond the screen itself. */
|
|
39
|
+
const SCROLLBACK = 1_000;
|
|
40
|
+
/** How often to look for sessions started by something other than this server. */
|
|
41
|
+
const SYNC_MS = 5_000;
|
|
42
|
+
/** How often output may trigger a check that the pty is still the same size. */
|
|
43
|
+
const SIZE_CHECK_MS = 1_000;
|
|
44
|
+
/** How long the width stays one column short while asking for a repaint. */
|
|
45
|
+
const NUDGE_MS = 200;
|
|
46
|
+
const POLL_MS = 250;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a program waiting on a question looks like, as opposed to waiting at its
|
|
50
|
+
* prompt.
|
|
51
|
+
*
|
|
52
|
+
* Matched against the visible screen only, so a question answered long ago and
|
|
53
|
+
* scrolled away does not count. Claude's dialogs (trust this folder, allow this
|
|
54
|
+
* command) and Codex's approvals all end in a "confirm" or "cancel" line, and
|
|
55
|
+
* the rest are the shapes a numbered menu or a yes/no question take.
|
|
56
|
+
*/
|
|
57
|
+
export const QUESTION_PATTERNS: RegExp[] = [
|
|
58
|
+
/enter to confirm/i,
|
|
59
|
+
/esc to cancel/i,
|
|
60
|
+
/^\s*[❯›]\s*\d+\.\s+\S/m,
|
|
61
|
+
/\((y\/n)\)|\[(y\/n)\]/i,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// dtach's packet: a type byte, a length byte, then a `struct winsize` of four
|
|
65
|
+
// unsigned shorts, in the machine's own byte order. See dtach.h.
|
|
66
|
+
const MSG_ATTACH = 1;
|
|
67
|
+
const MSG_REDRAW = 4;
|
|
68
|
+
const REDRAW_WINCH = 3;
|
|
69
|
+
const PACKET_BYTES = 10;
|
|
70
|
+
|
|
71
|
+
export function packet(
|
|
72
|
+
type: number,
|
|
73
|
+
len = 0,
|
|
74
|
+
size?: { rows: number; cols: number },
|
|
75
|
+
): Buffer {
|
|
76
|
+
const buf = Buffer.alloc(PACKET_BYTES);
|
|
77
|
+
buf[0] = type;
|
|
78
|
+
buf[1] = len;
|
|
79
|
+
if (size) {
|
|
80
|
+
if (os.endianness() === "LE") {
|
|
81
|
+
buf.writeUInt16LE(size.rows, 2);
|
|
82
|
+
buf.writeUInt16LE(size.cols, 4);
|
|
83
|
+
} else {
|
|
84
|
+
buf.writeUInt16BE(size.rows, 2);
|
|
85
|
+
buf.writeUInt16BE(size.cols, 4);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return buf;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether a program is working, waiting on a question, or at its prompt. */
|
|
92
|
+
export function classify(
|
|
93
|
+
lastOutputAt: number | null,
|
|
94
|
+
now: number,
|
|
95
|
+
visible: string,
|
|
96
|
+
): Activity {
|
|
97
|
+
if (lastOutputAt !== null && now - lastOutputAt < QUIET_MS) return "busy";
|
|
98
|
+
if (QUESTION_PATTERNS.some((p) => p.test(visible))) return "question";
|
|
99
|
+
return "idle";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Reads `stty size`: rows, then columns. */
|
|
103
|
+
export function parseSize(out: string): { rows: number; cols: number } | null {
|
|
104
|
+
const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(out);
|
|
105
|
+
if (!m) return null;
|
|
106
|
+
const rows = Number(m[1]);
|
|
107
|
+
const cols = Number(m[2]);
|
|
108
|
+
return rows > 0 && cols > 0 ? { rows, cols } : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function ttySize(
|
|
112
|
+
tty: string,
|
|
113
|
+
): Promise<{ rows: number; cols: number } | null> {
|
|
114
|
+
const flag = process.platform === "darwin" ? "-f" : "-F";
|
|
115
|
+
try {
|
|
116
|
+
const { stdout } = await run("stty", [flag, tty, "size"], {
|
|
117
|
+
timeout: 5_000,
|
|
118
|
+
});
|
|
119
|
+
return parseSize(stdout);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The last `count` lines of a terminal, as plain text.
|
|
127
|
+
*
|
|
128
|
+
* A line the terminal wrapped is joined back to the one it continues, so a
|
|
129
|
+
* long line reads as it was written rather than as the width it was drawn at.
|
|
130
|
+
* Blank lines at the bottom, which are only the unused part of the screen, are
|
|
131
|
+
* dropped.
|
|
132
|
+
*
|
|
133
|
+
* Faint text on the cursor's line is left out. That is where a harness draws a
|
|
134
|
+
* placeholder or a suggested prompt, greyed out with SGR 2 (Claude's `Try
|
|
135
|
+
* "…"`, Codex's hint), and as plain text it reads the same as something typed
|
|
136
|
+
* and not yet sent. The cursor is the input caret in both, so its line is the
|
|
137
|
+
* input line.
|
|
138
|
+
*/
|
|
139
|
+
export function bufferText(term: Terminal, count: number): string {
|
|
140
|
+
const buf = term.buffer.active;
|
|
141
|
+
const cursorRow = buf.baseY + buf.cursorY;
|
|
142
|
+
const lines: string[] = [];
|
|
143
|
+
for (let i = 0; i < buf.length; i++) {
|
|
144
|
+
const line = buf.getLine(i);
|
|
145
|
+
if (!line) continue;
|
|
146
|
+
// Untrimmed until joined, or a space at the wrap point would be lost.
|
|
147
|
+
const text =
|
|
148
|
+
i === cursorRow ? withoutFaint(line) : line.translateToString(true);
|
|
149
|
+
if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text;
|
|
150
|
+
else lines.push(text);
|
|
151
|
+
}
|
|
152
|
+
const trimmed = lines.map((l) => l.trimEnd());
|
|
153
|
+
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === "")
|
|
154
|
+
trimmed.pop();
|
|
155
|
+
return trimmed.slice(-count).join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A line's text with every faint cell read as a blank. */
|
|
159
|
+
function withoutFaint(line: IBufferLine): string {
|
|
160
|
+
let text = "";
|
|
161
|
+
for (let x = 0; x < line.length; x++) {
|
|
162
|
+
const cell = line.getCell(x);
|
|
163
|
+
// The second half of a wide character has width 0 and no text of its own.
|
|
164
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
165
|
+
text += cell.isDim() ? " ".repeat(cell.getWidth()) : cell.getChars() || " ";
|
|
166
|
+
}
|
|
167
|
+
return text.trimEnd();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One session's screen, kept up to date by its connection. */
|
|
171
|
+
export class Screen {
|
|
172
|
+
readonly term = new Terminal({
|
|
173
|
+
rows: DETACHED_ROWS,
|
|
174
|
+
cols: DETACHED_COLS,
|
|
175
|
+
scrollback: SCROLLBACK,
|
|
176
|
+
allowProposedApi: true,
|
|
177
|
+
});
|
|
178
|
+
/** When the program last drew anything, or null if not since we attached. */
|
|
179
|
+
lastOutputAt: number | null = null;
|
|
180
|
+
private tty: string | null = null;
|
|
181
|
+
private sizeCheckedAt = 0;
|
|
182
|
+
private sizing = false;
|
|
183
|
+
|
|
184
|
+
constructor(
|
|
185
|
+
private readonly sock: string,
|
|
186
|
+
private readonly socket: net.Socket,
|
|
187
|
+
) {}
|
|
188
|
+
|
|
189
|
+
feed(data: Buffer): void {
|
|
190
|
+
this.lastOutputAt = Date.now();
|
|
191
|
+
this.term.write(data);
|
|
192
|
+
if (Date.now() - this.sizeCheckedAt > SIZE_CHECK_MS) void this.syncSize();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
activity(now = Date.now()): Activity {
|
|
196
|
+
return classify(this.lastOutputAt, now, this.visible());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** What the screen shows right now: the bottom `rows` lines of the buffer. */
|
|
200
|
+
visible(): string {
|
|
201
|
+
const buf = this.term.buffer.active;
|
|
202
|
+
const out: string[] = [];
|
|
203
|
+
for (let i = buf.baseY; i < buf.baseY + this.term.rows; i++)
|
|
204
|
+
out.push(buf.getLine(i)?.translateToString(true) ?? "");
|
|
205
|
+
return out.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
text(lines: number): string {
|
|
209
|
+
return bufferText(this.term, lines);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Follows the pty's size, which whoever attaches last decides.
|
|
214
|
+
*
|
|
215
|
+
* Checked when output arrives rather than on a timer, because a resize is
|
|
216
|
+
* always followed by the program redrawing at the new size.
|
|
217
|
+
*/
|
|
218
|
+
async syncSize(): Promise<{ rows: number; cols: number } | null> {
|
|
219
|
+
if (this.sizing) return null;
|
|
220
|
+
this.sizing = true;
|
|
221
|
+
this.sizeCheckedAt = Date.now();
|
|
222
|
+
try {
|
|
223
|
+
this.tty ??= await sessionTty(this.sock);
|
|
224
|
+
const size = this.tty ? await ttySize(this.tty) : null;
|
|
225
|
+
if (
|
|
226
|
+
size &&
|
|
227
|
+
(size.rows !== this.term.rows || size.cols !== this.term.cols)
|
|
228
|
+
)
|
|
229
|
+
this.term.resize(size.cols, size.rows);
|
|
230
|
+
return size;
|
|
231
|
+
} finally {
|
|
232
|
+
this.sizing = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Asks the program to draw its whole screen again, for a session that was
|
|
238
|
+
* running before we attached.
|
|
239
|
+
*
|
|
240
|
+
* dtach's redraw sends SIGWINCH, but a program that finds its size unchanged
|
|
241
|
+
* may not repaint (Claude does not). So the width goes one column narrower
|
|
242
|
+
* and back. Whoever is attached sees one extra repaint.
|
|
243
|
+
*/
|
|
244
|
+
async repaint(): Promise<void> {
|
|
245
|
+
const size = await this.syncSize();
|
|
246
|
+
if (!size || size.cols < 2) return;
|
|
247
|
+
this.socket.write(
|
|
248
|
+
packet(MSG_REDRAW, REDRAW_WINCH, { ...size, cols: size.cols - 1 }),
|
|
249
|
+
);
|
|
250
|
+
await Bun.sleep(NUDGE_MS);
|
|
251
|
+
if (!this.socket.destroyed)
|
|
252
|
+
this.socket.write(packet(MSG_REDRAW, REDRAW_WINCH, size));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
close(): void {
|
|
256
|
+
this.socket.destroy();
|
|
257
|
+
this.term.dispose();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Every running session's screen, keyed by session name. */
|
|
262
|
+
export class Screens {
|
|
263
|
+
private readonly open = new Map<string, Screen>();
|
|
264
|
+
private readonly pending = new Map<string, Promise<Screen | undefined>>();
|
|
265
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
266
|
+
|
|
267
|
+
constructor(private readonly config: Config) {}
|
|
268
|
+
|
|
269
|
+
/** Watches whatever is running now, then looks again every few seconds. */
|
|
270
|
+
start(): void {
|
|
271
|
+
void this.sync();
|
|
272
|
+
this.timer = setInterval(() => void this.sync(), SYNC_MS);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
stop(): void {
|
|
276
|
+
if (this.timer) clearInterval(this.timer);
|
|
277
|
+
this.timer = null;
|
|
278
|
+
for (const screen of this.open.values()) screen.close();
|
|
279
|
+
this.open.clear();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
get(session: string): Screen | undefined {
|
|
283
|
+
return this.open.get(session);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Attaches to a session if nothing is yet, and returns its screen.
|
|
288
|
+
*
|
|
289
|
+
* Undefined when the session is not running. `fresh` is for a session this
|
|
290
|
+
* server has just created: it has drawn next to nothing yet, and asking it to
|
|
291
|
+
* repaint would only make it draw its startup twice.
|
|
292
|
+
*/
|
|
293
|
+
watch(
|
|
294
|
+
session: string,
|
|
295
|
+
opts: { fresh?: boolean } = {},
|
|
296
|
+
): Promise<Screen | undefined> {
|
|
297
|
+
const open = this.open.get(session);
|
|
298
|
+
if (open) return Promise.resolve(open);
|
|
299
|
+
let attempt = this.pending.get(session);
|
|
300
|
+
if (!attempt) {
|
|
301
|
+
attempt = this.connect(session, opts.fresh === true).finally(() =>
|
|
302
|
+
this.pending.delete(session),
|
|
303
|
+
);
|
|
304
|
+
this.pending.set(session, attempt);
|
|
305
|
+
}
|
|
306
|
+
return attempt;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private connect(
|
|
310
|
+
session: string,
|
|
311
|
+
fresh: boolean,
|
|
312
|
+
): Promise<Screen | undefined> {
|
|
313
|
+
const sock = socketPath(this.config, session);
|
|
314
|
+
return new Promise((resolve) => {
|
|
315
|
+
const socket = net.createConnection(sock);
|
|
316
|
+
socket.once("error", () => resolve(undefined));
|
|
317
|
+
socket.once("connect", () => {
|
|
318
|
+
socket.write(packet(MSG_ATTACH));
|
|
319
|
+
const screen = new Screen(sock, socket);
|
|
320
|
+
this.open.set(session, screen);
|
|
321
|
+
socket.on("data", (data: Buffer) => screen.feed(data));
|
|
322
|
+
socket.on("error", () => {});
|
|
323
|
+
socket.on("close", () => {
|
|
324
|
+
if (this.open.get(session) === screen) this.open.delete(session);
|
|
325
|
+
screen.close();
|
|
326
|
+
});
|
|
327
|
+
log.debug(`watching ${session}`);
|
|
328
|
+
if (fresh) void screen.syncSize();
|
|
329
|
+
else void screen.repaint();
|
|
330
|
+
resolve(screen);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Attaches to every socket in the sessions directory not already watched. */
|
|
336
|
+
async sync(): Promise<void> {
|
|
337
|
+
let names: string[];
|
|
338
|
+
try {
|
|
339
|
+
names = await readdir(`${this.config.stateDir}/sessions`);
|
|
340
|
+
} catch {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await Promise.all(
|
|
344
|
+
names
|
|
345
|
+
.filter((n) => n.endsWith(".sock"))
|
|
346
|
+
.map((n) => n.slice(0, -".sock".length))
|
|
347
|
+
.filter((s) => !this.open.has(s))
|
|
348
|
+
.map((s) => this.watch(s)),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Waits for a session to stop drawing, and says what it stopped on.
|
|
354
|
+
*
|
|
355
|
+
* The quiet has to be seen, not assumed: a session started a moment ago has
|
|
356
|
+
* drawn nothing yet, which looks the same as one that has finished. So the
|
|
357
|
+
* wait counts from when it began as if that were output, and always lasts at
|
|
358
|
+
* least `QUIET_MS`. Returns "busy" if it was still going when time ran out.
|
|
359
|
+
*/
|
|
360
|
+
async settle(session: string, timeoutMs: number): Promise<Activity> {
|
|
361
|
+
const begun = Date.now();
|
|
362
|
+
const deadline = begun + timeoutMs;
|
|
363
|
+
for (;;) {
|
|
364
|
+
const screen = this.get(session) ?? (await this.watch(session));
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
const state = screen
|
|
367
|
+
? classify(
|
|
368
|
+
Math.max(screen.lastOutputAt ?? 0, begun),
|
|
369
|
+
now,
|
|
370
|
+
screen.visible(),
|
|
371
|
+
)
|
|
372
|
+
: "idle";
|
|
373
|
+
if (state !== "busy" || now > deadline) return state;
|
|
374
|
+
await Bun.sleep(POLL_MS);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Waits for any output after `since`. False if none came in time. */
|
|
379
|
+
async drewSince(
|
|
380
|
+
session: string,
|
|
381
|
+
since: number,
|
|
382
|
+
timeoutMs: number,
|
|
383
|
+
): Promise<boolean> {
|
|
384
|
+
const deadline = Date.now() + timeoutMs;
|
|
385
|
+
for (;;) {
|
|
386
|
+
const at = this.get(session)?.lastOutputAt ?? null;
|
|
387
|
+
if (at !== null && at > since) return true;
|
|
388
|
+
if (Date.now() > deadline) return false;
|
|
389
|
+
await Bun.sleep(POLL_MS / 5);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
package/server/session.ts
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
// too, and api.ts needs attach.ts — importing in a circle to share three pure
|
|
12
12
|
// functions is not a trade worth making.
|
|
13
13
|
|
|
14
|
+
import { CUSTOM } from "../shared/harness.ts";
|
|
14
15
|
import type { Config } from "./config.ts";
|
|
16
|
+
import { harness } from "./harness.ts";
|
|
17
|
+
import { effectiveLimits, SCOPE_PROBE, scopePrefix } from "./memory.ts";
|
|
15
18
|
import type { Profile } from "./profiles.ts";
|
|
16
19
|
import { SOCK_VAR } from "./reaper.ts";
|
|
17
20
|
|
|
@@ -21,63 +24,15 @@ export function shellQuote(value: string): string {
|
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
|
-
*
|
|
27
|
+
* Whether a profile's harness can pick its last conversation back up.
|
|
25
28
|
*
|
|
26
|
-
* Every
|
|
27
|
-
* the
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `/tmp/Cursor.Test_1 space/sub` is `tmp-Cursor-Test-1-space-sub`.
|
|
29
|
+
* Every agent harness here resumes the most recent conversation in the working
|
|
30
|
+
* directory. That is the window's own conversation as long as it is the only
|
|
31
|
+
* one of its harness in the worktree, which is what opening a profile normally
|
|
32
|
+
* guarantees.
|
|
31
33
|
*/
|
|
32
|
-
export function
|
|
33
|
-
return
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Shell that points this worktree's Cursor project at the box's MCP credentials.
|
|
38
|
-
*
|
|
39
|
-
* Cursor keeps MCP OAuth tokens per working directory, in
|
|
40
|
-
* `~/.cursor/projects/<slug of the directory>/mcp-auth.json`. The server list is
|
|
41
|
-
* global — `~/.cursor/mcp.json` — so a new window shows Linear as configured and
|
|
42
|
-
* then says `requires_authentication`, because the tokens are somewhere else.
|
|
43
|
-
* Every workspace here is a freshly cut worktree at a path Cursor has never
|
|
44
|
-
* seen, which makes "log in once on this box" mean "log in once per window"
|
|
45
|
-
* unless something intervenes.
|
|
46
|
-
*
|
|
47
|
-
* A symlink is what intervenes, and it is a symlink rather than a copy because
|
|
48
|
-
* Cursor writes through it: an access token lasts a day, so the refresh that
|
|
49
|
-
* happens in whichever window is open has to be the refresh every other window
|
|
50
|
-
* sees. Copies would go stale a day apart and each one would race the others
|
|
51
|
-
* for a refresh token the provider rotates.
|
|
52
|
-
*
|
|
53
|
-
* The link is only ever created where there is nothing at all. An existing file
|
|
54
|
-
* is somebody's real per-directory login and is left alone; an existing link is
|
|
55
|
-
* this, already done. Neither is worth overwriting to save a stat.
|
|
56
|
-
*
|
|
57
|
-
* The shared store is deliberately allowed not to exist yet. Writing through a
|
|
58
|
-
* dangling symlink creates the target, so `cursor-agent mcp login linear` in any
|
|
59
|
-
* window is the one login the box needs, wherever it happens — which is why the
|
|
60
|
-
* store's directory is created here even though the file is not.
|
|
61
|
-
*
|
|
62
|
-
* Failure is silent on purpose, and inert rather than fatal. If Cursor ever
|
|
63
|
-
* changes how it names these directories, or `CURSOR_DATA_DIR` moves them, the
|
|
64
|
-
* link lands somewhere Cursor does not read and windows behave exactly as they
|
|
65
|
-
* did before: a login prompt, not a broken window.
|
|
66
|
-
*/
|
|
67
|
-
function cursorMcpLink(cwd: string, stateDir: string): string {
|
|
68
|
-
const store = `${stateDir}/cursor/mcp-auth.json`;
|
|
69
|
-
const storeDir = shellQuote(`${stateDir}/cursor`);
|
|
70
|
-
// The slug is letters, digits and dashes by construction, so it needs no
|
|
71
|
-
// quoting of its own; `$HOME` is left to the shell because the session runs as
|
|
72
|
-
// the user whose credentials these are.
|
|
73
|
-
const dir = `"$HOME/.cursor/projects/${cursorProjectSlug(cwd)}"`;
|
|
74
|
-
const link = `"$HOME/.cursor/projects/${cursorProjectSlug(cwd)}/mcp-auth.json"`;
|
|
75
|
-
return [
|
|
76
|
-
`mkdir -p ${dir} ${storeDir} 2>/dev/null`,
|
|
77
|
-
// Refresh tokens, so no wider than the state dir this sits in.
|
|
78
|
-
`chmod 700 ${storeDir} 2>/dev/null`,
|
|
79
|
-
`{ [ -e ${link} ] || [ -L ${link} ] || ln -s ${shellQuote(store)} ${link} 2>/dev/null; }`,
|
|
80
|
-
].join("; ");
|
|
34
|
+
export function canResume(profile: Profile | null | undefined): boolean {
|
|
35
|
+
return Boolean(profile && harness(profile.harness)?.spec.resume);
|
|
81
36
|
}
|
|
82
37
|
|
|
83
38
|
/**
|
|
@@ -102,40 +57,43 @@ function cursorMcpLink(cwd: string, stateDir: string): string {
|
|
|
102
57
|
*/
|
|
103
58
|
export function harnessCommand(
|
|
104
59
|
profile: Profile,
|
|
105
|
-
/** The worktree it runs in and the state dir, for the
|
|
60
|
+
/** The worktree it runs in and the state dir, for the harness's own setup. */
|
|
106
61
|
where: { cwd: string; stateDir: string },
|
|
62
|
+
/** Pick up the last conversation instead of starting a new one. */
|
|
63
|
+
resume = false,
|
|
107
64
|
): string | undefined {
|
|
108
|
-
|
|
109
|
-
profile.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
profile.harness === "cursor"
|
|
116
|
-
? "cursor-agent"
|
|
117
|
-
: profile.harness === "custom"
|
|
118
|
-
? profile.command
|
|
119
|
-
: null;
|
|
120
|
-
if (!executable) return undefined;
|
|
121
|
-
/*
|
|
122
|
-
* `hermes` on its own opens a chat, but `--model` and `--provider` belong to
|
|
123
|
-
* the `chat` subcommand rather than to the top-level command, so a profile
|
|
124
|
-
* that picks a model has to name it. It is prepended here rather than stored
|
|
125
|
-
* in the profile's argv because it is not a flag anyone should be able to
|
|
126
|
-
* delete in the editor: without it every model selection is silently ignored.
|
|
127
|
-
*/
|
|
128
|
-
const leading = profile.harness === "hermes" ? ["chat"] : [];
|
|
129
|
-
const argv = [executable, ...leading, ...profile.args]
|
|
130
|
-
.map(shellQuote)
|
|
131
|
-
.join(" ");
|
|
65
|
+
if (profile.harness === CUSTOM) {
|
|
66
|
+
if (!profile.command) return undefined;
|
|
67
|
+
return launch(profile.command, profile.args, "");
|
|
68
|
+
}
|
|
69
|
+
const adapter = harness(profile.harness);
|
|
70
|
+
if (!adapter) return undefined;
|
|
71
|
+
const { spec } = adapter;
|
|
132
72
|
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
73
|
+
const before: string[] = [...(spec.leading ?? [])];
|
|
74
|
+
const after: string[] = [];
|
|
75
|
+
const unless = new Set(spec.resume?.unless ?? []);
|
|
76
|
+
if (
|
|
77
|
+
resume &&
|
|
78
|
+
spec.resume &&
|
|
79
|
+
!profile.args.some((a) => unless.has(a.split("=")[0]))
|
|
80
|
+
) {
|
|
81
|
+
if (spec.resume.subcommand) before.push(...spec.resume.args);
|
|
82
|
+
else after.push(...spec.resume.args);
|
|
83
|
+
}
|
|
138
84
|
|
|
85
|
+
const setup = adapter.setup ? `${adapter.setup(where)}; ` : "";
|
|
86
|
+
return launch(spec.command, [...before, ...profile.args, ...after], setup);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The shell line that runs a harness: PATH restored, setup, then exec.
|
|
91
|
+
*
|
|
92
|
+
* Every token is quoted individually so a flag can contain spaces and cannot
|
|
93
|
+
* contain a second command.
|
|
94
|
+
*/
|
|
95
|
+
function launch(executable: string, args: string[], setup: string): string {
|
|
96
|
+
const argv = [executable, ...args].map(shellQuote).join(" ");
|
|
139
97
|
/*
|
|
140
98
|
* The PATH a forced command gets is not the PATH you get when you log in.
|
|
141
99
|
*
|
|
@@ -154,6 +112,16 @@ export function harnessCommand(
|
|
|
154
112
|
return `export PATH="$HOME/.local/bin:$PATH"; ${setup}exec ${argv}`;
|
|
155
113
|
}
|
|
156
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The size a session started with no terminal gets.
|
|
117
|
+
*
|
|
118
|
+
* dtach copies the size of the terminal that creates a session, and a detached
|
|
119
|
+
* start has none, so the program would come up at 0x0. The first browser or
|
|
120
|
+
* `vibe-os attach` sends its real size and the program repaints.
|
|
121
|
+
*/
|
|
122
|
+
export const DETACHED_ROWS = 40;
|
|
123
|
+
export const DETACHED_COLS = 120;
|
|
124
|
+
|
|
157
125
|
/** Where a window's dtach socket lives. */
|
|
158
126
|
export function socketPath(config: Config, session: string): string {
|
|
159
127
|
return `${config.stateDir}/sessions/${session}.sock`;
|
|
@@ -209,14 +177,20 @@ export function windowCommand(
|
|
|
209
177
|
cwd: string,
|
|
210
178
|
config: Config,
|
|
211
179
|
profile?: Profile | null,
|
|
212
|
-
/**
|
|
213
|
-
|
|
180
|
+
/**
|
|
181
|
+
* `forDisplay` drops the locking wrapper, leaving the readable essentials.
|
|
182
|
+
* `resume` only matters if the session is created rather than attached to.
|
|
183
|
+
* `detached` creates the session and stops there, without attaching, for a
|
|
184
|
+
* window started before any terminal opens it. It has no pty to copy a size
|
|
185
|
+
* from, so it sets one.
|
|
186
|
+
*/
|
|
187
|
+
opts?: { forDisplay?: boolean; resume?: boolean; detached?: boolean },
|
|
214
188
|
): string | undefined {
|
|
215
189
|
if (!config.sessions) return undefined;
|
|
216
190
|
|
|
217
191
|
const sock = socketPath(config, session);
|
|
218
192
|
const harness = profile
|
|
219
|
-
? harnessCommand(profile, { cwd, stateDir: config.stateDir })
|
|
193
|
+
? harnessCommand(profile, { cwd, stateDir: config.stateDir }, opts?.resume)
|
|
220
194
|
: undefined;
|
|
221
195
|
// Falls back to the login shell, so a window with no profile is still a
|
|
222
196
|
// session that survives a reload rather than a bare ssh command.
|
|
@@ -263,7 +237,9 @@ export function windowCommand(
|
|
|
263
237
|
* reports. Failure is ignored on purpose — a box where `stty` is missing
|
|
264
238
|
* should still open a window.
|
|
265
239
|
*/
|
|
266
|
-
const baud =
|
|
240
|
+
const baud = opts?.detached
|
|
241
|
+
? `stty 38400 rows ${DETACHED_ROWS} cols ${DETACHED_COLS} 2>/dev/null;`
|
|
242
|
+
: "stty 38400 2>/dev/null;";
|
|
267
243
|
const inner = `cd ${shellQuote(cwd)} && ${baud} ${env} ${harness ?? 'exec "$SHELL"'}`;
|
|
268
244
|
|
|
269
245
|
// `dtach -p` writes to a live socket and fails on a dead one, which makes it
|
|
@@ -284,7 +260,19 @@ export function windowCommand(
|
|
|
284
260
|
* what lets the reaper name the owner of a process found running in a
|
|
285
261
|
* deleted worktree a week later. reaper.ts is the only reader.
|
|
286
262
|
*/
|
|
287
|
-
const
|
|
263
|
+
const dtachNew = `dtach -n ${shellQuote(sock)} -E -z /bin/sh -c ${shellQuote(inner)}`;
|
|
264
|
+
/*
|
|
265
|
+
* A scope of its own, with the memory limits, when the login has a user
|
|
266
|
+
* systemd to ask (see memory.ts). The marker goes on both branches: a
|
|
267
|
+
* `VAR=value` prefix cannot sit in front of an `if`, and exporting it would
|
|
268
|
+
* hand it to the attaching client below as well, which would then count as
|
|
269
|
+
* part of the session it is only looking at.
|
|
270
|
+
*/
|
|
271
|
+
const scope = scopePrefix(effectiveLimits(config.memory, profile), session);
|
|
272
|
+
const marked = `${SOCK_VAR}=${shellQuote(sock)}`;
|
|
273
|
+
const create = scope
|
|
274
|
+
? `if ${SCOPE_PROBE}; then ${marked} ${scope}${dtachNew}; else ${marked} ${dtachNew}; fi`
|
|
275
|
+
: `${marked} ${dtachNew}`;
|
|
288
276
|
const attach = `exec dtach -a ${shellQuote(sock)} -E -z -r winch`;
|
|
289
277
|
|
|
290
278
|
/*
|
|
@@ -315,16 +303,17 @@ export function windowCommand(
|
|
|
315
303
|
const critical = `${probe} || { rm -f ${shellQuote(sock)}; ${create}; }`;
|
|
316
304
|
|
|
317
305
|
// The banner wants to show what a window actually runs, which is the create
|
|
318
|
-
// and the attach. Printing the locking wrapper as
|
|
319
|
-
// branch, with three levels of nested quoting — buries
|
|
320
|
-
// backslashes and tells the reader nothing they can act on.
|
|
321
|
-
if (opts?.forDisplay)
|
|
306
|
+
// (scoped, when it can be) and the attach. Printing the locking wrapper as
|
|
307
|
+
// well — twice, once per branch, with three levels of nested quoting — buries
|
|
308
|
+
// that in a wall of backslashes and tells the reader nothing they can act on.
|
|
309
|
+
if (opts?.forDisplay)
|
|
310
|
+
return [`${marked} ${scope}${dtachNew}`, attach].join("; ");
|
|
322
311
|
|
|
323
312
|
return [
|
|
324
313
|
`mkdir -p ${shellQuote(`${config.stateDir}/sessions`)}`,
|
|
325
314
|
`if command -v flock > /dev/null 2>&1; then ` +
|
|
326
315
|
`flock -o ${shellQuote(`${sock}.lock`)} -c ${shellQuote(critical)}; ` +
|
|
327
316
|
`else { ${critical}; }; fi`,
|
|
328
|
-
attach,
|
|
317
|
+
...(opts?.detached ? [] : [attach]),
|
|
329
318
|
].join("; ");
|
|
330
319
|
}
|