@tsconfigdotjson/vibe-os 0.2.0-rc.1 → 0.3.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.
Files changed (45) hide show
  1. package/README.md +19 -847
  2. package/THIRD_PARTY_NOTICES.md +28 -0
  3. package/dist/web/assets/index-82MhMOB4.css +1 -0
  4. package/dist/web/assets/index-82MhMOB4.css.br +0 -0
  5. package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
  6. package/dist/web/assets/index-DCB2nO0u.js +36 -0
  7. package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
  8. package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
  9. package/dist/web/index.html +2 -2
  10. package/package.json +2 -1
  11. package/scripts/notices.ts +3 -1
  12. package/server/api.ts +449 -20
  13. package/server/assets.generated.ts +13 -13
  14. package/server/attach.ts +65 -16
  15. package/server/browser.ts +1 -65
  16. package/server/cli.ts +48 -150
  17. package/server/client.ts +440 -0
  18. package/server/config.ts +64 -2
  19. package/server/db.ts +19 -0
  20. package/server/doctor.ts +176 -123
  21. package/server/harness.ts +378 -590
  22. package/server/harnesses/claude.ts +248 -0
  23. package/server/harnesses/codex.ts +241 -0
  24. package/server/harnesses/cursor.ts +202 -0
  25. package/server/harnesses/hermes.ts +259 -0
  26. package/server/harnesses/util.ts +181 -0
  27. package/server/headless.ts +358 -0
  28. package/server/index.ts +47 -14
  29. package/server/memory.ts +418 -0
  30. package/server/profiles.ts +27 -15
  31. package/server/projects.ts +52 -3
  32. package/server/reaper.ts +5 -3
  33. package/server/screens.ts +372 -0
  34. package/server/session.ts +84 -95
  35. package/server/setup.ts +73 -7
  36. package/server/windows.ts +51 -1
  37. package/shared/blanks.ts +113 -0
  38. package/shared/harness.ts +375 -0
  39. package/shared/wire.ts +105 -94
  40. package/dist/web/assets/index-B77Qk8B5.js +0 -35
  41. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  42. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  43. package/dist/web/assets/index-CH5Em-Mo.css +0 -1
  44. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  45. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
@@ -0,0 +1,372 @@
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 { 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
+ export function bufferText(term: Terminal, count: number): string {
134
+ const buf = term.buffer.active;
135
+ const lines: string[] = [];
136
+ for (let i = 0; i < buf.length; i++) {
137
+ const line = buf.getLine(i);
138
+ if (!line) continue;
139
+ // Untrimmed until joined, or a space at the wrap point would be lost.
140
+ const text = line.translateToString(true);
141
+ if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text;
142
+ else lines.push(text);
143
+ }
144
+ const trimmed = lines.map((l) => l.trimEnd());
145
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1] === "")
146
+ trimmed.pop();
147
+ return trimmed.slice(-count).join("\n");
148
+ }
149
+
150
+ /** One session's screen, kept up to date by its connection. */
151
+ export class Screen {
152
+ readonly term = new Terminal({
153
+ rows: DETACHED_ROWS,
154
+ cols: DETACHED_COLS,
155
+ scrollback: SCROLLBACK,
156
+ allowProposedApi: true,
157
+ });
158
+ /** When the program last drew anything, or null if not since we attached. */
159
+ lastOutputAt: number | null = null;
160
+ private tty: string | null = null;
161
+ private sizeCheckedAt = 0;
162
+ private sizing = false;
163
+
164
+ constructor(
165
+ private readonly sock: string,
166
+ private readonly socket: net.Socket,
167
+ ) {}
168
+
169
+ feed(data: Buffer): void {
170
+ this.lastOutputAt = Date.now();
171
+ this.term.write(data);
172
+ if (Date.now() - this.sizeCheckedAt > SIZE_CHECK_MS) void this.syncSize();
173
+ }
174
+
175
+ activity(now = Date.now()): Activity {
176
+ return classify(this.lastOutputAt, now, this.visible());
177
+ }
178
+
179
+ /** What the screen shows right now: the bottom `rows` lines of the buffer. */
180
+ visible(): string {
181
+ const buf = this.term.buffer.active;
182
+ const out: string[] = [];
183
+ for (let i = buf.baseY; i < buf.baseY + this.term.rows; i++)
184
+ out.push(buf.getLine(i)?.translateToString(true) ?? "");
185
+ return out.join("\n");
186
+ }
187
+
188
+ text(lines: number): string {
189
+ return bufferText(this.term, lines);
190
+ }
191
+
192
+ /**
193
+ * Follows the pty's size, which whoever attaches last decides.
194
+ *
195
+ * Checked when output arrives rather than on a timer, because a resize is
196
+ * always followed by the program redrawing at the new size.
197
+ */
198
+ async syncSize(): Promise<{ rows: number; cols: number } | null> {
199
+ if (this.sizing) return null;
200
+ this.sizing = true;
201
+ this.sizeCheckedAt = Date.now();
202
+ try {
203
+ this.tty ??= await sessionTty(this.sock);
204
+ const size = this.tty ? await ttySize(this.tty) : null;
205
+ if (
206
+ size &&
207
+ (size.rows !== this.term.rows || size.cols !== this.term.cols)
208
+ )
209
+ this.term.resize(size.cols, size.rows);
210
+ return size;
211
+ } finally {
212
+ this.sizing = false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Asks the program to draw its whole screen again, for a session that was
218
+ * running before we attached.
219
+ *
220
+ * dtach's redraw sends SIGWINCH, but a program that finds its size unchanged
221
+ * may not repaint (Claude does not). So the width goes one column narrower
222
+ * and back. Whoever is attached sees one extra repaint.
223
+ */
224
+ async repaint(): Promise<void> {
225
+ const size = await this.syncSize();
226
+ if (!size || size.cols < 2) return;
227
+ this.socket.write(
228
+ packet(MSG_REDRAW, REDRAW_WINCH, { ...size, cols: size.cols - 1 }),
229
+ );
230
+ await Bun.sleep(NUDGE_MS);
231
+ if (!this.socket.destroyed)
232
+ this.socket.write(packet(MSG_REDRAW, REDRAW_WINCH, size));
233
+ }
234
+
235
+ close(): void {
236
+ this.socket.destroy();
237
+ this.term.dispose();
238
+ }
239
+ }
240
+
241
+ /** Every running session's screen, keyed by session name. */
242
+ export class Screens {
243
+ private readonly open = new Map<string, Screen>();
244
+ private readonly pending = new Map<string, Promise<Screen | undefined>>();
245
+ private timer: ReturnType<typeof setInterval> | null = null;
246
+
247
+ constructor(private readonly config: Config) {}
248
+
249
+ /** Watches whatever is running now, then looks again every few seconds. */
250
+ start(): void {
251
+ void this.sync();
252
+ this.timer = setInterval(() => void this.sync(), SYNC_MS);
253
+ }
254
+
255
+ stop(): void {
256
+ if (this.timer) clearInterval(this.timer);
257
+ this.timer = null;
258
+ for (const screen of this.open.values()) screen.close();
259
+ this.open.clear();
260
+ }
261
+
262
+ get(session: string): Screen | undefined {
263
+ return this.open.get(session);
264
+ }
265
+
266
+ /**
267
+ * Attaches to a session if nothing is yet, and returns its screen.
268
+ *
269
+ * Undefined when the session is not running. `fresh` is for a session this
270
+ * server has just created: it has drawn next to nothing yet, and asking it to
271
+ * repaint would only make it draw its startup twice.
272
+ */
273
+ watch(
274
+ session: string,
275
+ opts: { fresh?: boolean } = {},
276
+ ): Promise<Screen | undefined> {
277
+ const open = this.open.get(session);
278
+ if (open) return Promise.resolve(open);
279
+ let attempt = this.pending.get(session);
280
+ if (!attempt) {
281
+ attempt = this.connect(session, opts.fresh === true).finally(() =>
282
+ this.pending.delete(session),
283
+ );
284
+ this.pending.set(session, attempt);
285
+ }
286
+ return attempt;
287
+ }
288
+
289
+ private connect(
290
+ session: string,
291
+ fresh: boolean,
292
+ ): Promise<Screen | undefined> {
293
+ const sock = socketPath(this.config, session);
294
+ return new Promise((resolve) => {
295
+ const socket = net.createConnection(sock);
296
+ socket.once("error", () => resolve(undefined));
297
+ socket.once("connect", () => {
298
+ socket.write(packet(MSG_ATTACH));
299
+ const screen = new Screen(sock, socket);
300
+ this.open.set(session, screen);
301
+ socket.on("data", (data: Buffer) => screen.feed(data));
302
+ socket.on("error", () => {});
303
+ socket.on("close", () => {
304
+ if (this.open.get(session) === screen) this.open.delete(session);
305
+ screen.close();
306
+ });
307
+ log.debug(`watching ${session}`);
308
+ if (fresh) void screen.syncSize();
309
+ else void screen.repaint();
310
+ resolve(screen);
311
+ });
312
+ });
313
+ }
314
+
315
+ /** Attaches to every socket in the sessions directory not already watched. */
316
+ async sync(): Promise<void> {
317
+ let names: string[];
318
+ try {
319
+ names = await readdir(`${this.config.stateDir}/sessions`);
320
+ } catch {
321
+ return;
322
+ }
323
+ await Promise.all(
324
+ names
325
+ .filter((n) => n.endsWith(".sock"))
326
+ .map((n) => n.slice(0, -".sock".length))
327
+ .filter((s) => !this.open.has(s))
328
+ .map((s) => this.watch(s)),
329
+ );
330
+ }
331
+
332
+ /**
333
+ * Waits for a session to stop drawing, and says what it stopped on.
334
+ *
335
+ * The quiet has to be seen, not assumed: a session started a moment ago has
336
+ * drawn nothing yet, which looks the same as one that has finished. So the
337
+ * wait counts from when it began as if that were output, and always lasts at
338
+ * least `QUIET_MS`. Returns "busy" if it was still going when time ran out.
339
+ */
340
+ async settle(session: string, timeoutMs: number): Promise<Activity> {
341
+ const begun = Date.now();
342
+ const deadline = begun + timeoutMs;
343
+ for (;;) {
344
+ const screen = this.get(session) ?? (await this.watch(session));
345
+ const now = Date.now();
346
+ const state = screen
347
+ ? classify(
348
+ Math.max(screen.lastOutputAt ?? 0, begun),
349
+ now,
350
+ screen.visible(),
351
+ )
352
+ : "idle";
353
+ if (state !== "busy" || now > deadline) return state;
354
+ await Bun.sleep(POLL_MS);
355
+ }
356
+ }
357
+
358
+ /** Waits for any output after `since`. False if none came in time. */
359
+ async drewSince(
360
+ session: string,
361
+ since: number,
362
+ timeoutMs: number,
363
+ ): Promise<boolean> {
364
+ const deadline = Date.now() + timeoutMs;
365
+ for (;;) {
366
+ const at = this.get(session)?.lastOutputAt ?? null;
367
+ if (at !== null && at > since) return true;
368
+ if (Date.now() > deadline) return false;
369
+ await Bun.sleep(POLL_MS / 5);
370
+ }
371
+ }
372
+ }
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
- * Cursor's name for a directory, as it spells it under `~/.cursor/projects`.
27
+ * Whether a profile's harness can pick its last conversation back up.
25
28
  *
26
- * Every run of anything that is not a letter or a digit becomes one dash, and
27
- * the leading one that a path always starts with is dropped. Case survives.
28
- * Read off a real install rather than from documentation, which does not
29
- * mention the directory at all: `/home/ubuntu` is `home-ubuntu`, and
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 cursorProjectSlug(dir: string): string {
33
- return dir.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
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 Cursor MCP link. */
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
- const executable =
109
- profile.harness === "claude"
110
- ? "claude"
111
- : profile.harness === "hermes"
112
- ? "hermes"
113
- : // The unambiguous of the two names its installer symlinks — `agent`
114
- // is the other, and too generic to be the one a PATH lookup bets on.
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
- // Only Cursor keeps credentials per directory, so only Cursor pays for this.
134
- const setup =
135
- profile.harness === "cursor"
136
- ? `${cursorMcpLink(where.cwd, where.stateDir)}; `
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
- /** `forDisplay` drops the locking wrapper, leaving the readable essentials. */
213
- opts?: { forDisplay?: boolean },
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 = "stty 38400 2>/dev/null;";
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 create = `${SOCK_VAR}=${shellQuote(sock)} dtach -n ${shellQuote(sock)} -E -z /bin/sh -c ${shellQuote(inner)}`;
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 well — twice, once per
319
- // branch, with three levels of nested quoting — buries that in a wall of
320
- // backslashes and tells the reader nothing they can act on.
321
- if (opts?.forDisplay) return [create, attach].join("; ");
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
  }