@tsconfigdotjson/vibe-os 0.2.0 → 0.4.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 +505 -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 +417 -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 +392 -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 +107 -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
package/server/api.ts
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
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
|
+
KEY_NAMES,
|
|
29
|
+
keyBytes,
|
|
30
|
+
paste,
|
|
31
|
+
press,
|
|
32
|
+
submit as pressEnter,
|
|
33
|
+
startSession,
|
|
34
|
+
waitForInput,
|
|
35
|
+
} from "./headless.ts";
|
|
15
36
|
import {
|
|
16
37
|
badId,
|
|
17
38
|
IMMUTABLE_CACHE_CONTROL,
|
|
@@ -26,12 +47,19 @@ import {
|
|
|
26
47
|
type McpScan,
|
|
27
48
|
syncMcpMirrors,
|
|
28
49
|
} from "./mcp.ts";
|
|
50
|
+
import {
|
|
51
|
+
capacityWarning,
|
|
52
|
+
effectiveLimits,
|
|
53
|
+
readBoxMemory,
|
|
54
|
+
sessionMemory,
|
|
55
|
+
} from "./memory.ts";
|
|
29
56
|
import {
|
|
30
57
|
createProfile,
|
|
31
58
|
deleteProfile,
|
|
32
59
|
getProfile,
|
|
33
60
|
listProfiles,
|
|
34
61
|
PALETTE,
|
|
62
|
+
type Profile,
|
|
35
63
|
updateProfile,
|
|
36
64
|
} from "./profiles.ts";
|
|
37
65
|
import {
|
|
@@ -44,7 +72,8 @@ import {
|
|
|
44
72
|
scanProjects,
|
|
45
73
|
touchWorkspace,
|
|
46
74
|
} from "./projects.ts";
|
|
47
|
-
import {
|
|
75
|
+
import { Screens } from "./screens.ts";
|
|
76
|
+
import { socketPath, windowCommand } from "./session.ts";
|
|
48
77
|
import type { SshCa } from "./ssh-ca.ts";
|
|
49
78
|
import { fingerprint, MAX_PUBKEY_BYTES } from "./ssh-ca.ts";
|
|
50
79
|
import {
|
|
@@ -57,7 +86,9 @@ import {
|
|
|
57
86
|
deleteWindow,
|
|
58
87
|
getWindow,
|
|
59
88
|
listWindows,
|
|
89
|
+
restoreWindow,
|
|
60
90
|
sessionNameFor,
|
|
91
|
+
takeResume,
|
|
61
92
|
updateWindow,
|
|
62
93
|
} from "./windows.ts";
|
|
63
94
|
|
|
@@ -103,16 +134,216 @@ async function refreshMcpMirrors(db: Db, stateDir: string): Promise<void> {
|
|
|
103
134
|
);
|
|
104
135
|
}
|
|
105
136
|
|
|
137
|
+
/** Ceiling on text sent to a window, matching a profile prompt's. */
|
|
138
|
+
const MAX_SEND_CHARS = 16_000;
|
|
139
|
+
|
|
140
|
+
/** Most keys one send can press. */
|
|
141
|
+
const MAX_KEYS = 32;
|
|
142
|
+
|
|
143
|
+
/** Longest label a window can be tagged with. */
|
|
144
|
+
const MAX_LABEL_CHARS = 200;
|
|
145
|
+
/**
|
|
146
|
+
* How long a send waits for a window to stop drawing before it pastes anyway.
|
|
147
|
+
*
|
|
148
|
+
* A harness that has just started draws its startup and goes quiet in a few
|
|
149
|
+
* seconds. One that is working never goes quiet, and a paste into a working
|
|
150
|
+
* agent is queued rather than lost, so after this it goes in regardless.
|
|
151
|
+
*/
|
|
152
|
+
const SETTLE_WAIT_MS = 20_000;
|
|
153
|
+
/** How long after a paste the window has to draw something. */
|
|
154
|
+
const ECHO_WAIT_MS = 3_000;
|
|
155
|
+
/** Lines of output when the caller does not say, and the most it can ask for. */
|
|
156
|
+
const DEFAULT_OUTPUT_LINES = 50;
|
|
157
|
+
const MAX_OUTPUT_LINES = 2_000;
|
|
158
|
+
/** How long a first read waits for a window to redraw itself. */
|
|
159
|
+
const FIRST_PAINT_WAIT_MS = 1_000;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What to paste for a send: the text given, or the profile prompt with its
|
|
163
|
+
* blanks filled. Throws with a message for the caller when neither works.
|
|
164
|
+
*/
|
|
165
|
+
function textToSend(
|
|
166
|
+
body: SendInput,
|
|
167
|
+
profile: { prompt: string } | undefined,
|
|
168
|
+
): string {
|
|
169
|
+
if (body.text !== undefined) {
|
|
170
|
+
if (typeof body.text !== "string" || body.text === "")
|
|
171
|
+
throw new Error(
|
|
172
|
+
'text must be a non-empty string. To press Enter alone, send keys: ["Enter"]',
|
|
173
|
+
);
|
|
174
|
+
if (body.blanks !== undefined)
|
|
175
|
+
throw new Error(
|
|
176
|
+
"blanks fill the profile prompt; leave out text to use it",
|
|
177
|
+
);
|
|
178
|
+
if (body.text.length > MAX_SEND_CHARS)
|
|
179
|
+
throw new Error(`text must be under ${MAX_SEND_CHARS} characters`);
|
|
180
|
+
return body.text;
|
|
181
|
+
}
|
|
182
|
+
if (!profile || profile.prompt.trim() === "")
|
|
183
|
+
throw new Error("this window has no profile prompt; send text instead");
|
|
184
|
+
|
|
185
|
+
const blanks = body.blanks ?? {};
|
|
186
|
+
if (
|
|
187
|
+
typeof blanks !== "object" ||
|
|
188
|
+
blanks === null ||
|
|
189
|
+
Array.isArray(blanks) ||
|
|
190
|
+
Object.values(blanks).some((v) => typeof v !== "string")
|
|
191
|
+
)
|
|
192
|
+
throw new Error("blanks must be an object of strings");
|
|
193
|
+
const filled = fillBlanks(profile.prompt, blanks);
|
|
194
|
+
if (filled.unknown.length > 0)
|
|
195
|
+
throw new Error(
|
|
196
|
+
`the prompt has no blank called ${filled.unknown.join(", ")}`,
|
|
197
|
+
);
|
|
198
|
+
if (filled.missing.length > 0)
|
|
199
|
+
throw new Error(`fill every blank: ${filled.missing.join(", ")}`);
|
|
200
|
+
return filled.text;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The keys a send presses instead of pasting. Throws with a message for the
|
|
205
|
+
* caller when they are not keys, or come with something a key send ignores.
|
|
206
|
+
*/
|
|
207
|
+
function keysToPress(body: SendInput): string[] {
|
|
208
|
+
const { keys } = body;
|
|
209
|
+
if (
|
|
210
|
+
!Array.isArray(keys) ||
|
|
211
|
+
keys.length === 0 ||
|
|
212
|
+
keys.length > MAX_KEYS ||
|
|
213
|
+
keys.some((k) => typeof k !== "string")
|
|
214
|
+
)
|
|
215
|
+
throw new Error(`keys must be a list of 1 to ${MAX_KEYS} key names`);
|
|
216
|
+
const unknown = keys.find((k) => keyBytes(k) === null);
|
|
217
|
+
if (unknown !== undefined)
|
|
218
|
+
throw new Error(
|
|
219
|
+
`unknown key ${JSON.stringify(unknown)}. A key is one character or one of ${KEY_NAMES.join(", ")}`,
|
|
220
|
+
);
|
|
221
|
+
if (body.text !== undefined || body.blanks !== undefined)
|
|
222
|
+
throw new Error("send text or keys, not both");
|
|
223
|
+
if (body.submit !== undefined)
|
|
224
|
+
throw new Error('submit is for text; put "Enter" in keys instead');
|
|
225
|
+
return keys;
|
|
226
|
+
}
|
|
227
|
+
|
|
106
228
|
export interface ApiDeps {
|
|
107
229
|
config: Config;
|
|
108
230
|
ca: SshCa;
|
|
109
231
|
hostKey: string | null;
|
|
110
232
|
wallpapers: WallpaperStore;
|
|
111
233
|
db: Db;
|
|
234
|
+
/** Every running window's screen. One that is never started if absent. */
|
|
235
|
+
screens?: Screens;
|
|
112
236
|
}
|
|
113
237
|
|
|
114
238
|
export function createApi(deps: ApiDeps) {
|
|
115
239
|
const { ca, config, db, hostKey, wallpapers } = deps;
|
|
240
|
+
const screens = deps.screens ?? new Screens(config);
|
|
241
|
+
|
|
242
|
+
const summarise = (t: AttachTarget, live: Set<string>): WindowSummary => {
|
|
243
|
+
const running = live.has(t.session);
|
|
244
|
+
const screen = running ? screens.get(t.session) : undefined;
|
|
245
|
+
return {
|
|
246
|
+
id: t.windowId,
|
|
247
|
+
ref: t.ref,
|
|
248
|
+
session: t.session,
|
|
249
|
+
workspace: t.workspace,
|
|
250
|
+
project: t.project,
|
|
251
|
+
role: t.role,
|
|
252
|
+
label: t.label,
|
|
253
|
+
live: running,
|
|
254
|
+
state: running ? (screen?.activity() ?? "idle") : null,
|
|
255
|
+
lastOutputAt: screen?.lastOutputAt ?? null,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Starts a window's session if it is not running, and watches its screen. */
|
|
260
|
+
async function start(
|
|
261
|
+
target: AttachTarget,
|
|
262
|
+
profile: Profile | undefined,
|
|
263
|
+
): Promise<boolean> {
|
|
264
|
+
if (profile?.harness === "claude")
|
|
265
|
+
await refreshMcpMirrors(db, config.stateDir).catch((err: unknown) => {
|
|
266
|
+
log.warn(`could not refresh mcp configs: ${describeError(err)}`);
|
|
267
|
+
});
|
|
268
|
+
const started = await startSession(
|
|
269
|
+
{ config, ca, hostKey },
|
|
270
|
+
target,
|
|
271
|
+
profile,
|
|
272
|
+
() => takeResume(db, target.windowId),
|
|
273
|
+
);
|
|
274
|
+
await screens.watch(target.session, { fresh: started });
|
|
275
|
+
return started;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Starts a window if need be, waits until it is ready for text, and pastes.
|
|
280
|
+
*
|
|
281
|
+
* Ready is three things in turn: the program has the terminal in raw mode,
|
|
282
|
+
* it has stopped drawing (a harness draws its startup, then goes quiet at its
|
|
283
|
+
* prompt), and what it stopped on is not a question. A paste into a question,
|
|
284
|
+
* such as Claude asking whether to trust a new folder, is lost, and the Enter
|
|
285
|
+
* after it answers the question. So that is reported rather than done, unless
|
|
286
|
+
* the caller says `force`. After the paste, the window has to draw something,
|
|
287
|
+
* which is the paste arriving; Enter is pressed only once it has.
|
|
288
|
+
*/
|
|
289
|
+
async function deliver(
|
|
290
|
+
target: AttachTarget,
|
|
291
|
+
profile: Profile | undefined,
|
|
292
|
+
text: string,
|
|
293
|
+
opts: { submit: boolean; force: boolean },
|
|
294
|
+
): Promise<SendResult> {
|
|
295
|
+
const { session } = target;
|
|
296
|
+
const started = await start(target, profile);
|
|
297
|
+
await waitForInput(config, session);
|
|
298
|
+
const watched = (await screens.watch(session)) !== undefined;
|
|
299
|
+
const state = watched
|
|
300
|
+
? await screens.settle(session, SETTLE_WAIT_MS)
|
|
301
|
+
: "idle";
|
|
302
|
+
if (state === "question" && !opts.force)
|
|
303
|
+
return {
|
|
304
|
+
started,
|
|
305
|
+
delivered: false,
|
|
306
|
+
state,
|
|
307
|
+
reason:
|
|
308
|
+
"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",
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const before = Date.now();
|
|
312
|
+
await paste(config, session, text);
|
|
313
|
+
if (watched && !(await screens.drewSince(session, before, ECHO_WAIT_MS)))
|
|
314
|
+
return {
|
|
315
|
+
started,
|
|
316
|
+
delivered: false,
|
|
317
|
+
state: screens.get(session)?.activity() ?? state,
|
|
318
|
+
reason: "the window did not show the paste, so Enter was not pressed",
|
|
319
|
+
};
|
|
320
|
+
if (opts.submit) await pressEnter(config, session);
|
|
321
|
+
// The window has its instructions, so the band has nothing to offer.
|
|
322
|
+
updateWindow(db, target.windowId, { promptDone: true });
|
|
323
|
+
log.info(
|
|
324
|
+
`delivered ${text.length} characters to ${session}${opts.submit ? " and submitted" : ""}`,
|
|
325
|
+
);
|
|
326
|
+
return { started, delivered: true, state };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Starts a window if need be, and presses keys in it once it reads them.
|
|
331
|
+
*
|
|
332
|
+
* No wait for quiet and no refusal on a question, unlike `deliver`: keys are
|
|
333
|
+
* how a question is answered, and an Escape is how a busy agent is stopped.
|
|
334
|
+
*/
|
|
335
|
+
async function pressKeys(
|
|
336
|
+
target: AttachTarget,
|
|
337
|
+
profile: Profile | undefined,
|
|
338
|
+
keys: string[],
|
|
339
|
+
): Promise<SendResult> {
|
|
340
|
+
const { session } = target;
|
|
341
|
+
const started = await start(target, profile);
|
|
342
|
+
await waitForInput(config, session);
|
|
343
|
+
const state = screens.get(session)?.activity() ?? "idle";
|
|
344
|
+
await press(config, session, keys);
|
|
345
|
+
return { started, delivered: true, state };
|
|
346
|
+
}
|
|
116
347
|
|
|
117
348
|
return async function handleApi(
|
|
118
349
|
req: Request,
|
|
@@ -123,21 +354,18 @@ export function createApi(deps: ApiDeps) {
|
|
|
123
354
|
|
|
124
355
|
if (p === "/api/health") return json({ ok: true });
|
|
125
356
|
|
|
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());
|
|
357
|
+
// Every harness a profile can launch, as the specs the editor renders.
|
|
358
|
+
if (p === "/api/harnesses" && req.method === "GET") {
|
|
359
|
+
return json(specs());
|
|
136
360
|
}
|
|
137
361
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
362
|
+
// What one harness found on the box: models and options read off the
|
|
363
|
+
// installed binary, its defaults, whether it is logged in.
|
|
364
|
+
const harnessMatch = /^\/api\/harness\/([a-z][a-z0-9-]*)$/.exec(p);
|
|
365
|
+
if (harnessMatch && req.method === "GET") {
|
|
366
|
+
const found = discover(harnessMatch[1]);
|
|
367
|
+
if (!found) return json({ error: "unknown harness" }, 404);
|
|
368
|
+
return json((await found).report);
|
|
141
369
|
}
|
|
142
370
|
|
|
143
371
|
if (p === "/api/config" && req.method === "GET") {
|
|
@@ -155,6 +383,7 @@ export function createApi(deps: ApiDeps) {
|
|
|
155
383
|
maxWallpaperBytes: MAX_WALLPAPER_BYTES,
|
|
156
384
|
themeColor: config.themeColor,
|
|
157
385
|
palette: PALETTE,
|
|
386
|
+
memory: config.memory,
|
|
158
387
|
};
|
|
159
388
|
return json(body);
|
|
160
389
|
}
|
|
@@ -195,12 +424,17 @@ export function createApi(deps: ApiDeps) {
|
|
|
195
424
|
|
|
196
425
|
if (req.method === "POST") {
|
|
197
426
|
try {
|
|
198
|
-
const body = await readJson<{ name?:
|
|
427
|
+
const body = await readJson<{ name?: unknown; from?: unknown }>(req);
|
|
428
|
+
if (body.name !== undefined && typeof body.name !== "string")
|
|
429
|
+
throw new Error("name must be a string");
|
|
430
|
+
if (body.from !== undefined && typeof body.from !== "string")
|
|
431
|
+
throw new Error("from must be a string");
|
|
199
432
|
const created = await createWorkspace(
|
|
200
433
|
db,
|
|
201
434
|
config.workspace,
|
|
202
435
|
projectId,
|
|
203
436
|
body.name,
|
|
437
|
+
body.from,
|
|
204
438
|
);
|
|
205
439
|
return json(created, 201);
|
|
206
440
|
} catch (err) {
|
|
@@ -212,6 +446,24 @@ export function createApi(deps: ApiDeps) {
|
|
|
212
446
|
return methodNotAllowed();
|
|
213
447
|
}
|
|
214
448
|
|
|
449
|
+
if (p === "/api/workspaces" && req.method === "GET") {
|
|
450
|
+
const projectNames = new Map(listProjects(db).map((x) => [x.id, x.name]));
|
|
451
|
+
const all: WorkspaceSummary[] = [];
|
|
452
|
+
for (const [projectId, project] of projectNames) {
|
|
453
|
+
for (const w of listWorkspaces(db, projectId))
|
|
454
|
+
all.push({
|
|
455
|
+
id: w.id,
|
|
456
|
+
name: w.name,
|
|
457
|
+
branch: w.branch,
|
|
458
|
+
path: w.path,
|
|
459
|
+
projectId,
|
|
460
|
+
project,
|
|
461
|
+
lastOpenedAt: w.lastOpenedAt,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return json(all.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt));
|
|
465
|
+
}
|
|
466
|
+
|
|
215
467
|
// ── profiles ─────────────────────────────────────────────────────────────
|
|
216
468
|
const profilesMatch = /^\/api\/projects\/([^/]+)\/profiles$/.exec(p);
|
|
217
469
|
if (profilesMatch) {
|
|
@@ -303,23 +555,241 @@ export function createApi(deps: ApiDeps) {
|
|
|
303
555
|
return json(listWindows(db, workspaceId));
|
|
304
556
|
}
|
|
305
557
|
if (req.method === "POST") {
|
|
306
|
-
const body = await readJson<{
|
|
558
|
+
const body = await readJson<{
|
|
559
|
+
profileId?: unknown;
|
|
560
|
+
force?: unknown;
|
|
561
|
+
label?: unknown;
|
|
562
|
+
start?: unknown;
|
|
563
|
+
prompt?: unknown;
|
|
564
|
+
blanks?: unknown;
|
|
565
|
+
submit?: unknown;
|
|
566
|
+
}>(req);
|
|
567
|
+
let label: string | null = null;
|
|
568
|
+
if (body.label !== undefined && body.label !== null) {
|
|
569
|
+
if (typeof body.label !== "string")
|
|
570
|
+
return json({ error: "label must be a string" }, 400);
|
|
571
|
+
if (body.label.length > MAX_LABEL_CHARS)
|
|
572
|
+
return json(
|
|
573
|
+
{ error: `label must be under ${MAX_LABEL_CHARS} characters` },
|
|
574
|
+
400,
|
|
575
|
+
);
|
|
576
|
+
label = body.label.trim() || null;
|
|
577
|
+
}
|
|
578
|
+
for (const flag of ["start", "submit"] as const) {
|
|
579
|
+
if (body[flag] !== undefined && typeof body[flag] !== "boolean")
|
|
580
|
+
return json({ error: `${flag} must be true or false` }, 400);
|
|
581
|
+
}
|
|
582
|
+
let profile: Profile | undefined;
|
|
307
583
|
let profileId: string | null = null;
|
|
308
584
|
if (typeof body.profileId === "string" && body.profileId !== "") {
|
|
309
|
-
|
|
585
|
+
profile = getProfile(db, body.profileId);
|
|
310
586
|
// A profile belongs to a project, so it may only open windows in that
|
|
311
587
|
// project's workspaces — otherwise one project's flags and prompt
|
|
312
588
|
// could be launched inside another project's worktree.
|
|
313
589
|
if (!profile || profile.projectId !== workspace.projectId) {
|
|
314
590
|
return json({ error: "unknown profile for this workspace" }, 400);
|
|
315
591
|
}
|
|
592
|
+
// An agent is what fills a small box, so an agent is what asks
|
|
593
|
+
// first. 409 with `capacity` is the question; the browser asks the
|
|
594
|
+
// person and sends `force` to go ahead anyway.
|
|
595
|
+
if (profile.harness !== "shell" && body.force !== true) {
|
|
596
|
+
const warning = capacityWarning(
|
|
597
|
+
await readBoxMemory(),
|
|
598
|
+
effectiveLimits(config.memory, profile).high,
|
|
599
|
+
);
|
|
600
|
+
if (warning) return json({ error: warning, capacity: true }, 409);
|
|
601
|
+
}
|
|
316
602
|
profileId = profile.id;
|
|
317
603
|
}
|
|
318
|
-
|
|
604
|
+
|
|
605
|
+
// A prompt or blanks mean start it and send; `start` alone means start.
|
|
606
|
+
const sends = body.prompt !== undefined || body.blanks !== undefined;
|
|
607
|
+
let text: string | undefined;
|
|
608
|
+
if (sends) {
|
|
609
|
+
try {
|
|
610
|
+
text = textToSend(
|
|
611
|
+
{
|
|
612
|
+
text: body.prompt as string | undefined,
|
|
613
|
+
blanks: body.blanks as Record<string, string> | undefined,
|
|
614
|
+
},
|
|
615
|
+
profile,
|
|
616
|
+
);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
return json({ error: describeError(err) }, 400);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if ((sends || body.start === true) && !config.sessions)
|
|
622
|
+
return json(
|
|
623
|
+
{ error: "this server runs plain login shells (--no-sessions)" },
|
|
624
|
+
409,
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const row = createWindow(db, workspaceId, profileId, label);
|
|
628
|
+
if (!sends && body.start !== true) return json(row, 201);
|
|
629
|
+
|
|
630
|
+
const target = resolveTarget(db, row.id);
|
|
631
|
+
if (!target) return json({ error: "unknown window" }, 404);
|
|
632
|
+
try {
|
|
633
|
+
const result =
|
|
634
|
+
text !== undefined
|
|
635
|
+
? await deliver(target, profile, text, {
|
|
636
|
+
submit: body.submit === true,
|
|
637
|
+
force: false,
|
|
638
|
+
})
|
|
639
|
+
: { started: await start(target, profile) };
|
|
640
|
+
return json(
|
|
641
|
+
{
|
|
642
|
+
...(getWindow(db, row.id) ?? row),
|
|
643
|
+
...result,
|
|
644
|
+
window: summarise(target, new Set([target.session])),
|
|
645
|
+
},
|
|
646
|
+
201,
|
|
647
|
+
);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
// A window that could not start is a tile with nothing in it. Take it
|
|
650
|
+
// back out rather than leave that on someone's desktop.
|
|
651
|
+
const message = describeError(err);
|
|
652
|
+
log.warn(`open ${target.session} failed: ${message}`);
|
|
653
|
+
await killSession(config, target.session).catch(() => {});
|
|
654
|
+
deleteWindow(db, row.id);
|
|
655
|
+
return json({ error: message }, 502);
|
|
656
|
+
}
|
|
319
657
|
}
|
|
320
658
|
return methodNotAllowed();
|
|
321
659
|
}
|
|
322
660
|
|
|
661
|
+
// What the box has and what each of this workspace's windows costs, for
|
|
662
|
+
// the dock. Linux only; elsewhere both halves come back empty.
|
|
663
|
+
const memoryMatch = /^\/api\/workspaces\/([^/]+)\/memory$/.exec(p);
|
|
664
|
+
if (memoryMatch && req.method === "GET") {
|
|
665
|
+
const workspaceId = decodeURIComponent(memoryMatch[1]);
|
|
666
|
+
if (!ID_PATTERN.test(workspaceId)) return badId("workspace");
|
|
667
|
+
const sockets = new Map<string, string>();
|
|
668
|
+
for (const win of listWindows(db, workspaceId)) {
|
|
669
|
+
const target = sessionNameFor(db, win.id);
|
|
670
|
+
if (target) sockets.set(socketPath(config, target.session), win.id);
|
|
671
|
+
}
|
|
672
|
+
const [box, usage] = await Promise.all([
|
|
673
|
+
readBoxMemory(),
|
|
674
|
+
sessionMemory([...sockets.keys()]),
|
|
675
|
+
]);
|
|
676
|
+
const report: MemoryReport = {
|
|
677
|
+
box,
|
|
678
|
+
warning: capacityWarning(box, config.memory.high),
|
|
679
|
+
windows: {},
|
|
680
|
+
};
|
|
681
|
+
for (const [sock, mem] of usage) {
|
|
682
|
+
const id = sockets.get(sock);
|
|
683
|
+
if (id) report.windows[id] = mem;
|
|
684
|
+
}
|
|
685
|
+
return json(report);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (p === "/api/windows" && req.method === "GET") {
|
|
689
|
+
const live = config.sessions
|
|
690
|
+
? await liveSessions(config)
|
|
691
|
+
: new Set<string>();
|
|
692
|
+
// A session started by a login rather than by this server is picked up
|
|
693
|
+
// within a few seconds anyway; this saves a caller the wait.
|
|
694
|
+
await Promise.all([...live].map((s) => screens.watch(s)));
|
|
695
|
+
const label = url.searchParams.get("label");
|
|
696
|
+
return json(
|
|
697
|
+
listTargets(db)
|
|
698
|
+
.filter((t) => label === null || t.label === label)
|
|
699
|
+
.map((t) => summarise(t, live)),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// What a window shows, as plain text: the screen and the history above it.
|
|
704
|
+
const outputMatch = /^\/api\/windows\/([^/]+)\/output$/.exec(p);
|
|
705
|
+
if (outputMatch && req.method === "GET") {
|
|
706
|
+
const id = decodeURIComponent(outputMatch[1]);
|
|
707
|
+
if (!ID_PATTERN.test(id)) return badId("window");
|
|
708
|
+
if (!config.sessions)
|
|
709
|
+
return json(
|
|
710
|
+
{ error: "this server runs plain login shells (--no-sessions)" },
|
|
711
|
+
409,
|
|
712
|
+
);
|
|
713
|
+
const target = resolveTarget(db, id);
|
|
714
|
+
if (!target || target.windowId !== id)
|
|
715
|
+
return json({ error: "unknown window" }, 404);
|
|
716
|
+
const given = url.searchParams.get("lines");
|
|
717
|
+
const lines = given === null ? DEFAULT_OUTPUT_LINES : Number(given);
|
|
718
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > MAX_OUTPUT_LINES)
|
|
719
|
+
return json(
|
|
720
|
+
{
|
|
721
|
+
error: `lines must be a whole number from 1 to ${MAX_OUTPUT_LINES}`,
|
|
722
|
+
},
|
|
723
|
+
400,
|
|
724
|
+
);
|
|
725
|
+
const screen = await screens.watch(target.session);
|
|
726
|
+
if (!screen) return json({ error: "the window is not running" }, 409);
|
|
727
|
+
// Just attached to: its repaint is on the way, and blank until it lands.
|
|
728
|
+
if (screen.lastOutputAt === null)
|
|
729
|
+
await screens.drewSince(target.session, 0, FIRST_PAINT_WAIT_MS);
|
|
730
|
+
return json({
|
|
731
|
+
text: screen.text(lines),
|
|
732
|
+
state: screen.activity(),
|
|
733
|
+
lastOutputAt: screen.lastOutputAt,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Starts a window's session with nothing attached, so it is running before
|
|
738
|
+
// any browser opens it. Idempotent: a running session is left alone.
|
|
739
|
+
const startMatch = /^\/api\/windows\/([^/]+)\/(start|send)$/.exec(p);
|
|
740
|
+
if (startMatch && req.method === "POST") {
|
|
741
|
+
const id = decodeURIComponent(startMatch[1]);
|
|
742
|
+
if (!ID_PATTERN.test(id)) return badId("window");
|
|
743
|
+
if (!config.sessions)
|
|
744
|
+
return json(
|
|
745
|
+
{ error: "this server runs plain login shells (--no-sessions)" },
|
|
746
|
+
409,
|
|
747
|
+
);
|
|
748
|
+
const target = resolveTarget(db, id);
|
|
749
|
+
if (!target || target.windowId !== id)
|
|
750
|
+
return json({ error: "unknown window" }, 404);
|
|
751
|
+
const profile = target.profileId
|
|
752
|
+
? getProfile(db, target.profileId)
|
|
753
|
+
: undefined;
|
|
754
|
+
|
|
755
|
+
let text: string | undefined;
|
|
756
|
+
let keys: string[] | undefined;
|
|
757
|
+
let submit = false;
|
|
758
|
+
let force = false;
|
|
759
|
+
if (startMatch[2] === "send") {
|
|
760
|
+
const body = await readJson<SendInput>(req);
|
|
761
|
+
for (const flag of ["submit", "force"] as const) {
|
|
762
|
+
if (body[flag] !== undefined && typeof body[flag] !== "boolean")
|
|
763
|
+
return json({ error: `${flag} must be true or false` }, 400);
|
|
764
|
+
}
|
|
765
|
+
submit = body.submit === true;
|
|
766
|
+
force = body.force === true;
|
|
767
|
+
try {
|
|
768
|
+
if (body.keys !== undefined) keys = keysToPress(body);
|
|
769
|
+
else text = textToSend(body, profile);
|
|
770
|
+
} catch (err) {
|
|
771
|
+
return json({ error: describeError(err) }, 400);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
const result =
|
|
777
|
+
keys !== undefined
|
|
778
|
+
? await pressKeys(target, profile, keys)
|
|
779
|
+
: text !== undefined
|
|
780
|
+
? await deliver(target, profile, text, { submit, force })
|
|
781
|
+
: { started: await start(target, profile) };
|
|
782
|
+
return json({
|
|
783
|
+
...result,
|
|
784
|
+
window: summarise(target, new Set([target.session])),
|
|
785
|
+
});
|
|
786
|
+
} catch (err) {
|
|
787
|
+
const message = describeError(err);
|
|
788
|
+
log.warn(`${startMatch[2]} ${target.session} failed: ${message}`);
|
|
789
|
+
return json({ error: message }, 502);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
323
793
|
// How to reach this window from a real terminal. Read-only: it composes
|
|
324
794
|
// strings a person can copy, and never runs anything itself.
|
|
325
795
|
const attachMatch = /^\/api\/windows\/([^/]+)\/attach$/.exec(p);
|
|
@@ -355,6 +825,20 @@ export function createApi(deps: ApiDeps) {
|
|
|
355
825
|
return json(getWindow(db, id) ?? { ok: true });
|
|
356
826
|
}
|
|
357
827
|
|
|
828
|
+
// The answer to "bring this window back?" after its session died under it.
|
|
829
|
+
// Recorded here and acted on by the next certificate, which is what creates
|
|
830
|
+
// the session.
|
|
831
|
+
const restoreMatch = /^\/api\/windows\/([^/]+)\/restore$/.exec(p);
|
|
832
|
+
if (restoreMatch && req.method === "POST") {
|
|
833
|
+
const id = decodeURIComponent(restoreMatch[1]);
|
|
834
|
+
if (!ID_PATTERN.test(id)) return badId("window");
|
|
835
|
+
const body = await readJson<{ resume?: unknown }>(req);
|
|
836
|
+
if (typeof body.resume !== "boolean")
|
|
837
|
+
return json({ error: "resume must be true or false" }, 400);
|
|
838
|
+
const row = restoreWindow(db, id, body.resume);
|
|
839
|
+
return row ? json(row) : json({ error: "unknown window" }, 404);
|
|
840
|
+
}
|
|
841
|
+
|
|
358
842
|
const windowMatch = /^\/api\/windows\/([^/]+)$/.exec(p);
|
|
359
843
|
if (windowMatch) {
|
|
360
844
|
const id = decodeURIComponent(windowMatch[1]);
|
|
@@ -423,6 +907,7 @@ export function createApi(deps: ApiDeps) {
|
|
|
423
907
|
target.cwd,
|
|
424
908
|
config,
|
|
425
909
|
profile,
|
|
910
|
+
{ resume: takeResume(db, windowId) },
|
|
426
911
|
);
|
|
427
912
|
const cert = await ca.signUserCert({
|
|
428
913
|
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,
|