@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.
- package/README.md +19 -847
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/web/assets/index-82MhMOB4.css +1 -0
- package/dist/web/assets/index-82MhMOB4.css.br +0 -0
- package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
- package/dist/web/assets/index-DCB2nO0u.js +36 -0
- package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
- package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/scripts/notices.ts +3 -1
- package/server/api.ts +449 -20
- package/server/assets.generated.ts +13 -13
- package/server/attach.ts +65 -16
- package/server/browser.ts +1 -65
- package/server/cli.ts +48 -150
- package/server/client.ts +440 -0
- package/server/config.ts +64 -2
- package/server/db.ts +19 -0
- package/server/doctor.ts +176 -123
- package/server/harness.ts +378 -590
- package/server/harnesses/claude.ts +248 -0
- package/server/harnesses/codex.ts +241 -0
- package/server/harnesses/cursor.ts +202 -0
- package/server/harnesses/hermes.ts +259 -0
- package/server/harnesses/util.ts +181 -0
- package/server/headless.ts +358 -0
- package/server/index.ts +47 -14
- package/server/memory.ts +418 -0
- package/server/profiles.ts +27 -15
- package/server/projects.ts +52 -3
- package/server/reaper.ts +5 -3
- package/server/screens.ts +372 -0
- package/server/session.ts +84 -95
- package/server/setup.ts +73 -7
- package/server/windows.ts +51 -1
- package/shared/blanks.ts +113 -0
- package/shared/harness.ts +375 -0
- package/shared/wire.ts +105 -94
- package/dist/web/assets/index-B77Qk8B5.js +0 -35
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +0 -1
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- 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
|
|
15
|
+
import { type Config, savePersisted } from "./config.ts";
|
|
15
16
|
import { openDb } from "./db.ts";
|
|
16
|
-
import {
|
|
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
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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)`);
|