@tsconfigdotjson/vibe-os 0.2.0 → 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
@@ -27,8 +27,8 @@
27
27
  <meta name="apple-mobile-web-app-capable" content="yes" />
28
28
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
29
29
  <meta name="apple-mobile-web-app-title" content="vibe-os" />
30
- <script type="module" crossorigin src="/assets/index-B77Qk8B5.js"></script>
31
- <link rel="stylesheet" crossorigin href="/assets/index-CH5Em-Mo.css">
30
+ <script type="module" crossorigin src="/assets/index-DCB2nO0u.js"></script>
31
+ <link rel="stylesheet" crossorigin href="/assets/index-82MhMOB4.css">
32
32
  </head>
33
33
  <body>
34
34
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsconfigdotjson/vibe-os",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A coding desktop in the browser, on a box you own. Git worktrees per piece of work, terminals that survive a reload, and SSH that runs in the tab.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,7 @@
50
50
  "start": "bun bin/vibe-os.mjs start"
51
51
  },
52
52
  "dependencies": {
53
+ "@xterm/headless": "6.0.0",
53
54
  "acme-client": "^5.4.0",
54
55
  "drizzle-orm": "^0.45.2"
55
56
  },
@@ -81,7 +81,9 @@ function readPackage(name: string): Record<string, unknown> | null {
81
81
  function repoOf(pkg: Record<string, unknown>): string | null {
82
82
  const repo = pkg.repository;
83
83
  const url = typeof repo === "string" ? repo : (repo as { url?: string })?.url;
84
- const match = url && /github\.com[/:]([^/]+\/[^/.#]+)/.exec(url);
84
+ // A repository name can have dots in it, like `xtermjs/xterm.js`.
85
+ const match =
86
+ url && /github\.com[/:]([^/]+\/[^/#]+?)(?:\.git)?(?:[/#]|$)/.exec(url);
85
87
  return match ? match[1] : null;
86
88
  }
87
89
 
package/server/api.ts CHANGED
@@ -1,17 +1,35 @@
1
1
  import os from "node:os";
2
2
  import pkg from "../package.json" with { type: "json" };
3
- import type { ClientConfig } from "../shared/wire.ts";
3
+ import { fillBlanks } from "../shared/blanks.ts";
4
+ import type {
5
+ ClientConfig,
6
+ MemoryReport,
7
+ SendInput,
8
+ SendResult,
9
+ WindowSummary,
10
+ WorkspaceSummary,
11
+ } from "../shared/wire.ts";
4
12
  import {
13
+ type AttachTarget,
5
14
  attachInfo,
6
15
  killSession,
16
+ listTargets,
17
+ liveSessions,
7
18
  publicHost,
8
19
  reapStaleHandoffs,
20
+ resolveTarget,
9
21
  setHandoff,
10
22
  } from "./attach.ts";
11
23
  import type { Config } from "./config.ts";
12
24
  import type { Db } from "./db.ts";
13
25
  import { ID_PATTERN } from "./db.ts";
14
- import { discoverClaude, discoverCursor, discoverHermes } from "./harness.ts";
26
+ import { discover, specs } from "./harness.ts";
27
+ import {
28
+ paste,
29
+ submit as pressEnter,
30
+ startSession,
31
+ waitForInput,
32
+ } from "./headless.ts";
15
33
  import {
16
34
  badId,
17
35
  IMMUTABLE_CACHE_CONTROL,
@@ -26,12 +44,19 @@ import {
26
44
  type McpScan,
27
45
  syncMcpMirrors,
28
46
  } from "./mcp.ts";
47
+ import {
48
+ capacityWarning,
49
+ effectiveLimits,
50
+ readBoxMemory,
51
+ sessionMemory,
52
+ } from "./memory.ts";
29
53
  import {
30
54
  createProfile,
31
55
  deleteProfile,
32
56
  getProfile,
33
57
  listProfiles,
34
58
  PALETTE,
59
+ type Profile,
35
60
  updateProfile,
36
61
  } from "./profiles.ts";
37
62
  import {
@@ -44,7 +69,8 @@ import {
44
69
  scanProjects,
45
70
  touchWorkspace,
46
71
  } from "./projects.ts";
47
- import { windowCommand } from "./session.ts";
72
+ import { Screens } from "./screens.ts";
73
+ import { socketPath, windowCommand } from "./session.ts";
48
74
  import type { SshCa } from "./ssh-ca.ts";
49
75
  import { fingerprint, MAX_PUBKEY_BYTES } from "./ssh-ca.ts";
50
76
  import {
@@ -57,7 +83,9 @@ import {
57
83
  deleteWindow,
58
84
  getWindow,
59
85
  listWindows,
86
+ restoreWindow,
60
87
  sessionNameFor,
88
+ takeResume,
61
89
  updateWindow,
62
90
  } from "./windows.ts";
63
91
 
@@ -103,16 +131,167 @@ async function refreshMcpMirrors(db: Db, stateDir: string): Promise<void> {
103
131
  );
104
132
  }
105
133
 
134
+ /** Ceiling on text sent to a window, matching a profile prompt's. */
135
+ const MAX_SEND_CHARS = 16_000;
136
+
137
+ /** Longest label a window can be tagged with. */
138
+ const MAX_LABEL_CHARS = 200;
139
+ /**
140
+ * How long a send waits for a window to stop drawing before it pastes anyway.
141
+ *
142
+ * A harness that has just started draws its startup and goes quiet in a few
143
+ * seconds. One that is working never goes quiet, and a paste into a working
144
+ * agent is queued rather than lost, so after this it goes in regardless.
145
+ */
146
+ const SETTLE_WAIT_MS = 20_000;
147
+ /** How long after a paste the window has to draw something. */
148
+ const ECHO_WAIT_MS = 3_000;
149
+ /** Lines of output when the caller does not say, and the most it can ask for. */
150
+ const DEFAULT_OUTPUT_LINES = 50;
151
+ const MAX_OUTPUT_LINES = 2_000;
152
+ /** How long a first read waits for a window to redraw itself. */
153
+ const FIRST_PAINT_WAIT_MS = 1_000;
154
+
155
+ /**
156
+ * What to paste for a send: the text given, or the profile prompt with its
157
+ * blanks filled. Throws with a message for the caller when neither works.
158
+ */
159
+ function textToSend(
160
+ body: SendInput,
161
+ profile: { prompt: string } | undefined,
162
+ ): string {
163
+ if (body.text !== undefined) {
164
+ if (typeof body.text !== "string" || body.text === "")
165
+ throw new Error("text must be a non-empty string");
166
+ if (body.blanks !== undefined)
167
+ throw new Error(
168
+ "blanks fill the profile prompt; leave out text to use it",
169
+ );
170
+ if (body.text.length > MAX_SEND_CHARS)
171
+ throw new Error(`text must be under ${MAX_SEND_CHARS} characters`);
172
+ return body.text;
173
+ }
174
+ if (!profile || profile.prompt.trim() === "")
175
+ throw new Error("this window has no profile prompt; send text instead");
176
+
177
+ const blanks = body.blanks ?? {};
178
+ if (
179
+ typeof blanks !== "object" ||
180
+ blanks === null ||
181
+ Array.isArray(blanks) ||
182
+ Object.values(blanks).some((v) => typeof v !== "string")
183
+ )
184
+ throw new Error("blanks must be an object of strings");
185
+ const filled = fillBlanks(profile.prompt, blanks);
186
+ if (filled.unknown.length > 0)
187
+ throw new Error(
188
+ `the prompt has no blank called ${filled.unknown.join(", ")}`,
189
+ );
190
+ if (filled.missing.length > 0)
191
+ throw new Error(`fill every blank: ${filled.missing.join(", ")}`);
192
+ return filled.text;
193
+ }
194
+
106
195
  export interface ApiDeps {
107
196
  config: Config;
108
197
  ca: SshCa;
109
198
  hostKey: string | null;
110
199
  wallpapers: WallpaperStore;
111
200
  db: Db;
201
+ /** Every running window's screen. One that is never started if absent. */
202
+ screens?: Screens;
112
203
  }
113
204
 
114
205
  export function createApi(deps: ApiDeps) {
115
206
  const { ca, config, db, hostKey, wallpapers } = deps;
207
+ const screens = deps.screens ?? new Screens(config);
208
+
209
+ const summarise = (t: AttachTarget, live: Set<string>): WindowSummary => {
210
+ const running = live.has(t.session);
211
+ const screen = running ? screens.get(t.session) : undefined;
212
+ return {
213
+ id: t.windowId,
214
+ ref: t.ref,
215
+ session: t.session,
216
+ workspace: t.workspace,
217
+ project: t.project,
218
+ role: t.role,
219
+ label: t.label,
220
+ live: running,
221
+ state: running ? (screen?.activity() ?? "idle") : null,
222
+ lastOutputAt: screen?.lastOutputAt ?? null,
223
+ };
224
+ };
225
+
226
+ /** Starts a window's session if it is not running, and watches its screen. */
227
+ async function start(
228
+ target: AttachTarget,
229
+ profile: Profile | undefined,
230
+ ): Promise<boolean> {
231
+ if (profile?.harness === "claude")
232
+ await refreshMcpMirrors(db, config.stateDir).catch((err: unknown) => {
233
+ log.warn(`could not refresh mcp configs: ${describeError(err)}`);
234
+ });
235
+ const started = await startSession(
236
+ { config, ca, hostKey },
237
+ target,
238
+ profile,
239
+ () => takeResume(db, target.windowId),
240
+ );
241
+ await screens.watch(target.session, { fresh: started });
242
+ return started;
243
+ }
244
+
245
+ /**
246
+ * Starts a window if need be, waits until it is ready for text, and pastes.
247
+ *
248
+ * Ready is three things in turn: the program has the terminal in raw mode,
249
+ * it has stopped drawing (a harness draws its startup, then goes quiet at its
250
+ * prompt), and what it stopped on is not a question. A paste into a question,
251
+ * such as Claude asking whether to trust a new folder, is lost, and the Enter
252
+ * after it answers the question. So that is reported rather than done, unless
253
+ * the caller says `force`. After the paste, the window has to draw something,
254
+ * which is the paste arriving; Enter is pressed only once it has.
255
+ */
256
+ async function deliver(
257
+ target: AttachTarget,
258
+ profile: Profile | undefined,
259
+ text: string,
260
+ opts: { submit: boolean; force: boolean },
261
+ ): Promise<SendResult> {
262
+ const { session } = target;
263
+ const started = await start(target, profile);
264
+ await waitForInput(config, session);
265
+ const watched = (await screens.watch(session)) !== undefined;
266
+ const state = watched
267
+ ? await screens.settle(session, SETTLE_WAIT_MS)
268
+ : "idle";
269
+ if (state === "question" && !opts.force)
270
+ return {
271
+ started,
272
+ delivered: false,
273
+ state,
274
+ reason:
275
+ "the window is waiting on a question, so nothing was pasted. Read it with GET /api/windows/:window/output, or send with force to paste anyway",
276
+ };
277
+
278
+ const before = Date.now();
279
+ await paste(config, session, text);
280
+ if (watched && !(await screens.drewSince(session, before, ECHO_WAIT_MS)))
281
+ return {
282
+ started,
283
+ delivered: false,
284
+ state: screens.get(session)?.activity() ?? state,
285
+ reason: "the window did not show the paste, so Enter was not pressed",
286
+ };
287
+ if (opts.submit) await pressEnter(config, session);
288
+ // The window has its instructions, so the band has nothing to offer.
289
+ updateWindow(db, target.windowId, { promptDone: true });
290
+ log.info(
291
+ `delivered ${text.length} characters to ${session}${opts.submit ? " and submitted" : ""}`,
292
+ );
293
+ return { started, delivered: true, state };
294
+ }
116
295
 
117
296
  return async function handleApi(
118
297
  req: Request,
@@ -123,21 +302,18 @@ export function createApi(deps: ApiDeps) {
123
302
 
124
303
  if (p === "/api/health") return json({ ok: true });
125
304
 
126
- // What the installed Claude CLI accepts, so the editor can offer it as
127
- // dropdowns rather than asking people to remember flag spellings.
128
- if (p === "/api/harness/claude" && req.method === "GET") {
129
- return json(await discoverClaude());
130
- }
131
-
132
- // The same question for Hermes, which answers a narrower version of it:
133
- // configured providers, its own defaults, and where its browser tools land.
134
- if (p === "/api/harness/hermes" && req.method === "GET") {
135
- return json(await discoverHermes());
305
+ // Every harness a profile can launch, as the specs the editor renders.
306
+ if (p === "/api/harnesses" && req.method === "GET") {
307
+ return json(specs());
136
308
  }
137
309
 
138
- // And for Cursor: the account's model list, and whether anyone is logged in.
139
- if (p === "/api/harness/cursor" && req.method === "GET") {
140
- return json(await discoverCursor());
310
+ // What one harness found on the box: models and options read off the
311
+ // installed binary, its defaults, whether it is logged in.
312
+ const harnessMatch = /^\/api\/harness\/([a-z][a-z0-9-]*)$/.exec(p);
313
+ if (harnessMatch && req.method === "GET") {
314
+ const found = discover(harnessMatch[1]);
315
+ if (!found) return json({ error: "unknown harness" }, 404);
316
+ return json((await found).report);
141
317
  }
142
318
 
143
319
  if (p === "/api/config" && req.method === "GET") {
@@ -155,6 +331,7 @@ export function createApi(deps: ApiDeps) {
155
331
  maxWallpaperBytes: MAX_WALLPAPER_BYTES,
156
332
  themeColor: config.themeColor,
157
333
  palette: PALETTE,
334
+ memory: config.memory,
158
335
  };
159
336
  return json(body);
160
337
  }
@@ -195,12 +372,17 @@ export function createApi(deps: ApiDeps) {
195
372
 
196
373
  if (req.method === "POST") {
197
374
  try {
198
- const body = await readJson<{ name?: string }>(req);
375
+ const body = await readJson<{ name?: unknown; from?: unknown }>(req);
376
+ if (body.name !== undefined && typeof body.name !== "string")
377
+ throw new Error("name must be a string");
378
+ if (body.from !== undefined && typeof body.from !== "string")
379
+ throw new Error("from must be a string");
199
380
  const created = await createWorkspace(
200
381
  db,
201
382
  config.workspace,
202
383
  projectId,
203
384
  body.name,
385
+ body.from,
204
386
  );
205
387
  return json(created, 201);
206
388
  } catch (err) {
@@ -212,6 +394,24 @@ export function createApi(deps: ApiDeps) {
212
394
  return methodNotAllowed();
213
395
  }
214
396
 
397
+ if (p === "/api/workspaces" && req.method === "GET") {
398
+ const projectNames = new Map(listProjects(db).map((x) => [x.id, x.name]));
399
+ const all: WorkspaceSummary[] = [];
400
+ for (const [projectId, project] of projectNames) {
401
+ for (const w of listWorkspaces(db, projectId))
402
+ all.push({
403
+ id: w.id,
404
+ name: w.name,
405
+ branch: w.branch,
406
+ path: w.path,
407
+ projectId,
408
+ project,
409
+ lastOpenedAt: w.lastOpenedAt,
410
+ });
411
+ }
412
+ return json(all.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt));
413
+ }
414
+
215
415
  // ── profiles ─────────────────────────────────────────────────────────────
216
416
  const profilesMatch = /^\/api\/projects\/([^/]+)\/profiles$/.exec(p);
217
417
  if (profilesMatch) {
@@ -303,23 +503,237 @@ export function createApi(deps: ApiDeps) {
303
503
  return json(listWindows(db, workspaceId));
304
504
  }
305
505
  if (req.method === "POST") {
306
- const body = await readJson<{ profileId?: unknown }>(req);
506
+ const body = await readJson<{
507
+ profileId?: unknown;
508
+ force?: unknown;
509
+ label?: unknown;
510
+ start?: unknown;
511
+ prompt?: unknown;
512
+ blanks?: unknown;
513
+ submit?: unknown;
514
+ }>(req);
515
+ let label: string | null = null;
516
+ if (body.label !== undefined && body.label !== null) {
517
+ if (typeof body.label !== "string")
518
+ return json({ error: "label must be a string" }, 400);
519
+ if (body.label.length > MAX_LABEL_CHARS)
520
+ return json(
521
+ { error: `label must be under ${MAX_LABEL_CHARS} characters` },
522
+ 400,
523
+ );
524
+ label = body.label.trim() || null;
525
+ }
526
+ for (const flag of ["start", "submit"] as const) {
527
+ if (body[flag] !== undefined && typeof body[flag] !== "boolean")
528
+ return json({ error: `${flag} must be true or false` }, 400);
529
+ }
530
+ let profile: Profile | undefined;
307
531
  let profileId: string | null = null;
308
532
  if (typeof body.profileId === "string" && body.profileId !== "") {
309
- const profile = getProfile(db, body.profileId);
533
+ profile = getProfile(db, body.profileId);
310
534
  // A profile belongs to a project, so it may only open windows in that
311
535
  // project's workspaces — otherwise one project's flags and prompt
312
536
  // could be launched inside another project's worktree.
313
537
  if (!profile || profile.projectId !== workspace.projectId) {
314
538
  return json({ error: "unknown profile for this workspace" }, 400);
315
539
  }
540
+ // An agent is what fills a small box, so an agent is what asks
541
+ // first. 409 with `capacity` is the question; the browser asks the
542
+ // person and sends `force` to go ahead anyway.
543
+ if (profile.harness !== "shell" && body.force !== true) {
544
+ const warning = capacityWarning(
545
+ await readBoxMemory(),
546
+ effectiveLimits(config.memory, profile).high,
547
+ );
548
+ if (warning) return json({ error: warning, capacity: true }, 409);
549
+ }
316
550
  profileId = profile.id;
317
551
  }
318
- return json(createWindow(db, workspaceId, profileId), 201);
552
+
553
+ // A prompt or blanks mean start it and send; `start` alone means start.
554
+ const sends = body.prompt !== undefined || body.blanks !== undefined;
555
+ let text: string | undefined;
556
+ if (sends) {
557
+ try {
558
+ text = textToSend(
559
+ {
560
+ text: body.prompt as string | undefined,
561
+ blanks: body.blanks as Record<string, string> | undefined,
562
+ },
563
+ profile,
564
+ );
565
+ } catch (err) {
566
+ return json({ error: describeError(err) }, 400);
567
+ }
568
+ }
569
+ if ((sends || body.start === true) && !config.sessions)
570
+ return json(
571
+ { error: "this server runs plain login shells (--no-sessions)" },
572
+ 409,
573
+ );
574
+
575
+ const row = createWindow(db, workspaceId, profileId, label);
576
+ if (!sends && body.start !== true) return json(row, 201);
577
+
578
+ const target = resolveTarget(db, row.id);
579
+ if (!target) return json({ error: "unknown window" }, 404);
580
+ try {
581
+ const result =
582
+ text !== undefined
583
+ ? await deliver(target, profile, text, {
584
+ submit: body.submit === true,
585
+ force: false,
586
+ })
587
+ : { started: await start(target, profile) };
588
+ return json(
589
+ {
590
+ ...(getWindow(db, row.id) ?? row),
591
+ ...result,
592
+ window: summarise(target, new Set([target.session])),
593
+ },
594
+ 201,
595
+ );
596
+ } catch (err) {
597
+ // A window that could not start is a tile with nothing in it. Take it
598
+ // back out rather than leave that on someone's desktop.
599
+ const message = describeError(err);
600
+ log.warn(`open ${target.session} failed: ${message}`);
601
+ await killSession(config, target.session).catch(() => {});
602
+ deleteWindow(db, row.id);
603
+ return json({ error: message }, 502);
604
+ }
319
605
  }
320
606
  return methodNotAllowed();
321
607
  }
322
608
 
609
+ // What the box has and what each of this workspace's windows costs, for
610
+ // the dock. Linux only; elsewhere both halves come back empty.
611
+ const memoryMatch = /^\/api\/workspaces\/([^/]+)\/memory$/.exec(p);
612
+ if (memoryMatch && req.method === "GET") {
613
+ const workspaceId = decodeURIComponent(memoryMatch[1]);
614
+ if (!ID_PATTERN.test(workspaceId)) return badId("workspace");
615
+ const sockets = new Map<string, string>();
616
+ for (const win of listWindows(db, workspaceId)) {
617
+ const target = sessionNameFor(db, win.id);
618
+ if (target) sockets.set(socketPath(config, target.session), win.id);
619
+ }
620
+ const [box, usage] = await Promise.all([
621
+ readBoxMemory(),
622
+ sessionMemory([...sockets.keys()]),
623
+ ]);
624
+ const report: MemoryReport = {
625
+ box,
626
+ warning: capacityWarning(box, config.memory.high),
627
+ windows: {},
628
+ };
629
+ for (const [sock, mem] of usage) {
630
+ const id = sockets.get(sock);
631
+ if (id) report.windows[id] = mem;
632
+ }
633
+ return json(report);
634
+ }
635
+
636
+ if (p === "/api/windows" && req.method === "GET") {
637
+ const live = config.sessions
638
+ ? await liveSessions(config)
639
+ : new Set<string>();
640
+ // A session started by a login rather than by this server is picked up
641
+ // within a few seconds anyway; this saves a caller the wait.
642
+ await Promise.all([...live].map((s) => screens.watch(s)));
643
+ const label = url.searchParams.get("label");
644
+ return json(
645
+ listTargets(db)
646
+ .filter((t) => label === null || t.label === label)
647
+ .map((t) => summarise(t, live)),
648
+ );
649
+ }
650
+
651
+ // What a window shows, as plain text: the screen and the history above it.
652
+ const outputMatch = /^\/api\/windows\/([^/]+)\/output$/.exec(p);
653
+ if (outputMatch && req.method === "GET") {
654
+ const id = decodeURIComponent(outputMatch[1]);
655
+ if (!ID_PATTERN.test(id)) return badId("window");
656
+ if (!config.sessions)
657
+ return json(
658
+ { error: "this server runs plain login shells (--no-sessions)" },
659
+ 409,
660
+ );
661
+ const target = resolveTarget(db, id);
662
+ if (!target || target.windowId !== id)
663
+ return json({ error: "unknown window" }, 404);
664
+ const given = url.searchParams.get("lines");
665
+ const lines = given === null ? DEFAULT_OUTPUT_LINES : Number(given);
666
+ if (!Number.isInteger(lines) || lines < 1 || lines > MAX_OUTPUT_LINES)
667
+ return json(
668
+ {
669
+ error: `lines must be a whole number from 1 to ${MAX_OUTPUT_LINES}`,
670
+ },
671
+ 400,
672
+ );
673
+ const screen = await screens.watch(target.session);
674
+ if (!screen) return json({ error: "the window is not running" }, 409);
675
+ // Just attached to: its repaint is on the way, and blank until it lands.
676
+ if (screen.lastOutputAt === null)
677
+ await screens.drewSince(target.session, 0, FIRST_PAINT_WAIT_MS);
678
+ return json({
679
+ text: screen.text(lines),
680
+ state: screen.activity(),
681
+ lastOutputAt: screen.lastOutputAt,
682
+ });
683
+ }
684
+
685
+ // Starts a window's session with nothing attached, so it is running before
686
+ // any browser opens it. Idempotent: a running session is left alone.
687
+ const startMatch = /^\/api\/windows\/([^/]+)\/(start|send)$/.exec(p);
688
+ if (startMatch && req.method === "POST") {
689
+ const id = decodeURIComponent(startMatch[1]);
690
+ if (!ID_PATTERN.test(id)) return badId("window");
691
+ if (!config.sessions)
692
+ return json(
693
+ { error: "this server runs plain login shells (--no-sessions)" },
694
+ 409,
695
+ );
696
+ const target = resolveTarget(db, id);
697
+ if (!target || target.windowId !== id)
698
+ return json({ error: "unknown window" }, 404);
699
+ const profile = target.profileId
700
+ ? getProfile(db, target.profileId)
701
+ : undefined;
702
+
703
+ let text: string | undefined;
704
+ let submit = false;
705
+ let force = false;
706
+ if (startMatch[2] === "send") {
707
+ const body = await readJson<SendInput>(req);
708
+ for (const flag of ["submit", "force"] as const) {
709
+ if (body[flag] !== undefined && typeof body[flag] !== "boolean")
710
+ return json({ error: `${flag} must be true or false` }, 400);
711
+ }
712
+ submit = body.submit === true;
713
+ force = body.force === true;
714
+ try {
715
+ text = textToSend(body, profile);
716
+ } catch (err) {
717
+ return json({ error: describeError(err) }, 400);
718
+ }
719
+ }
720
+
721
+ try {
722
+ const result =
723
+ text !== undefined
724
+ ? await deliver(target, profile, text, { submit, force })
725
+ : { started: await start(target, profile) };
726
+ return json({
727
+ ...result,
728
+ window: summarise(target, new Set([target.session])),
729
+ });
730
+ } catch (err) {
731
+ const message = describeError(err);
732
+ log.warn(`${startMatch[2]} ${target.session} failed: ${message}`);
733
+ return json({ error: message }, 502);
734
+ }
735
+ }
736
+
323
737
  // How to reach this window from a real terminal. Read-only: it composes
324
738
  // strings a person can copy, and never runs anything itself.
325
739
  const attachMatch = /^\/api\/windows\/([^/]+)\/attach$/.exec(p);
@@ -355,6 +769,20 @@ export function createApi(deps: ApiDeps) {
355
769
  return json(getWindow(db, id) ?? { ok: true });
356
770
  }
357
771
 
772
+ // The answer to "bring this window back?" after its session died under it.
773
+ // Recorded here and acted on by the next certificate, which is what creates
774
+ // the session.
775
+ const restoreMatch = /^\/api\/windows\/([^/]+)\/restore$/.exec(p);
776
+ if (restoreMatch && req.method === "POST") {
777
+ const id = decodeURIComponent(restoreMatch[1]);
778
+ if (!ID_PATTERN.test(id)) return badId("window");
779
+ const body = await readJson<{ resume?: unknown }>(req);
780
+ if (typeof body.resume !== "boolean")
781
+ return json({ error: "resume must be true or false" }, 400);
782
+ const row = restoreWindow(db, id, body.resume);
783
+ return row ? json(row) : json({ error: "unknown window" }, 404);
784
+ }
785
+
358
786
  const windowMatch = /^\/api\/windows\/([^/]+)$/.exec(p);
359
787
  if (windowMatch) {
360
788
  const id = decodeURIComponent(windowMatch[1]);
@@ -423,6 +851,7 @@ export function createApi(deps: ApiDeps) {
423
851
  target.cwd,
424
852
  config,
425
853
  profile,
854
+ { resume: takeResume(db, windowId) },
426
855
  );
427
856
  const cert = await ca.signUserCert({
428
857
  publicKey,
@@ -5,12 +5,12 @@
5
5
  // these resolve to real paths on disk, so the same code serves both modes.
6
6
 
7
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' };
8
+ import a1 from "../dist/web/assets/index-82MhMOB4.css" with { type: 'file' };
9
+ import a2 from "../dist/web/assets/index-82MhMOB4.css.br" with { type: 'file' };
10
+ import a3 from "../dist/web/assets/index-82MhMOB4.css.gz" with { type: 'file' };
11
+ import a4 from "../dist/web/assets/index-DCB2nO0u.js" with { type: 'file' };
12
+ import a5 from "../dist/web/assets/index-DCB2nO0u.js.br" with { type: 'file' };
13
+ import a6 from "../dist/web/assets/index-DCB2nO0u.js.gz" with { type: 'file' };
14
14
  import a7 from "../dist/web/icon-192.png" with { type: 'file' };
15
15
  import a8 from "../dist/web/icon-512.png" with { type: 'file' };
16
16
  import a9 from "../dist/web/icon-app.svg" with { type: 'file' };
@@ -26,16 +26,16 @@ import a18 from "../dist/web/wasm_exec.js" with { type: 'file' };
26
26
  import a19 from "../dist/web/wasm_exec.js.br" with { type: 'file' };
27
27
  import a20 from "../dist/web/wasm_exec.js.gz" with { type: 'file' };
28
28
 
29
- export const BUILD_ID = "4784e0916592";
29
+ export const BUILD_ID = "e0a1784e9da0";
30
30
 
31
31
  export const EMBEDDED: Record<string, string> = {
32
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,
33
+ "/assets/index-82MhMOB4.css": a1,
34
+ "/assets/index-82MhMOB4.css.br": a2,
35
+ "/assets/index-82MhMOB4.css.gz": a3,
36
+ "/assets/index-DCB2nO0u.js": a4,
37
+ "/assets/index-DCB2nO0u.js.br": a5,
38
+ "/assets/index-DCB2nO0u.js.gz": a6,
39
39
  "/icon-192.png": a7,
40
40
  "/icon-512.png": a8,
41
41
  "/icon-app.svg": a9,