@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,358 @@
1
+ // Starting a window, and typing into one, with no terminal attached.
2
+ //
3
+ // A window's session is normally created by the certificate's force-command
4
+ // the first time a browser or `vibe-os attach` logs in as it. Automation wants
5
+ // the window running before anyone looks at it, so `startSession` logs in the
6
+ // same way: it mints a throwaway key, has the CA sign it with the window's
7
+ // command, and connects to sshd with it. The session then comes from sshd, like
8
+ // every other one, with the login environment and the logind session that go
9
+ // with it. Spawning the command from this process instead would put it in the
10
+ // server's cgroup, where restarting the service ends it.
11
+ //
12
+ // `paste` is the prompt band's Send, done from here: the text goes into the
13
+ // pty wrapped in bracketed-paste markers, through `dtach -p`, which writes to a
14
+ // session without attaching to it. Knowing when a window is ready for it, past
15
+ // raw mode, needs its screen, which is screens.ts and the API's `deliver`.
16
+
17
+ import { execFile, spawn } from "node:child_process";
18
+ import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { promisify } from "node:util";
22
+ import { type AttachTarget, liveSessions } from "./attach.ts";
23
+ import type { Config } from "./config.ts";
24
+ import { log } from "./log.ts";
25
+ import type { Profile } from "./profiles.ts";
26
+ import { pgrep } from "./reaper.ts";
27
+ import { socketPath, windowCommand } from "./session.ts";
28
+ import type { SshCa } from "./ssh-ca.ts";
29
+
30
+ const run = promisify(execFile);
31
+
32
+ /** How long the certificate behind a headless login is good for. */
33
+ const CERT_TTL_S = 60;
34
+ /** How long the ssh login that creates the session may take. */
35
+ const LOGIN_TIMEOUT_MS = 30_000;
36
+ /** How long after the login the socket has to start answering. */
37
+ const SOCKET_WAIT_MS = 5_000;
38
+ /** How long `sendText` waits for the program to start reading keys. */
39
+ export const INPUT_WAIT_MS = 60_000;
40
+ /**
41
+ * How long the pty has to stay in raw mode before the program counts as ready.
42
+ *
43
+ * A harness switches the terminal to raw mode early in its startup, and
44
+ * switches on bracketed paste a moment after. Text that arrives in between is
45
+ * read as typed keys. A second of quiet covers the gap with room to spare.
46
+ */
47
+ const SETTLE_MS = 1_000;
48
+ const POLL_MS = 250;
49
+ /** Between the paste and the Enter that submits it, so they are two events. */
50
+ const SUBMIT_DELAY_MS = 300;
51
+
52
+ const PASTE_START = "\x1b[200~";
53
+ const PASTE_END = "\x1b[201~";
54
+
55
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
56
+
57
+ export interface StartDeps {
58
+ config: Config;
59
+ ca: SshCa;
60
+ /** `type blob`, as discovered at startup. Null skips host key checking. */
61
+ hostKey: string | null;
62
+ }
63
+
64
+ /** Whether a window's session is running. */
65
+ export async function isLive(
66
+ config: Config,
67
+ target: AttachTarget,
68
+ ): Promise<boolean> {
69
+ return (await liveSessions(config)).has(target.session);
70
+ }
71
+
72
+ /**
73
+ * Creates a window's session if it is not running, without attaching to it.
74
+ *
75
+ * Returns false when the session was already there. `resume` is asked only when
76
+ * a session is about to be created, because answering it clears the window's
77
+ * flag: see `takeResume`.
78
+ */
79
+ export async function startSession(
80
+ deps: StartDeps,
81
+ target: AttachTarget,
82
+ profile: Profile | undefined,
83
+ resume: () => boolean,
84
+ ): Promise<boolean> {
85
+ const { config, ca, hostKey } = deps;
86
+ if (await isLive(config, target)) return false;
87
+
88
+ const forceCommand = windowCommand(
89
+ target.session,
90
+ target.cwd,
91
+ config,
92
+ profile,
93
+ { resume: resume(), detached: true },
94
+ );
95
+ if (!forceCommand) throw new Error("sessions are off (--no-sessions)");
96
+
97
+ const dir = await mkdtemp(path.join(os.tmpdir(), "vibe-os-start-"));
98
+ try {
99
+ await chmod(dir, 0o700);
100
+ const key = path.join(dir, "id");
101
+ await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", key], {
102
+ timeout: 15_000,
103
+ });
104
+ const cert = await ca.signUserCert({
105
+ publicKey: await readFile(`${key}.pub`, "utf8"),
106
+ principal: config.user,
107
+ identity: `vibe-os/${target.session}/headless`,
108
+ forceCommand,
109
+ ttlSeconds: CERT_TTL_S,
110
+ });
111
+ await writeFile(`${key}-cert.pub`, cert, { mode: 0o600 });
112
+
113
+ const knownHosts = path.join(dir, "known_hosts");
114
+ if (hostKey) {
115
+ await writeFile(
116
+ knownHosts,
117
+ `${knownHostsName(config.sshHost, config.sshPort)} ${hostKey}\n`,
118
+ { mode: 0o600 },
119
+ );
120
+ }
121
+
122
+ await ssh(
123
+ sshArgs(config, key, hostKey ? knownHosts : null),
124
+ LOGIN_TIMEOUT_MS,
125
+ );
126
+ } finally {
127
+ await rm(dir, { recursive: true, force: true });
128
+ }
129
+
130
+ // The login returns once dtach has forked, which can be a moment before the
131
+ // socket is listening.
132
+ const deadline = Date.now() + SOCKET_WAIT_MS;
133
+ while (!(await isLive(config, target))) {
134
+ if (Date.now() > deadline)
135
+ throw new Error(
136
+ `${target.session} did not start; check the server log and vibe-os doctor`,
137
+ );
138
+ await sleep(POLL_MS);
139
+ }
140
+ log.info(`started ${target.session} with no terminal attached`);
141
+ return true;
142
+ }
143
+
144
+ /** How a host appears in known_hosts: bare on port 22, bracketed otherwise. */
145
+ export function knownHostsName(host: string, port: number): string {
146
+ return port === 22 ? host : `[${host}]:${port}`;
147
+ }
148
+
149
+ /**
150
+ * The ssh invocation for a headless login.
151
+ *
152
+ * No command: the certificate forces one. `-T` because there is no terminal to
153
+ * give it, and `BatchMode` so nothing can stop to ask a question.
154
+ */
155
+ export function sshArgs(
156
+ config: Config,
157
+ key: string,
158
+ knownHosts: string | null,
159
+ ): string[] {
160
+ const hostChecking = knownHosts
161
+ ? [
162
+ "-o",
163
+ "StrictHostKeyChecking=yes",
164
+ "-o",
165
+ `UserKnownHostsFile=${knownHosts}`,
166
+ ]
167
+ : ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"];
168
+ return [
169
+ "-T",
170
+ "-i",
171
+ key,
172
+ "-o",
173
+ `CertificateFile=${key}-cert.pub`,
174
+ "-o",
175
+ "IdentitiesOnly=yes",
176
+ "-o",
177
+ "BatchMode=yes",
178
+ "-o",
179
+ "ConnectTimeout=10",
180
+ "-o",
181
+ "LogLevel=ERROR",
182
+ "-o",
183
+ "GlobalKnownHostsFile=/dev/null",
184
+ ...hostChecking,
185
+ "-p",
186
+ String(config.sshPort),
187
+ `${config.user}@${config.sshHost}`,
188
+ ];
189
+ }
190
+
191
+ function ssh(args: string[], timeout: number): Promise<void> {
192
+ return new Promise((resolve, reject) => {
193
+ const child = spawn("ssh", args, {
194
+ stdio: ["ignore", "ignore", "pipe"],
195
+ timeout,
196
+ });
197
+ let stderr = "";
198
+ child.stderr.on("data", (chunk: Buffer) => {
199
+ stderr += chunk.toString();
200
+ });
201
+ child.on("error", (err) =>
202
+ reject(new Error(`could not run ssh: ${err.message}`)),
203
+ );
204
+ child.on("exit", (code, signal) => {
205
+ if (code === 0) return resolve();
206
+ const reason =
207
+ stderr.trim().split("\n").pop() || `exit ${code ?? signal}`;
208
+ reject(new Error(`headless login failed: ${reason}`));
209
+ });
210
+ });
211
+ }
212
+
213
+ // ── typing into a session ────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * The bytes a paste of `text` puts on the wire.
217
+ *
218
+ * What xterm.js does for the browser's Send: line endings become carriage
219
+ * returns, the way a terminal sends Enter, and the text is wrapped in
220
+ * bracketed-paste markers so a multi-line prompt arrives as one block rather
221
+ * than submitting at its first newline. An end marker inside the text would
222
+ * close the paste early and type the rest, so the markers are stripped from it.
223
+ */
224
+ export function pasteBytes(text: string): string {
225
+ const body = text
226
+ .replaceAll(PASTE_START, "")
227
+ .replaceAll(PASTE_END, "")
228
+ .replace(/\r?\n/g, "\r");
229
+ return `${PASTE_START}${body}${PASTE_END}`;
230
+ }
231
+
232
+ /** Writes bytes into a session's pty, as if typed, without attaching. */
233
+ async function push(sock: string, bytes: string): Promise<void> {
234
+ await new Promise<void>((resolve, reject) => {
235
+ const child = spawn("dtach", ["-p", sock], {
236
+ stdio: ["pipe", "ignore", "pipe"],
237
+ timeout: 10_000,
238
+ });
239
+ let stderr = "";
240
+ child.stderr.on("data", (chunk: Buffer) => {
241
+ stderr += chunk.toString();
242
+ });
243
+ child.on("error", reject);
244
+ child.on("exit", (code) =>
245
+ code === 0
246
+ ? resolve()
247
+ : reject(new Error(stderr.trim() || `dtach -p exited ${code}`)),
248
+ );
249
+ child.stdin.end(bytes);
250
+ });
251
+ }
252
+
253
+ /**
254
+ * The terminal device the session's program runs on, like `/dev/pts/3`.
255
+ *
256
+ * The dtach master holds the pty and its one child is the program, which is
257
+ * where `ps` reports the device.
258
+ */
259
+ export async function sessionTty(sock: string): Promise<string | null> {
260
+ for (const master of await pgrep(["-f", `^([^ ]*/)?dtach -n ${sock}`])) {
261
+ for (const child of await pgrep(["-P", String(master)])) {
262
+ const { stdout } = await run("ps", ["-o", "tty=", "-p", String(child)], {
263
+ timeout: 10_000,
264
+ }).catch(() => ({ stdout: "" }));
265
+ const tty = stdout.trim();
266
+ if (tty && tty !== "?" && tty !== "??") return `/dev/${tty}`;
267
+ }
268
+ }
269
+ return null;
270
+ }
271
+
272
+ /**
273
+ * Whether a terminal is in raw mode, which is how a program says it is reading
274
+ * keys itself. Null when it cannot be read.
275
+ */
276
+ export async function isRaw(tty: string): Promise<boolean | null> {
277
+ const flag = process.platform === "darwin" ? "-f" : "-F";
278
+ try {
279
+ const { stdout } = await run("stty", [flag, tty, "-a"], {
280
+ timeout: 5_000,
281
+ });
282
+ return parseRaw(stdout);
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ /** Reads `stty -a` output: `-icanon` means raw, `icanon` means cooked. */
289
+ export function parseRaw(sttyOutput: string): boolean | null {
290
+ const words = sttyOutput.split(/[\s;]+/);
291
+ if (words.includes("-icanon")) return true;
292
+ if (words.includes("icanon")) return false;
293
+ return null;
294
+ }
295
+
296
+ /**
297
+ * Waits until the session's program is reading keys.
298
+ *
299
+ * A session started a moment ago is still a shell running `cd` and `stty`, and
300
+ * then a harness loading. The pty is in canonical mode until the program takes
301
+ * it, and the program's own setup may flush what is waiting, so text sent
302
+ * before then is lost or garbled. Raw mode held for `SETTLE_MS` is the signal.
303
+ */
304
+ export async function waitForInput(
305
+ config: Config,
306
+ session: string,
307
+ timeoutMs = INPUT_WAIT_MS,
308
+ ): Promise<void> {
309
+ const sock = socketPath(config, session);
310
+ const deadline = Date.now() + timeoutMs;
311
+ let rawSince: number | null = null;
312
+ for (;;) {
313
+ const tty = await sessionTty(sock);
314
+ const raw = tty ? await isRaw(tty) : null;
315
+ const now = Date.now();
316
+ if (raw) {
317
+ rawSince ??= now;
318
+ if (now - rawSince >= SETTLE_MS) return;
319
+ } else {
320
+ rawSince = null;
321
+ }
322
+ if (now > deadline)
323
+ throw new Error(
324
+ tty
325
+ ? `${session} is not reading input after ${Math.round(timeoutMs / 1000)}s`
326
+ : `${session} is not running`,
327
+ );
328
+ await sleep(POLL_MS);
329
+ }
330
+ }
331
+
332
+ /** Pastes text into a running session, then presses Enter if asked to. */
333
+ export async function sendText(
334
+ config: Config,
335
+ session: string,
336
+ text: string,
337
+ opts: { submit?: boolean; timeoutMs?: number } = {},
338
+ ): Promise<void> {
339
+ await waitForInput(config, session, opts.timeoutMs);
340
+ await paste(config, session, text);
341
+ if (opts.submit) await submit(config, session);
342
+ }
343
+
344
+ /** Pastes text into a session now, without waiting for it to be ready. */
345
+ export async function paste(
346
+ config: Config,
347
+ session: string,
348
+ text: string,
349
+ ): Promise<void> {
350
+ await push(socketPath(config, session), pasteBytes(text));
351
+ log.info(`pasted ${text.length} characters into ${session}`);
352
+ }
353
+
354
+ /** Presses Enter, a moment after a paste so the two arrive as two events. */
355
+ export async function submit(config: Config, session: string): Promise<void> {
356
+ await sleep(SUBMIT_DELAY_MS);
357
+ await push(socketPath(config, session), "\r");
358
+ }
package/server/index.ts CHANGED
@@ -5,19 +5,21 @@ import { promisify } from "node:util";
5
5
  import type { Server } from "bun";
6
6
  import { Acme } from "./acme.ts";
7
7
  import { createApi } from "./api.ts";
8
+ import { interruptedWindows } from "./attach.ts";
8
9
  import { createGate, hostAllowed, hostName, originAllowed } from "./auth.ts";
9
10
  import {
10
11
  type BridgeData,
11
12
  bridgeConnections,
12
13
  createBridgeHandlers,
13
14
  } from "./bridge.ts";
14
- import type { Config } from "./config.ts";
15
+ import { type Config, savePersisted } from "./config.ts";
15
16
  import { openDb } from "./db.ts";
16
- import { discoverClaude, discoverCursor, discoverHermes } from "./harness.ts";
17
+ import { discover, harnesses } from "./harness.ts";
17
18
  import { json } from "./http.ts";
18
19
  import { color, describeError, log } from "./log.ts";
19
20
  import { ensureWasm, ensureWebRoot } from "./preflight.ts";
20
21
  import { scanProjects } from "./projects.ts";
22
+ import { Screens } from "./screens.ts";
21
23
  import { windowCommand } from "./session.ts";
22
24
  import {
23
25
  discoverHostKey,
@@ -27,6 +29,7 @@ import {
27
29
  } from "./ssh-ca.ts";
28
30
  import { createStaticServer } from "./static.ts";
29
31
  import { WallpaperStore } from "./wallpapers.ts";
32
+ import { markInterrupted } from "./windows.ts";
30
33
 
31
34
  const run = promisify(execFile);
32
35
 
@@ -230,23 +233,40 @@ export async function startServer(config: Config): Promise<RunningServer> {
230
233
  // on the very first page load rather than one poll later.
231
234
  await scanProjects(db, config);
232
235
 
233
- // Warmed here, deliberately not awaited: asking the Claude binary what it
234
- // supports costs a few seconds of its startup, and the answer is only needed
235
- // the first time someone opens the profile editor. Doing it now means it is
236
- // ready by then; doing it there would make the panel hang on first open.
237
- void discoverClaude();
238
- // Hermes is Python and answers three `config get` calls, so it is slower
239
- // still. Same reasoning, more of it.
240
- void discoverHermes();
241
- // Cursor asks its own service for the model list and login state, so this
242
- // one can wait on the network rather than a binary. Same reasoning again.
243
- void discoverCursor();
236
+ // Before listening: the first browser to log in to one of these windows would
237
+ // otherwise quietly start a fresh session in it.
238
+ const interrupted = markInterrupted(
239
+ db,
240
+ await interruptedWindows(db, config).catch(() => []),
241
+ );
242
+ if (interrupted > 0)
243
+ log.info(
244
+ interrupted === 1
245
+ ? "1 window lost its session; the desktop will offer to bring it back"
246
+ : `${interrupted} windows lost their sessions; the desktop will offer to bring them back`,
247
+ );
248
+
249
+ // Warmed here, deliberately not awaited: asking a binary what it supports
250
+ // costs seconds of its startup (Hermes is Python; Cursor goes to the
251
+ // network), and the answer is only needed the first time someone opens the
252
+ // profile editor. Doing it now means it is ready by then.
253
+ for (const adapter of harnesses()) void discover(adapter.spec.id);
244
254
 
245
255
  const gate = createGate(config.token);
246
256
  // Only consulted without a token, so only worth the Tailscale probe then.
247
257
  const hosts = config.token ? new Set<string>() : await knownHosts(config);
248
258
  const serveStatic = createStaticServer(config.webRoot);
249
- const handleApi = createApi({ config, ca, hostKey, wallpapers, db });
259
+ // What every running window shows, so the API can say what is on it.
260
+ const screens = new Screens(config);
261
+ if (config.sessions) screens.start();
262
+ const handleApi = createApi({
263
+ config,
264
+ ca,
265
+ hostKey,
266
+ wallpapers,
267
+ db,
268
+ screens,
269
+ });
250
270
  const bridge = createBridgeHandlers({
251
271
  host: config.sshHost,
252
272
  port: config.sshPort,
@@ -412,6 +432,18 @@ export async function startServer(config: Config): Promise<RunningServer> {
412
432
  log.warn(`listening on ${port} instead of ${config.port}`);
413
433
  }
414
434
 
435
+ // Where the client verbs on this box find the server: the port it actually
436
+ // bound, which may be the fallback, on an address that reaches it.
437
+ const loopback =
438
+ config.host === "0.0.0.0" || config.host === "::"
439
+ ? "127.0.0.1"
440
+ : config.host;
441
+ await savePersisted(config.stateDir, {
442
+ localUrl: `http://${loopback.includes(":") ? `[${loopback}]` : loopback}:${port}`,
443
+ }).catch((err: unknown) => {
444
+ log.warn(`could not record the local url: ${describeError(err)}`);
445
+ });
446
+
415
447
  let httpsServer: Server<BridgeData> | undefined;
416
448
  let stopRenewal: (() => void) | undefined;
417
449
  const extras: string[] = [];
@@ -461,6 +493,7 @@ export async function startServer(config: Config): Promise<RunningServer> {
461
493
  port,
462
494
  close() {
463
495
  stopRenewal?.();
496
+ screens.stop();
464
497
  httpServer.stop(true);
465
498
  httpsServer?.stop(true);
466
499
  log.info(`stopped (${bridgeConnections()} bridge connections were open)`);