@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.1

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +988 -2
  3. package/THIRD_PARTY_NOTICES.md +2467 -0
  4. package/bin/vibe-os.mjs +13 -0
  5. package/dist/web/apple-touch-icon.png +0 -0
  6. package/dist/web/assets/index-B77Qk8B5.js +35 -0
  7. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  8. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  9. package/dist/web/assets/index-CH5Em-Mo.css +1 -0
  10. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  11. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-app.svg +19 -0
  15. package/dist/web/icon-maskable-512.png +0 -0
  16. package/dist/web/icon-maskable.svg +13 -0
  17. package/dist/web/icon.svg +15 -0
  18. package/dist/web/index.html +36 -0
  19. package/dist/web/ssh.wasm +0 -0
  20. package/dist/web/ssh.wasm.br +0 -0
  21. package/dist/web/ssh.wasm.gz +0 -0
  22. package/dist/web/sw.js +29 -0
  23. package/dist/web/wasm_exec.js +575 -0
  24. package/dist/web/wasm_exec.js.br +0 -0
  25. package/dist/web/wasm_exec.js.gz +0 -0
  26. package/package.json +82 -4
  27. package/scripts/compile.ts +62 -0
  28. package/scripts/compress.mjs +54 -0
  29. package/scripts/fetch-wasm.mjs +242 -0
  30. package/scripts/gen-assets.ts +76 -0
  31. package/scripts/lib/walk.ts +18 -0
  32. package/scripts/notices.ts +189 -0
  33. package/server/acme.ts +182 -0
  34. package/server/api.ts +515 -0
  35. package/server/assets.generated.ts +53 -0
  36. package/server/attach.ts +524 -0
  37. package/server/auth.ts +194 -0
  38. package/server/bridge.ts +145 -0
  39. package/server/browser.ts +519 -0
  40. package/server/cli.ts +857 -0
  41. package/server/config.ts +385 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +977 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +655 -0
  46. package/server/http.ts +53 -0
  47. package/server/index.ts +469 -0
  48. package/server/log.ts +40 -0
  49. package/server/main.ts +7 -0
  50. package/server/mcp.ts +291 -0
  51. package/server/preflight.ts +87 -0
  52. package/server/profiles.ts +238 -0
  53. package/server/projects.ts +721 -0
  54. package/server/reaper.ts +303 -0
  55. package/server/runtime.ts +64 -0
  56. package/server/session.ts +330 -0
  57. package/server/setup.ts +1151 -0
  58. package/server/ssh-ca.ts +368 -0
  59. package/server/static.ts +147 -0
  60. package/server/wallpapers.ts +248 -0
  61. package/server/windows.ts +237 -0
  62. package/shared/args.ts +91 -0
  63. package/shared/grid.ts +32 -0
  64. package/shared/wire.ts +226 -0
@@ -0,0 +1,330 @@
1
+ // What a window actually runs when something logs in as it.
2
+ //
3
+ // This is the single source of truth for the tmux invocation behind a window,
4
+ // and it has two callers that must never disagree: the certificate signer,
5
+ // which bakes it into a `force-command` for the browser, and `vibe-os attach`,
6
+ // which runs it directly for someone arriving over SSH. Both resolve the same
7
+ // window id to the same command, so a terminal and a browser tab land in the
8
+ // same session, in the same worktree, with the same options set.
9
+ //
10
+ // It lives in its own module rather than in api.ts because attach.ts needs it
11
+ // too, and api.ts needs attach.ts — importing in a circle to share three pure
12
+ // functions is not a trade worth making.
13
+
14
+ import type { Config } from "./config.ts";
15
+ import type { Profile } from "./profiles.ts";
16
+ import { SOCK_VAR } from "./reaper.ts";
17
+
18
+ /** Single-quote for the login shell that runs a certificate's force-command. */
19
+ export function shellQuote(value: string): string {
20
+ return `'${value.replaceAll("'", `'\\''`)}'`;
21
+ }
22
+
23
+ /**
24
+ * Cursor's name for a directory, as it spells it under `~/.cursor/projects`.
25
+ *
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`.
31
+ */
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("; ");
81
+ }
82
+
83
+ /**
84
+ * The command a profile window starts its pane with.
85
+ *
86
+ * **Quitting the harness ends the window.** tmux ends a session when its last
87
+ * pane exits, and nothing is appended here to keep the pane alive, so leaving
88
+ * Claude closes the window everywhere at once — the browser tile and any
89
+ * terminal attached to the same session. That is deliberate: a window opened as
90
+ * a role exists to run that role, and being dropped into a shell in the
91
+ * worktree instead means every finished conversation leaves a window behind to
92
+ * be tidied up by hand.
93
+ *
94
+ * It cuts both ways and the other edge is worth knowing. A harness that fails
95
+ * to launch at all — the wrong command, or a PATH that does not reach it — also
96
+ * exits immediately, so the window closes before anyone can read the error.
97
+ * `vibe-os doctor` and the server log are where that shows up.
98
+ *
99
+ * Quoting happens at two levels and both are handled: every token is quoted
100
+ * individually so a flag can contain spaces and cannot contain a second
101
+ * command, and the whole string is quoted again by the caller.
102
+ */
103
+ export function harnessCommand(
104
+ profile: Profile,
105
+ /** The worktree it runs in and the state dir, for the Cursor MCP link. */
106
+ where: { cwd: string; stateDir: string },
107
+ ): 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(" ");
132
+
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
+ : "";
138
+
139
+ /*
140
+ * The PATH a forced command gets is not the PATH you get when you log in.
141
+ *
142
+ * sshd runs a certificate's force-command as `$SHELL -c`, which for bash is
143
+ * neither a login shell nor an interactive one — so `.profile` never runs,
144
+ * and `~/.local/bin` is missing. That is exactly where Claude's native
145
+ * installer puts its binary, and where most `curl | sh` installers put
146
+ * theirs, so the harness fails with "command not found" while typing the
147
+ * same name by hand in the same window works perfectly.
148
+ *
149
+ * Restored here rather than by asking for a login shell: `$SHELL -lc` would
150
+ * mean another layer of quoting around an already twice-quoted string, and
151
+ * a `.profile` is entitled to do surprising things like change directory,
152
+ * which would undo the `-c` this window was started with.
153
+ */
154
+ return `export PATH="$HOME/.local/bin:$PATH"; ${setup}exec ${argv}`;
155
+ }
156
+
157
+ /** Where a window's dtach socket lives. */
158
+ export function socketPath(config: Config, session: string): string {
159
+ return `${config.stateDir}/sessions/${session}.sock`;
160
+ }
161
+
162
+ /**
163
+ * Builds the command a window runs on login.
164
+ *
165
+ * The working directory is the point of the projects feature: the session
166
+ * starts in the worktree of the workspace the window belongs to. That path is
167
+ * looked up server-side from the window id — the browser never sends a
168
+ * directory, so there is nothing to smuggle a path through. The same is true of
169
+ * the profile: the window row says which one it was opened as, so the browser
170
+ * never sends a command either.
171
+ *
172
+ * ── Why dtach and not tmux ───────────────────────────────────────────────────
173
+ * A window needs exactly two things from a session manager: survive a reload,
174
+ * and let a real terminal take over. tmux does both, and brings a full terminal
175
+ * emulator with it — a second screen model that it keeps in sync with the
176
+ * client by sending only the cells it believes have changed. Measured on one
177
+ * window: 48,998 cursor hops and 2,124 erase-character sequences in a single
178
+ * capture, in place of simply forwarding the program's output.
179
+ *
180
+ * That optimisation is the bug. If the client's grid and tmux's model ever
181
+ * disagree about one cell, tmux will not send that cell again — it is certain
182
+ * the client already has it right — so a momentary disagreement becomes
183
+ * permanent, and text arrives with fragments of older frames wedged into it.
184
+ * The pane itself stays perfect, which is why `capture-pane` always read
185
+ * correctly while the browser did not, and why resizing a window cleaned it up:
186
+ * a resize is the one thing that makes tmux throw the model away and repaint.
187
+ *
188
+ * dtach keeps no model. It holds the pty and moves bytes, so the program talks
189
+ * to the browser's terminal directly and there is nothing to fall out of sync.
190
+ * The scrollback that tmux would have kept was never worth anything here
191
+ * anyway: a full-screen program like Claude runs on the alternate screen, where
192
+ * nothing scrolls into history — measured `history_size=0` on every window.
193
+ *
194
+ * ── The shape of the command ─────────────────────────────────────────────────
195
+ * `-n` creates the session detached and `-a` attaches to it, deliberately kept
196
+ * as two invocations rather than one `-A`. It costs a liveness probe and buys
197
+ * the ability to tell a client from the process that owns the pty: they differ
198
+ * in argv, so "detach whoever is attached" is something the server can do
199
+ * without risking the session itself.
200
+ *
201
+ * `-E` disables the detach character, because every key dtach reserves is a key
202
+ * the program underneath cannot have — the same argument that ruled out
203
+ * tmux's prefix. `-z` lets the suspend key through for the same reason. `-r
204
+ * winch` asks the program to repaint on attach, which is how a reattached
205
+ * window fills itself in without a screen model to replay.
206
+ */
207
+ export function windowCommand(
208
+ session: string,
209
+ cwd: string,
210
+ config: Config,
211
+ profile?: Profile | null,
212
+ /** `forDisplay` drops the locking wrapper, leaving the readable essentials. */
213
+ opts?: { forDisplay?: boolean },
214
+ ): string | undefined {
215
+ if (!config.sessions) return undefined;
216
+
217
+ const sock = socketPath(config, session);
218
+ const harness = profile
219
+ ? harnessCommand(profile, { cwd, stateDir: config.stateDir })
220
+ : undefined;
221
+ // Falls back to the login shell, so a window with no profile is still a
222
+ // session that survives a reload rather than a bare ssh command.
223
+ /*
224
+ * Say what this terminal can actually do.
225
+ *
226
+ * sshterm asks sshd for a pty as `xterm`, and terminfo's `xterm` promises
227
+ * eight colours. Programs believe it: Claude picks a duller palette, and
228
+ * anything consulting `tput colors` gets 8. The browser's terminal is not that
229
+ * — xterm.js has done 256 colours and 24-bit for years — so the honest value
230
+ * is the wider one, and `COLORTERM` is how the true-colour half of that is
231
+ * conventionally advertised.
232
+ *
233
+ * Set on the session rather than the attachment because a program reads this
234
+ * once, at startup. Reattaching later inherits nothing, which is the same
235
+ * reason the working directory belongs here too.
236
+ */
237
+ const env = "export TERM=xterm-256color COLORTERM=truecolor;";
238
+
239
+ /*
240
+ * Give the pty a baud rate Linux will accept back.
241
+ *
242
+ * SSH carries terminal modes in its pty-req, `TTY_OP_ISPEED` and
243
+ * `TTY_OP_OSPEED` among them, and sshd writes whatever it is sent straight
244
+ * into the pty. The browser's client is Go, and the Go SSH example every
245
+ * project copies from hardcodes 14400 — a rate Linux has no encoding for, and
246
+ * one `stty 14400` will not even set by hand. sshd sets it anyway, because it
247
+ * writes the raw termios fields rather than going through cfsetspeed.
248
+ *
249
+ * A pty then exists whose speed cannot be written back. Reading it works, so
250
+ * nothing looks wrong until a program does the ordinary raw-mode dance —
251
+ * tcgetattr, flip some flags, tcsetattr — at which point the unchanged speed
252
+ * field comes back as EINVAL. Hermes dies exactly there, inside
253
+ * prompt_toolkit, before it paints anything. Claude survives the same pty
254
+ * because Node's setRawMode swallows the error, which is why this went
255
+ * unnoticed until a second harness arrived.
256
+ *
257
+ * Set on the session rather than the attachment: the harness reads it once at
258
+ * startup, and this is the pty the harness actually runs on. dtach copies
259
+ * termios from the sshd pty when it creates its own, so the bad value is
260
+ * inherited here and has to be corrected here.
261
+ *
262
+ * 38400 is the conventional pty speed and what every other path already
263
+ * reports. Failure is ignored on purpose — a box where `stty` is missing
264
+ * should still open a window.
265
+ */
266
+ const baud = "stty 38400 2>/dev/null;";
267
+ const inner = `cd ${shellQuote(cwd)} && ${baud} ${env} ${harness ?? 'exec "$SHELL"'}`;
268
+
269
+ // `dtach -p` writes to a live socket and fails on a dead one, which makes it
270
+ // a liveness probe. A socket left behind by a crashed session would otherwise
271
+ // make every later attach fail with no way back except deleting it by hand.
272
+ const probe = `dtach -p ${shellQuote(sock)} < /dev/null > /dev/null 2>&1`;
273
+ /*
274
+ * `VIBE_OS_SOCK` is the session's name tag, and inheritance is the feature.
275
+ *
276
+ * Set on the create and nowhere else, it rides the environment into dtach,
277
+ * into the shell on the pty, into the harness, and into everything the
278
+ * harness ever starts — across fork, exec, daemonisation, even a second
279
+ * setsid. A process group cannot make that promise: dtach creates the pty
280
+ * with forkpty, which makes its child a session leader, so no pgid of ours
281
+ * ever covers the grandchildren. The environment does.
282
+ *
283
+ * That is what lets killSession end a session *tree* instead of one pid, and
284
+ * what lets the reaper name the owner of a process found running in a
285
+ * deleted worktree a week later. reaper.ts is the only reader.
286
+ */
287
+ const create = `${SOCK_VAR}=${shellQuote(sock)} dtach -n ${shellQuote(sock)} -E -z /bin/sh -c ${shellQuote(inner)}`;
288
+ const attach = `exec dtach -a ${shellQuote(sock)} -E -z -r winch`;
289
+
290
+ /*
291
+ * Probe-then-create has to be one critical section.
292
+ *
293
+ * This whole string is the certificate's force-command, so it runs once per
294
+ * login — and two logins for the same window are ordinary: two tabs, a reload
295
+ * racing a reconnect, two machines. Unserialised, both probe a missing socket
296
+ * and both take the create branch, at which point the second one's `rm -f`
297
+ * unlinks the socket the first is already living on. That session survives
298
+ * with no name: unreachable, still holding a pty, invisible to `clientsOn`
299
+ * and `killSession`, which can only ever find the socket that replaced it.
300
+ *
301
+ * `-o` is not optional, and leaving it off is a trap worth naming. flock
302
+ * holds the lock on an open file descriptor, and a descriptor is inherited
303
+ * across fork and exec — so `dtach -n`, which daemonises and outlives the
304
+ * command, kept that descriptor and held the lock for the entire life of the
305
+ * session. Every later login then blocked on flock forever: the terminal got
306
+ * as far as verifying the host key and hung there, because sshd was still
307
+ * waiting for the forced command to produce anything. `-o` closes the
308
+ * descriptor in the child before the command runs, so the lock lasts exactly
309
+ * as long as the critical section and nothing spawned inside it inherits it.
310
+ *
311
+ * flock is in util-linux and present on every distro this runs on, but the
312
+ * fallback keeps a box without it working exactly as it did before rather
313
+ * than failing to open a terminal at all.
314
+ */
315
+ const critical = `${probe} || { rm -f ${shellQuote(sock)}; ${create}; }`;
316
+
317
+ // 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("; ");
322
+
323
+ return [
324
+ `mkdir -p ${shellQuote(`${config.stateDir}/sessions`)}`,
325
+ `if command -v flock > /dev/null 2>&1; then ` +
326
+ `flock -o ${shellQuote(`${sock}.lock`)} -c ${shellQuote(critical)}; ` +
327
+ `else { ${critical}; }; fi`,
328
+ attach,
329
+ ].join("; ");
330
+ }