@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +928 -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 +843 -0
  41. package/server/config.ts +362 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +966 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +654 -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/ssh-ca.ts +368 -0
  58. package/server/static.ts +147 -0
  59. package/server/wallpapers.ts +248 -0
  60. package/server/windows.ts +237 -0
  61. package/shared/args.ts +91 -0
  62. package/shared/grid.ts +32 -0
  63. package/shared/wire.ts +226 -0
@@ -0,0 +1,53 @@
1
+ // @ts-nocheck — generated; `with { type: 'file' }` imports are not modelled by tsc.
2
+ // Generated by scripts/gen-assets.ts — do not edit.
3
+ //
4
+ // Maps URL paths to files embedded in the compiled binary. Under `bun run`
5
+ // these resolve to real paths on disk, so the same code serves both modes.
6
+
7
+ import a0 from "../dist/web/apple-touch-icon.png" with { type: 'file' };
8
+ import a1 from "../dist/web/assets/index-B77Qk8B5.js" with { type: 'file' };
9
+ import a2 from "../dist/web/assets/index-B77Qk8B5.js.br" with { type: 'file' };
10
+ import a3 from "../dist/web/assets/index-B77Qk8B5.js.gz" with { type: 'file' };
11
+ import a4 from "../dist/web/assets/index-CH5Em-Mo.css" with { type: 'file' };
12
+ import a5 from "../dist/web/assets/index-CH5Em-Mo.css.br" with { type: 'file' };
13
+ import a6 from "../dist/web/assets/index-CH5Em-Mo.css.gz" with { type: 'file' };
14
+ import a7 from "../dist/web/icon-192.png" with { type: 'file' };
15
+ import a8 from "../dist/web/icon-512.png" with { type: 'file' };
16
+ import a9 from "../dist/web/icon-app.svg" with { type: 'file' };
17
+ import a10 from "../dist/web/icon-maskable-512.png" with { type: 'file' };
18
+ import a11 from "../dist/web/icon-maskable.svg" with { type: 'file' };
19
+ import a12 from "../dist/web/icon.svg" with { type: 'file' };
20
+ import a13 from "../dist/web/index.html" with { type: 'file' };
21
+ import a14 from "../dist/web/ssh.wasm" with { type: 'file' };
22
+ import a15 from "../dist/web/ssh.wasm.br" with { type: 'file' };
23
+ import a16 from "../dist/web/ssh.wasm.gz" with { type: 'file' };
24
+ import a17 from "../dist/web/sw.js" with { type: 'file' };
25
+ import a18 from "../dist/web/wasm_exec.js" with { type: 'file' };
26
+ import a19 from "../dist/web/wasm_exec.js.br" with { type: 'file' };
27
+ import a20 from "../dist/web/wasm_exec.js.gz" with { type: 'file' };
28
+
29
+ export const BUILD_ID = "754753f62b6b";
30
+
31
+ export const EMBEDDED: Record<string, string> = {
32
+ "/apple-touch-icon.png": a0,
33
+ "/assets/index-B77Qk8B5.js": a1,
34
+ "/assets/index-B77Qk8B5.js.br": a2,
35
+ "/assets/index-B77Qk8B5.js.gz": a3,
36
+ "/assets/index-CH5Em-Mo.css": a4,
37
+ "/assets/index-CH5Em-Mo.css.br": a5,
38
+ "/assets/index-CH5Em-Mo.css.gz": a6,
39
+ "/icon-192.png": a7,
40
+ "/icon-512.png": a8,
41
+ "/icon-app.svg": a9,
42
+ "/icon-maskable-512.png": a10,
43
+ "/icon-maskable.svg": a11,
44
+ "/icon.svg": a12,
45
+ "/index.html": a13,
46
+ "/ssh.wasm": a14,
47
+ "/ssh.wasm.br": a15,
48
+ "/ssh.wasm.gz": a16,
49
+ "/sw.js": a17,
50
+ "/wasm_exec.js": a18,
51
+ "/wasm_exec.js.br": a19,
52
+ "/wasm_exec.js.gz": a20,
53
+ };
@@ -0,0 +1,524 @@
1
+ // Popping a terminal out to a real terminal.
2
+ //
3
+ // A window is a dtach session, and dtach does not care who attaches to it. The
4
+ // browser reaches it through the WASM/bridge/sshd path; someone on the tailnet
5
+ // can reach the same session with plain `ssh`, and land in exactly the same
6
+ // worktree running exactly the same thing — because both ask session.ts for the
7
+ // command rather than composing one of their own.
8
+ //
9
+ // The only rule that has to survive is the one the pop-out already enforces:
10
+ // **one client per session**. dtach lets several attach at once and sizes the
11
+ // pty to whoever most recently arrived, so a desktop window and a full-screen
12
+ // terminal attached together would fight over the size. The browser pop-out
13
+ // solves that by unmounting the desktop's terminal and gossiping over a
14
+ // BroadcastChannel. An SSH client cannot join that conversation, so the handoff
15
+ // is recorded on the window row instead — durable across a reload, and undone
16
+ // by killing the attached client, which is the server-side equivalent of
17
+ // closing the pop-out window.
18
+ //
19
+ // Killing "the client" is safe because of how session.ts starts things: the
20
+ // process that owns the pty is `dtach -n <socket> …` and every attachment is
21
+ // `dtach -a <socket>`. They differ in argv, so the two are never confused.
22
+
23
+ import { execFile } from "node:child_process";
24
+ import { readdir, rm } from "node:fs/promises";
25
+ import { promisify } from "node:util";
26
+ import { and, desc, eq } from "drizzle-orm";
27
+ import type { AttachInfo, Handoff, SshEndpoint } from "../shared/wire.ts";
28
+ import type { Config } from "./config.ts";
29
+ import {
30
+ type Db,
31
+ ID_PATTERN,
32
+ profiles,
33
+ projects,
34
+ windows,
35
+ workspaces,
36
+ } from "./db.ts";
37
+ import { log } from "./log.ts";
38
+ import { getProfile } from "./profiles.ts";
39
+ import { sessionProcesses, terminate } from "./reaper.ts";
40
+ import { invocation } from "./runtime.ts";
41
+ import { socketPath, windowCommand } from "./session.ts";
42
+
43
+ const run = promisify(execFile);
44
+
45
+ /**
46
+ * How long a handoff nobody ever used is left alone.
47
+ *
48
+ * This only applies before the first client shows up. A handoff is made the
49
+ * moment you pick "SSH session", which is necessarily *before* you have pasted
50
+ * the command anywhere — so for a while the row says the terminal is out there
51
+ * and nobody is attached yet. Reaping on that would take the window back
52
+ * while the command is still on the clipboard, and then the terminal you
53
+ * eventually paste into becomes a second client, which is the one thing the
54
+ * handoff exists to prevent.
55
+ *
56
+ * Generous, because the case it guards is "I got distracted", and because it
57
+ * costs nothing: once a client has attached, `handoffSeen` takes over and its
58
+ * departure is reaped straight away rather than waiting this out.
59
+ */
60
+ const HANDOFF_GRACE_MS = 15 * 60_000;
61
+
62
+ /** A ref is `<workspace>-<index>`; the `vibe-` session prefix is optional. */
63
+ const REF = /^(?:vibe-)?([a-z0-9]+(?:-[a-z0-9]+)*)-(\d+)$/;
64
+
65
+ /**
66
+ * The dtach session name for a window. `REF` above parses this back, so the two
67
+ * have to agree; keeping the prefix in one place is what makes that checkable.
68
+ */
69
+ export const sessionName = (workspace: string, idx: number): string =>
70
+ `vibe-${workspace}-${idx}`;
71
+
72
+ /** The same pair as a person types it, without the prefix. */
73
+ export const sessionRef = (workspace: string, idx: number): string =>
74
+ `${workspace}-${idx}`;
75
+
76
+ export interface AttachTarget {
77
+ windowId: string;
78
+ /** What a person types: `quiet-amber-otter-1`. */
79
+ ref: string;
80
+ session: string;
81
+ cwd: string;
82
+ profileId: string | null;
83
+ workspace: string;
84
+ project: string;
85
+ /** The role the window was opened as, when it has one. */
86
+ role: string | null;
87
+ }
88
+
89
+ // ── resolution ───────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Turns a ref or a window id into everything needed to attach.
93
+ *
94
+ * Both spellings are accepted because they serve different callers: the UI has
95
+ * an opaque window id in hand, and a person at a shell prompt has the label off
96
+ * the title bar. A ref is matched against the workspace name and window index
97
+ * that produce the session name, so what you type is what the socket is called.
98
+ */
99
+ /**
100
+ * The join every attach lookup needs, and the mapping from a row to a target.
101
+ *
102
+ * Both were written out twice — same five tables, same seven fields — so a
103
+ * column added to `AttachTarget` had to be added in two places, and a miss
104
+ * compiled cleanly in whichever copy happened to leave the field optional.
105
+ */
106
+ const targetRows = (db: Db) =>
107
+ db
108
+ .select({
109
+ windowId: windows.id,
110
+ idx: windows.idx,
111
+ profileId: windows.profileId,
112
+ workspace: workspaces.name,
113
+ cwd: workspaces.path,
114
+ lastOpenedAt: workspaces.lastOpenedAt,
115
+ project: projects.name,
116
+ role: profiles.name,
117
+ })
118
+ .from(windows)
119
+ .innerJoin(workspaces, eq(windows.workspaceId, workspaces.id))
120
+ .innerJoin(projects, eq(workspaces.projectId, projects.id))
121
+ .leftJoin(profiles, eq(windows.profileId, profiles.id));
122
+
123
+ type TargetRow = Awaited<
124
+ ReturnType<ReturnType<typeof targetRows>["all"]>
125
+ >[number];
126
+
127
+ const toTarget = (r: TargetRow): AttachTarget => ({
128
+ windowId: r.windowId,
129
+ ref: sessionRef(r.workspace, r.idx),
130
+ session: sessionName(r.workspace, r.idx),
131
+ cwd: r.cwd,
132
+ profileId: r.profileId,
133
+ workspace: r.workspace,
134
+ project: r.project,
135
+ role: r.role,
136
+ });
137
+
138
+ export function resolveTarget(
139
+ db: Db,
140
+ refOrId: string,
141
+ ): AttachTarget | undefined {
142
+ const rows = targetRows(db);
143
+
144
+ /*
145
+ * The id is tried first, and both are tried — the obvious shape, matching on
146
+ * whichever pattern the input looks like, is wrong.
147
+ *
148
+ * Window ids are UUIDs, and the last group of a UUID is twelve hex digits
149
+ * that are sometimes all decimal: `…-a716-446655440001` is a perfectly
150
+ * ordinary id that also parses as the ref "workspace …-a716, window
151
+ * 446655440001". Choosing by shape would send that id down the ref path,
152
+ * find nothing, and give up — an intermittent failure in the API, which
153
+ * passes ids, on roughly one window in two hundred.
154
+ */
155
+ const trimmed = refOrId.trim();
156
+ const match = REF.exec(trimmed);
157
+ const found =
158
+ (ID_PATTERN.test(trimmed)
159
+ ? rows.where(eq(windows.id, trimmed)).all()[0]
160
+ : undefined) ??
161
+ (match
162
+ ? rows
163
+ .where(
164
+ and(
165
+ eq(workspaces.name, match[1]),
166
+ eq(windows.idx, Number(match[2])),
167
+ ),
168
+ )
169
+ // Workspace names are three random words, so a collision across two
170
+ // projects is vanishingly unlikely rather than impossible. Preferring
171
+ // the one you touched most recently beats picking by row order.
172
+ .orderBy(desc(workspaces.lastOpenedAt))
173
+ .all()[0]
174
+ : undefined);
175
+
176
+ return found ? toTarget(found) : undefined;
177
+ }
178
+
179
+ /** Every window that could be attached to, newest workspace first. */
180
+ export function listTargets(db: Db): AttachTarget[] {
181
+ return targetRows(db)
182
+ .orderBy(desc(workspaces.lastOpenedAt), windows.idx)
183
+ .all()
184
+ .map(toTarget);
185
+ }
186
+
187
+ /** The session command for a target — the same one the certificate forces. */
188
+ export function commandFor(
189
+ db: Db,
190
+ config: Config,
191
+ target: AttachTarget,
192
+ ): string | undefined {
193
+ const profile = target.profileId
194
+ ? getProfile(db, target.profileId)
195
+ : undefined;
196
+ return windowCommand(target.session, target.cwd, config, profile);
197
+ }
198
+
199
+ // ── talking to dtach ─────────────────────────────────────────────────────────
200
+
201
+ /** Sessions with something still holding the other end of their socket. */
202
+ export async function liveSessions(config: Config): Promise<Set<string>> {
203
+ try {
204
+ const dir = `${config.stateDir}/sessions`;
205
+ const names = await readdir(dir);
206
+ const live = new Set<string>();
207
+ await Promise.all(
208
+ names
209
+ .filter((n) => n.endsWith(".sock"))
210
+ .map(async (n) => {
211
+ // Writing nothing to the socket succeeds only if something is still
212
+ // holding the other end, which is what separates a live session from
213
+ // a socket a crashed one left behind.
214
+ const ok = await run("dtach", ["-p", `${dir}/${n}`], {
215
+ timeout: 10_000,
216
+ })
217
+ .then(() => true)
218
+ .catch(() => false);
219
+ if (ok) live.add(n.replace(/\.sock$/, ""));
220
+ }),
221
+ );
222
+ return live;
223
+ } catch {
224
+ // No sessions directory yet: nothing has ever been opened.
225
+ return new Set();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Processes currently attached to a session.
231
+ *
232
+ * A client is `dtach -a <socket>`; the process that owns the pty is
233
+ * `dtach -n <socket> …`. They differ in argv precisely so this can tell them
234
+ * apart — matching too loosely here would mean "detach the clients" killing the
235
+ * session along with them.
236
+ */
237
+ export async function clientsOn(
238
+ config: Config,
239
+ session: string,
240
+ ): Promise<string[]> {
241
+ const sock = socketPath(config, session);
242
+ try {
243
+ const { stdout } = await run("pgrep", ["-f", `^dtach -a ${sock}`], {
244
+ timeout: 10_000,
245
+ });
246
+ return stdout
247
+ .split("\n")
248
+ .map((l) => l.trim())
249
+ .filter(Boolean);
250
+ } catch {
251
+ // pgrep exits non-zero when nothing matches.
252
+ return [];
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Kicks every client off a session, leaving the session itself running.
258
+ *
259
+ * This is what "Bring it back" does to a terminal, and the exact counterpart of
260
+ * closing a browser pop-out: the attached dtach exits, the forced command it
261
+ * was running returns, and the ssh session ends. What was running is untouched
262
+ * — the process that owns the pty is a different process and is left alone.
263
+ */
264
+ export async function detachClients(
265
+ config: Config,
266
+ session: string,
267
+ ): Promise<void> {
268
+ const pids = await clientsOn(config, session);
269
+ for (const pid of pids) {
270
+ await run("kill", [pid], { timeout: 10_000 }).catch(() => {
271
+ // Already gone between listing and killing; that is the desired state.
272
+ });
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Ends a session outright: the whole process tree, then the socket.
278
+ *
279
+ * "Whole tree" is the load-bearing part. This used to pkill exactly the dtach
280
+ * master, whose death takes the pty and with it the harness — but not the
281
+ * harness's children, which were reparented to init and ran for a week in
282
+ * worktrees that no longer existed (#38). `sessionProcesses` resolves
283
+ * everything the session ever started, by the pty's kernel session id and by
284
+ * the `VIBE_OS_SOCK` marker stamped on the create command, and `terminate`
285
+ * gives it TERM, a grace, then KILL.
286
+ */
287
+ export async function killSession(
288
+ config: Config,
289
+ session: string,
290
+ ): Promise<void> {
291
+ const sock = socketPath(config, session);
292
+ await terminate(await sessionProcesses(sock));
293
+ await rm(sock, { force: true }).catch(() => {});
294
+ // The lock file the forced command serialises on. Harmless to leave behind —
295
+ // `liveSessions` only looks at `.sock` — but it is ours to clean up.
296
+ await rm(`${sock}.lock`, { force: true }).catch(() => {});
297
+ }
298
+
299
+ /**
300
+ * Ends every dtach session belonging to a workspace.
301
+ *
302
+ * Call this *before* deleting the window rows. The name of a session is derived
303
+ * from the workspace name and the window index, so once the rows are gone there
304
+ * is no way left to name — and therefore no way to reach — the processes still
305
+ * holding those ptys. Three deletion paths in `projects.ts` used to drop the
306
+ * rows on their own and strand a dtach process plus its socket each time.
307
+ */
308
+ export async function killWorkspaceSessions(
309
+ db: Db,
310
+ config: Config,
311
+ workspaceId: string,
312
+ ): Promise<void> {
313
+ const rows = db
314
+ .select({ idx: windows.idx, workspace: workspaces.name })
315
+ .from(windows)
316
+ .innerJoin(workspaces, eq(windows.workspaceId, workspaces.id))
317
+ .where(eq(windows.workspaceId, workspaceId))
318
+ .all();
319
+ await Promise.all(
320
+ rows.map((r) => killSession(config, sessionName(r.workspace, r.idx))),
321
+ );
322
+ }
323
+
324
+ // ── where to point ssh ───────────────────────────────────────────────────────
325
+
326
+ /**
327
+ * The endpoint to print in an ssh command.
328
+ *
329
+ * `config.sshHost` is the wrong answer and the tempting one: it is where the
330
+ * *bridge* dials, which is `127.0.0.1` in every recommended deployment. So is
331
+ * `os.hostname()`, which on a VPS is something like `ubuntu-2gb-fsn1` and
332
+ * resolves nowhere.
333
+ *
334
+ * The right answer is the host the browser is already talking to. If you
335
+ * reached the desktop at `vibe-os.your-tailnet.ts.net`, ssh to that name works —
336
+ * it is the same machine, and on a tailnet it is the same name sshd answers on.
337
+ * `--ssh-advertise` overrides it for the case where the two genuinely differ,
338
+ * such as a reverse proxy in front of the web port.
339
+ */
340
+ /** The host a person would type, from the same forwarded headers. */
341
+ export function publicHost(req: Request, url: URL): string {
342
+ const first = (name: string) => req.headers.get(name)?.split(",")[0].trim();
343
+ return first("x-forwarded-host") || first("host") || url.host;
344
+ }
345
+
346
+ export function sshEndpoint(config: Config, requestHost: string): SshEndpoint {
347
+ const advertised = config.sshAdvertise?.trim();
348
+ if (advertised) {
349
+ // Splitting on every colon breaks IPv6: `::1` becomes host "" port "".
350
+ // A bracketed literal keeps its address, and a trailing `:port` is only a
351
+ // port when it is one — `--ssh-advertise host:abc` used to render
352
+ // `ssh -t -p NaN`.
353
+ const bracketed = /^\[(.+)\](?::(\d+))?$/.exec(advertised);
354
+ const plain = /^([^:]+)(?::(\d+))?$/.exec(advertised);
355
+ const m = bracketed ?? plain;
356
+ const host = m ? m[1] : advertised;
357
+ const port = m?.[2] ? Number(m[2]) : 22;
358
+ return { user: config.user, host, port };
359
+ }
360
+ // Strips the web port, which is rarely 22 and never relevant to ssh.
361
+ return {
362
+ user: config.user,
363
+ host: requestHost.replace(/:\d+$/, ""),
364
+ port: 22,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * How to spell "run vibe-os" on the far side of an ssh command.
370
+ *
371
+ * `ssh host <command>` runs it through `$SHELL -c`, which is neither a login
372
+ * shell nor an interactive one, so the PATH is sshd's default rather than the
373
+ * one `.profile` builds. A binary in `/usr/local/bin` — where the install
374
+ * instructions put it — is on that PATH and can be named bare. Anywhere else,
375
+ * and `~/.local/bin` in particular, has to be spelled out in full or the
376
+ * command fails with "not found" for someone who can run it fine by hand.
377
+ */
378
+ /** `ssh -t [-p n] user@host …`, with the tail supplied by the caller. */
379
+ function sshPrefix(endpoint: SshEndpoint): string {
380
+ const port = endpoint.port === 22 ? "" : ` -p ${endpoint.port}`;
381
+ return `ssh -t${port} ${endpoint.user}@${endpoint.host}`;
382
+ }
383
+
384
+ /** Everything the UI needs to offer an SSH handoff. */
385
+ export function attachInfo(
386
+ db: Db,
387
+ config: Config,
388
+ refOrId: string,
389
+ requestHost: string,
390
+ ): AttachInfo | undefined {
391
+ const target = resolveTarget(db, refOrId);
392
+ if (!target) return undefined;
393
+ // Not used in the command, but a window with no way to launch anything is not
394
+ // one worth offering a handoff for.
395
+ if (!commandFor(db, config, target)) return undefined;
396
+
397
+ const endpoint = sshEndpoint(config, requestHost);
398
+
399
+ return {
400
+ ref: target.ref,
401
+ session: target.session,
402
+ cwd: target.cwd,
403
+ workspace: target.workspace,
404
+ project: target.project,
405
+ role: target.role,
406
+ endpoint,
407
+ command: `${sshPrefix(endpoint)} ${invocation()} attach ${target.ref}`,
408
+ };
409
+ }
410
+
411
+ // ── the handoff ──────────────────────────────────────────────────────────────
412
+
413
+ /**
414
+ * Records that a window's terminal has been handed to an ssh client — or taken
415
+ * back, which also kicks whoever is attached.
416
+ *
417
+ * Persisted rather than kept in the desktop's memory because the whole point is
418
+ * that you close the laptop and the terminal keeps running. The browser
419
+ * pop-out can gossip over a BroadcastChannel because both ends are documents on
420
+ * one origin; an ssh client has no such channel, so the row is the record.
421
+ */
422
+ export async function setHandoff(
423
+ db: Db,
424
+ config: Config,
425
+ windowId: string,
426
+ mode: Handoff,
427
+ ): Promise<boolean> {
428
+ const target = resolveTarget(db, windowId);
429
+ if (!target) return false;
430
+
431
+ if (config.sessions) {
432
+ /*
433
+ * Both directions detach every client, and the reason differs.
434
+ *
435
+ * Taking it back is the obvious one: the terminal has to let go, and this
436
+ * is the server-side equivalent of closing a browser pop-out. It happens
437
+ * before the flag is cleared, so a failure leaves the row saying the
438
+ * terminal is still out there, which is the honest state.
439
+ *
440
+ * Handing it out is the subtle one. The desktop that asked has already
441
+ * unmounted its terminal — but any *other* desktop showing this workspace
442
+ * has not heard yet, and until it polls it is still a client. The
443
+ * terminal would then arrive as a second one and the session would shrink
444
+ * to whichever is smaller. Broadcasting to sibling tabs closes that for one
445
+ * browser; only the server can close it for another machine, and it closes
446
+ * it here to zero rather than to a poll interval. Nothing is lost: a client
447
+ * detaching is not a session ending, and the browsers reconnect or show the
448
+ * handoff as soon as they catch up.
449
+ */
450
+ await detachClients(config, target.session);
451
+ log.info(
452
+ mode === "ssh"
453
+ ? `handed ${target.session} to a terminal`
454
+ : `reclaimed ${target.session} from its terminal`,
455
+ );
456
+ }
457
+
458
+ db.update(windows)
459
+ .set({ handoff: mode, handoffAt: mode ? Date.now() : null, handoffSeen: 0 })
460
+ .where(eq(windows.id, windowId))
461
+ .run();
462
+ return true;
463
+ }
464
+
465
+ /**
466
+ * Clears handoffs whose terminal has gone away.
467
+ *
468
+ * Without this a window stays a placeholder forever after you close the ssh
469
+ * session — the desktop would be waiting for a client that will never come
470
+ * back, and the only way out is a button you would have to know to press. With
471
+ * it, closing the terminal hands the window back on the next poll.
472
+ *
473
+ * The two cases are deliberately not treated the same, because "nobody has
474
+ * attached yet" and "the terminal that was attached has gone" look identical
475
+ * in `list-clients` and mean opposite things. Once a client has been seen the
476
+ * session is known to have been claimed, so its disappearance is real and gets
477
+ * reaped at once; before that, an empty list is just someone still finding
478
+ * their terminal window, and only the grace period settles it.
479
+ *
480
+ * Runs on the window list, which the desktop polls while anything is out, and
481
+ * only ever does session work for windows that are actually handed off.
482
+ */
483
+ export async function reapStaleHandoffs(db: Db, config: Config): Promise<void> {
484
+ if (!config.sessions) return;
485
+ const outstanding = db
486
+ .select()
487
+ .from(windows)
488
+ .where(eq(windows.handoff, "ssh"))
489
+ .all();
490
+ if (outstanding.length === 0) return;
491
+
492
+ const now = Date.now();
493
+ for (const row of outstanding) {
494
+ const target = resolveTarget(db, row.id);
495
+ if (!target) continue;
496
+
497
+ if ((await clientsOn(config, target.session)).length > 0) {
498
+ // First sighting. From here on, an empty client list means it left.
499
+ if (!row.handoffSeen) {
500
+ db.update(windows)
501
+ .set({ handoffSeen: 1 })
502
+ .where(eq(windows.id, row.id))
503
+ .run();
504
+ log.debug(`${target.session} picked up by a terminal`);
505
+ }
506
+ continue;
507
+ }
508
+
509
+ if (!row.handoffSeen && now - (row.handoffAt ?? 0) < HANDOFF_GRACE_MS)
510
+ continue;
511
+
512
+ db.update(windows)
513
+ .set({ handoff: null, handoffAt: null, handoffSeen: 0 })
514
+ .where(eq(windows.id, row.id))
515
+ .run();
516
+ log.info(
517
+ row.handoffSeen
518
+ ? `${target.session} was left by its terminal — handing it back to the desktop`
519
+ : `${target.session} was never picked up — handing it back to the desktop`,
520
+ );
521
+ }
522
+ }
523
+
524
+ export type { AttachInfo, Handoff, SshEndpoint };