@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.
- 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
|
Binary file
|
|
Binary file
|
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
31
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
},
|
package/scripts/notices.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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?:
|
|
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<{
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
9
|
-
import a2 from "../dist/web/assets/index-
|
|
10
|
-
import a3 from "../dist/web/assets/index-
|
|
11
|
-
import a4 from "../dist/web/assets/index-
|
|
12
|
-
import a5 from "../dist/web/assets/index-
|
|
13
|
-
import a6 from "../dist/web/assets/index-
|
|
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 = "
|
|
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-
|
|
34
|
-
"/assets/index-
|
|
35
|
-
"/assets/index-
|
|
36
|
-
"/assets/index-
|
|
37
|
-
"/assets/index-
|
|
38
|
-
"/assets/index-
|
|
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,
|