@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
package/server/client.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// The CLI verbs that drive a running server: `ls`, `workspace`, `open`,
|
|
2
|
+
// `output`, `close`.
|
|
3
|
+
//
|
|
4
|
+
// Every one of them goes through the HTTP API rather than the database, so the
|
|
5
|
+
// same command works on the box and from anywhere else with `--url` and
|
|
6
|
+
// `--token`, and does exactly what the API does for any other client.
|
|
7
|
+
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import type {
|
|
10
|
+
Activity,
|
|
11
|
+
Profile,
|
|
12
|
+
SendResult,
|
|
13
|
+
WindowSummary,
|
|
14
|
+
WorkspaceSummary,
|
|
15
|
+
} from "../shared/wire.ts";
|
|
16
|
+
import { loadPersisted, type RawOptions } from "./config.ts";
|
|
17
|
+
import { color, describeError, log } from "./log.ts";
|
|
18
|
+
|
|
19
|
+
export interface Remote {
|
|
20
|
+
url: string;
|
|
21
|
+
token: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ProjectRow {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
path: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where the server is, and the token to show it.
|
|
32
|
+
*
|
|
33
|
+
* On the box both come from the state dir, where `start` records the address
|
|
34
|
+
* it bound and the token it requires. `--port` overrides the address. With `--url` the remembered token is
|
|
35
|
+
* not used, since it belongs to this box and not to the one named.
|
|
36
|
+
*/
|
|
37
|
+
export async function resolveRemote(
|
|
38
|
+
values: RawOptions,
|
|
39
|
+
local: { port: number; stateDir: string },
|
|
40
|
+
): Promise<Remote> {
|
|
41
|
+
const given = (values.url as string | undefined) ?? process.env.VIBE_OS_URL;
|
|
42
|
+
const persisted = given ? {} : await loadPersisted(local.stateDir);
|
|
43
|
+
const recorded =
|
|
44
|
+
typeof persisted.localUrl === "string" ? persisted.localUrl : null;
|
|
45
|
+
const url = (
|
|
46
|
+
given ??
|
|
47
|
+
(values.port === undefined && recorded
|
|
48
|
+
? recorded
|
|
49
|
+
: `http://127.0.0.1:${local.port}`)
|
|
50
|
+
).replace(/\/+$/, "");
|
|
51
|
+
if (!/^https?:\/\//.test(url))
|
|
52
|
+
throw new Error(`--url must start with http:// or https://, not ${url}`);
|
|
53
|
+
|
|
54
|
+
let token: string | null = null;
|
|
55
|
+
if (typeof values.token === "string" && values.token !== "")
|
|
56
|
+
token = values.token;
|
|
57
|
+
else if (process.env.VIBE_OS_TOKEN) token = process.env.VIBE_OS_TOKEN;
|
|
58
|
+
else if (!given) token = persisted.token ?? null;
|
|
59
|
+
return { url, token };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Thrown for an answer the server gave, as opposed to not reaching it. */
|
|
63
|
+
export class ApiError extends Error {
|
|
64
|
+
constructor(
|
|
65
|
+
message: string,
|
|
66
|
+
readonly status: number,
|
|
67
|
+
readonly body: Record<string, unknown>,
|
|
68
|
+
) {
|
|
69
|
+
super(message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function request<T>(
|
|
74
|
+
remote: Remote,
|
|
75
|
+
method: string,
|
|
76
|
+
route: string,
|
|
77
|
+
body?: unknown,
|
|
78
|
+
): Promise<T> {
|
|
79
|
+
const headers: Record<string, string> = {};
|
|
80
|
+
if (remote.token) headers.authorization = `Bearer ${remote.token}`;
|
|
81
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
82
|
+
|
|
83
|
+
let res: Response;
|
|
84
|
+
try {
|
|
85
|
+
res = await fetch(`${remote.url}${route}`, {
|
|
86
|
+
method,
|
|
87
|
+
headers,
|
|
88
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
89
|
+
redirect: "manual",
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`could not reach vibe-os at ${remote.url}: ${describeError(err)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const text = await res.text();
|
|
98
|
+
let parsed: unknown;
|
|
99
|
+
try {
|
|
100
|
+
parsed = text ? JSON.parse(text) : {};
|
|
101
|
+
} catch {
|
|
102
|
+
parsed = { error: text.trim() || res.statusText };
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const detail = (parsed as { error?: unknown }).error;
|
|
106
|
+
const message =
|
|
107
|
+
res.status === 401
|
|
108
|
+
? `${remote.url} wants a token: pass --token or set VIBE_OS_TOKEN`
|
|
109
|
+
: typeof detail === "string"
|
|
110
|
+
? detail
|
|
111
|
+
: `${method} ${route} answered ${res.status}`;
|
|
112
|
+
throw new ApiError(message, res.status, parsed as Record<string, unknown>);
|
|
113
|
+
}
|
|
114
|
+
return parsed as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── finding things by name ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** A project by id, name or path. */
|
|
120
|
+
export function findProject(projects: ProjectRow[], name: string): ProjectRow {
|
|
121
|
+
const found = projects.find(
|
|
122
|
+
(p) => p.id === name || p.name === name || p.path === name,
|
|
123
|
+
);
|
|
124
|
+
if (found) return found;
|
|
125
|
+
throw new Error(
|
|
126
|
+
projects.length === 0
|
|
127
|
+
? `no project called ${name}: this box has no projects yet`
|
|
128
|
+
: `no project called ${name}. Projects: ${projects.map((p) => p.name).join(", ")}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A workspace by id, name, or `project/name`.
|
|
134
|
+
*
|
|
135
|
+
* Names are three random words, so two projects sharing one is unlikely, and
|
|
136
|
+
* an error that asks for the project is better than guessing.
|
|
137
|
+
*/
|
|
138
|
+
export function findWorkspace(
|
|
139
|
+
all: WorkspaceSummary[],
|
|
140
|
+
name: string,
|
|
141
|
+
): WorkspaceSummary {
|
|
142
|
+
const byId = all.find((w) => w.id === name);
|
|
143
|
+
if (byId) return byId;
|
|
144
|
+
const slash = name.lastIndexOf("/");
|
|
145
|
+
const matches =
|
|
146
|
+
slash === -1
|
|
147
|
+
? all.filter((w) => w.name === name)
|
|
148
|
+
: all.filter(
|
|
149
|
+
(w) =>
|
|
150
|
+
w.name === name.slice(slash + 1) &&
|
|
151
|
+
w.project === name.slice(0, slash),
|
|
152
|
+
);
|
|
153
|
+
if (matches.length === 1) return matches[0];
|
|
154
|
+
if (matches.length > 1)
|
|
155
|
+
throw new Error(
|
|
156
|
+
`${name} is in more than one project: ${matches.map((w) => `${w.project}/${w.name}`).join(", ")}`,
|
|
157
|
+
);
|
|
158
|
+
throw new Error(`no workspace called ${name}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A profile by id or by name, ignoring case. */
|
|
162
|
+
export function findProfile(profiles: Profile[], name: string): Profile {
|
|
163
|
+
const lower = name.toLowerCase();
|
|
164
|
+
const found = profiles.find(
|
|
165
|
+
(p) => p.id === name || p.name.toLowerCase() === lower,
|
|
166
|
+
);
|
|
167
|
+
if (found) return found;
|
|
168
|
+
throw new Error(
|
|
169
|
+
profiles.length === 0
|
|
170
|
+
? `no profile called ${name}: this project has no profiles`
|
|
171
|
+
: `no profile called ${name}. Profiles: ${profiles.map((p) => p.name).join(", ")}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A window by id or by ref, with or without the `vibe-` prefix. */
|
|
176
|
+
export function findWindow(
|
|
177
|
+
windows: WindowSummary[],
|
|
178
|
+
name: string,
|
|
179
|
+
): WindowSummary {
|
|
180
|
+
const ref = name.replace(/^vibe-/, "");
|
|
181
|
+
const found = windows.find((w) => w.id === name || w.ref === ref);
|
|
182
|
+
if (found) return found;
|
|
183
|
+
throw new Error(`no window called ${name}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `--blank key=value`, repeatable, into an object. */
|
|
187
|
+
export function parseBlanks(pairs: string[]): Record<string, string> {
|
|
188
|
+
const out: Record<string, string> = {};
|
|
189
|
+
for (const pair of pairs) {
|
|
190
|
+
const at = pair.indexOf("=");
|
|
191
|
+
if (at <= 0) throw new Error(`--blank wants label=value, not ${pair}`);
|
|
192
|
+
out[pair.slice(0, at).trim()] = pair.slice(at + 1);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── the verbs ────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
const print = (values: RawOptions, data: unknown, human: () => void) => {
|
|
200
|
+
if (values.json) console.log(JSON.stringify(data, null, 2));
|
|
201
|
+
else human();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** A window's state, padded to one width so the columns line up. */
|
|
205
|
+
function stateLabel(state: Activity | null): string {
|
|
206
|
+
const text = (state ?? "stopped").padEnd(8);
|
|
207
|
+
if (state === "busy") return color.green(text);
|
|
208
|
+
if (state === "question") return color.yellow(text);
|
|
209
|
+
if (state === "idle") return text;
|
|
210
|
+
return color.dim(text);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function ls(remote: Remote, values: RawOptions): Promise<number> {
|
|
214
|
+
const windows = await request<WindowSummary[]>(remote, "GET", "/api/windows");
|
|
215
|
+
print(values, windows, () => {
|
|
216
|
+
if (windows.length === 0) {
|
|
217
|
+
console.log("no windows");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
for (const w of windows) {
|
|
221
|
+
const role = w.role ?? color.dim("terminal");
|
|
222
|
+
console.log(
|
|
223
|
+
`${stateLabel(w.state)} ${w.ref.padEnd(30)} ${role.padEnd(20)} ${color.dim(`${w.project}/${w.workspace}`)}${w.label ? ` ${w.label}` : ""}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function workspace(
|
|
231
|
+
remote: Remote,
|
|
232
|
+
values: RawOptions,
|
|
233
|
+
args: string[],
|
|
234
|
+
): Promise<number> {
|
|
235
|
+
const [verb, projectName] = args;
|
|
236
|
+
if (verb === "ls") {
|
|
237
|
+
const all = await request<WorkspaceSummary[]>(
|
|
238
|
+
remote,
|
|
239
|
+
"GET",
|
|
240
|
+
"/api/workspaces",
|
|
241
|
+
);
|
|
242
|
+
const shown = projectName
|
|
243
|
+
? all.filter(
|
|
244
|
+
(w) => w.project === projectName || w.projectId === projectName,
|
|
245
|
+
)
|
|
246
|
+
: all;
|
|
247
|
+
print(values, shown, () => {
|
|
248
|
+
for (const w of shown)
|
|
249
|
+
console.log(`${w.project}/${w.name} ${color.dim(w.path)}`);
|
|
250
|
+
});
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
if (verb !== "new" || !projectName) {
|
|
254
|
+
log.error("usage: vibe-os workspace new <project> [--from <branch>]");
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const project = findProject(
|
|
259
|
+
await request<ProjectRow[]>(remote, "GET", "/api/projects"),
|
|
260
|
+
projectName,
|
|
261
|
+
);
|
|
262
|
+
const created = await request<{
|
|
263
|
+
id: string;
|
|
264
|
+
name: string;
|
|
265
|
+
path: string;
|
|
266
|
+
base: string;
|
|
267
|
+
warning: string | null;
|
|
268
|
+
}>(
|
|
269
|
+
remote,
|
|
270
|
+
"POST",
|
|
271
|
+
`/api/projects/${encodeURIComponent(project.id)}/workspaces`,
|
|
272
|
+
{ name: values.name, from: values.from },
|
|
273
|
+
);
|
|
274
|
+
if (created.warning) log.warn(created.warning);
|
|
275
|
+
print(values, created, () => {
|
|
276
|
+
console.log(`${project.name}/${created.name}`);
|
|
277
|
+
console.log(color.dim(` ${created.path}, from ${created.base}`));
|
|
278
|
+
});
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `--prompt -` reads the prompt from stdin, for text too long for argv. */
|
|
283
|
+
async function promptText(values: RawOptions): Promise<string | undefined> {
|
|
284
|
+
if (values.prompt === undefined) return undefined;
|
|
285
|
+
const given = String(values.prompt);
|
|
286
|
+
if (given !== "-") return given;
|
|
287
|
+
return await readFile("/dev/stdin", "utf8");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function open(
|
|
291
|
+
remote: Remote,
|
|
292
|
+
values: RawOptions,
|
|
293
|
+
name: string | undefined,
|
|
294
|
+
): Promise<number> {
|
|
295
|
+
if (!name) {
|
|
296
|
+
log.error(
|
|
297
|
+
"usage: vibe-os open <workspace> [--profile <name>] [--prompt <text>] [--blank label=value]",
|
|
298
|
+
);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
const text = await promptText(values);
|
|
302
|
+
const blanks = parseBlanks(values.blank ?? []);
|
|
303
|
+
const hasBlanks = Object.keys(blanks).length > 0;
|
|
304
|
+
if (text !== undefined && hasBlanks)
|
|
305
|
+
throw new Error(
|
|
306
|
+
"--blank fills the profile's prompt, and --prompt replaces it: use one",
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const ws = findWorkspace(
|
|
310
|
+
await request<WorkspaceSummary[]>(remote, "GET", "/api/workspaces"),
|
|
311
|
+
name,
|
|
312
|
+
);
|
|
313
|
+
let profileId: string | undefined;
|
|
314
|
+
if (values.profile) {
|
|
315
|
+
profileId = findProfile(
|
|
316
|
+
await request<Profile[]>(
|
|
317
|
+
remote,
|
|
318
|
+
"GET",
|
|
319
|
+
`/api/projects/${encodeURIComponent(ws.projectId)}/profiles`,
|
|
320
|
+
),
|
|
321
|
+
String(values.profile),
|
|
322
|
+
).id;
|
|
323
|
+
} else if (hasBlanks) {
|
|
324
|
+
throw new Error("--blank needs --profile, whose prompt it fills");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// One call: the server creates the window, starts it, waits for it to be
|
|
328
|
+
// ready and pastes, and takes the window back out if it cannot start.
|
|
329
|
+
const send = text !== undefined || hasBlanks;
|
|
330
|
+
type Opened = Partial<SendResult> & { window: WindowSummary };
|
|
331
|
+
let result: Opened;
|
|
332
|
+
try {
|
|
333
|
+
result = await request<Opened>(
|
|
334
|
+
remote,
|
|
335
|
+
"POST",
|
|
336
|
+
`/api/workspaces/${encodeURIComponent(ws.id)}/windows`,
|
|
337
|
+
{
|
|
338
|
+
profileId,
|
|
339
|
+
force: values.force === true,
|
|
340
|
+
label: values.label,
|
|
341
|
+
start: true,
|
|
342
|
+
prompt: text,
|
|
343
|
+
blanks: hasBlanks ? blanks : undefined,
|
|
344
|
+
submit: send ? values.submit === true : undefined,
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
if (err instanceof ApiError && err.body.capacity === true)
|
|
349
|
+
throw new Error(`${err.message}\nPass --force to open it anyway.`);
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { window } = result;
|
|
354
|
+
print(values, values.json ? result : window, () => {
|
|
355
|
+
console.log(window.ref);
|
|
356
|
+
console.log(
|
|
357
|
+
color.dim(
|
|
358
|
+
` ${window.role ?? "terminal"} in ${window.project}/${window.workspace}${send && result.delivered ? (values.submit ? ", prompt submitted" : ", prompt pasted") : ""}`,
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
if (send && !result.delivered) {
|
|
363
|
+
log.error(
|
|
364
|
+
`${window.ref} is open, but the prompt was not sent: ${result.reason ?? result.state}.` +
|
|
365
|
+
` See it with: vibe-os output ${window.ref}`,
|
|
366
|
+
);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function output(
|
|
373
|
+
remote: Remote,
|
|
374
|
+
values: RawOptions,
|
|
375
|
+
name: string | undefined,
|
|
376
|
+
): Promise<number> {
|
|
377
|
+
if (!name) {
|
|
378
|
+
log.error("usage: vibe-os output <window> [--lines <n>]");
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
const win = findWindow(
|
|
382
|
+
await request<WindowSummary[]>(remote, "GET", "/api/windows"),
|
|
383
|
+
name,
|
|
384
|
+
);
|
|
385
|
+
const lines =
|
|
386
|
+
values.lines === undefined
|
|
387
|
+
? ""
|
|
388
|
+
: `?lines=${encodeURIComponent(String(values.lines))}`;
|
|
389
|
+
const out = await request<{ text: string; state: Activity }>(
|
|
390
|
+
remote,
|
|
391
|
+
"GET",
|
|
392
|
+
`/api/windows/${encodeURIComponent(win.id)}/output${lines}`,
|
|
393
|
+
);
|
|
394
|
+
print(values, out, () => console.log(out.text));
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function close(
|
|
399
|
+
remote: Remote,
|
|
400
|
+
name: string | undefined,
|
|
401
|
+
): Promise<number> {
|
|
402
|
+
if (!name) {
|
|
403
|
+
log.error("usage: vibe-os close <window>");
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
const win = findWindow(
|
|
407
|
+
await request<WindowSummary[]>(remote, "GET", "/api/windows"),
|
|
408
|
+
name,
|
|
409
|
+
);
|
|
410
|
+
await request(remote, "DELETE", `/api/windows/${encodeURIComponent(win.id)}`);
|
|
411
|
+
console.log(`closed ${win.ref}`);
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Runs one client verb. Errors are printed here, as one line. */
|
|
416
|
+
export async function runClient(
|
|
417
|
+
command: "ls" | "workspace" | "open" | "output" | "close",
|
|
418
|
+
args: string[],
|
|
419
|
+
values: RawOptions,
|
|
420
|
+
local: { port: number; stateDir: string },
|
|
421
|
+
): Promise<number> {
|
|
422
|
+
try {
|
|
423
|
+
const remote = await resolveRemote(values, local);
|
|
424
|
+
switch (command) {
|
|
425
|
+
case "ls":
|
|
426
|
+
return await ls(remote, values);
|
|
427
|
+
case "workspace":
|
|
428
|
+
return await workspace(remote, values, args);
|
|
429
|
+
case "open":
|
|
430
|
+
return await open(remote, values, args[0]);
|
|
431
|
+
case "output":
|
|
432
|
+
return await output(remote, values, args[0]);
|
|
433
|
+
case "close":
|
|
434
|
+
return await close(remote, args[0]);
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
log.error(describeError(err));
|
|
438
|
+
return 1;
|
|
439
|
+
}
|
|
440
|
+
}
|
package/server/config.ts
CHANGED
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { parseArgs, promisify } from "node:util";
|
|
8
|
+
import { type MemoryLimits, parseSize } from "./memory.ts";
|
|
8
9
|
|
|
9
10
|
const run = promisify(execFile);
|
|
10
11
|
|
|
@@ -63,6 +64,12 @@ export interface Config {
|
|
|
63
64
|
* from whether dtach is installed, unless forced.
|
|
64
65
|
*/
|
|
65
66
|
sessions: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The MemoryHigh and MemoryMax each window's scope gets unless its profile
|
|
69
|
+
* says otherwise, and the MemorySwapMax every window gets. All null
|
|
70
|
+
* (`--no-memory-limit`) starts windows without a scope at all.
|
|
71
|
+
*/
|
|
72
|
+
memory: MemoryLimits;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -90,6 +97,10 @@ export const OPTION_SPEC = {
|
|
|
90
97
|
"theme-color": { type: "string" as const },
|
|
91
98
|
sessions: { type: "boolean" as const },
|
|
92
99
|
"no-sessions": { type: "boolean" as const },
|
|
100
|
+
"memory-high": { type: "string" as const },
|
|
101
|
+
"memory-max": { type: "string" as const },
|
|
102
|
+
"memory-swap-max": { type: "string" as const },
|
|
103
|
+
"no-memory-limit": { type: "boolean" as const },
|
|
93
104
|
// doctor only, same reasoning as the install-browser block below: parseArgs
|
|
94
105
|
// is strict, so a flag it has never heard of is an error.
|
|
95
106
|
reap: { type: "boolean" as const },
|
|
@@ -108,13 +119,29 @@ export const OPTION_SPEC = {
|
|
|
108
119
|
"no-restart": { type: "boolean" as const },
|
|
109
120
|
"vnc-password": { type: "string" as const },
|
|
110
121
|
"no-vnc-password": { type: "boolean" as const },
|
|
122
|
+
// ls, workspace, open, output and close: the client verbs. `--token` is shared with
|
|
123
|
+
// start, where it is the token to require rather than the one to send.
|
|
124
|
+
url: { type: "string" as const },
|
|
125
|
+
json: { type: "boolean" as const },
|
|
126
|
+
name: { type: "string" as const },
|
|
127
|
+
from: { type: "string" as const },
|
|
128
|
+
profile: { type: "string" as const },
|
|
129
|
+
prompt: { type: "string" as const },
|
|
130
|
+
blank: { type: "string" as const, multiple: true },
|
|
131
|
+
submit: { type: "boolean" as const },
|
|
132
|
+
force: { type: "boolean" as const },
|
|
133
|
+
label: { type: "string" as const },
|
|
134
|
+
lines: { type: "string" as const },
|
|
111
135
|
help: { type: "boolean" as const, short: "h" },
|
|
112
136
|
version: { type: "boolean" as const, short: "v" },
|
|
113
137
|
};
|
|
114
138
|
|
|
115
139
|
export type RawOptions = Partial<
|
|
116
|
-
Record<
|
|
117
|
-
|
|
140
|
+
Record<
|
|
141
|
+
Exclude<keyof typeof OPTION_SPEC, "allowed-host" | "blank">,
|
|
142
|
+
string | boolean
|
|
143
|
+
>
|
|
144
|
+
> & { "allowed-host"?: string[]; blank?: string[] };
|
|
118
145
|
|
|
119
146
|
/**
|
|
120
147
|
* Flags that mean "generate one" when given no value.
|
|
@@ -225,6 +252,40 @@ function userName(value: unknown, label: string): string {
|
|
|
225
252
|
return name;
|
|
226
253
|
}
|
|
227
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Defaults that leave room for one runaway on a small box and change nothing
|
|
257
|
+
* on a large one: on 4 GB a window is throttled at 1.6 GB and killed at 2 GB.
|
|
258
|
+
*/
|
|
259
|
+
export const DEFAULT_MEMORY_HIGH = "40%";
|
|
260
|
+
export const DEFAULT_MEMORY_MAX = "50%";
|
|
261
|
+
/** Of RAM, as systemd reads it: about 400 MB of swap per window on 4 GB. */
|
|
262
|
+
export const DEFAULT_MEMORY_SWAP_MAX = "10%";
|
|
263
|
+
|
|
264
|
+
function memoryLimits(values: RawOptions): MemoryLimits {
|
|
265
|
+
if (flag(values["no-memory-limit"] ?? process.env.VIBE_OS_NO_MEMORY_LIMIT))
|
|
266
|
+
return { high: null, max: null, swapMax: null };
|
|
267
|
+
return {
|
|
268
|
+
high: parseSize(
|
|
269
|
+
values["memory-high"] ??
|
|
270
|
+
process.env.VIBE_OS_MEMORY_HIGH ??
|
|
271
|
+
DEFAULT_MEMORY_HIGH,
|
|
272
|
+
"--memory-high",
|
|
273
|
+
),
|
|
274
|
+
max: parseSize(
|
|
275
|
+
values["memory-max"] ??
|
|
276
|
+
process.env.VIBE_OS_MEMORY_MAX ??
|
|
277
|
+
DEFAULT_MEMORY_MAX,
|
|
278
|
+
"--memory-max",
|
|
279
|
+
),
|
|
280
|
+
swapMax: parseSize(
|
|
281
|
+
values["memory-swap-max"] ??
|
|
282
|
+
process.env.VIBE_OS_MEMORY_SWAP_MAX ??
|
|
283
|
+
DEFAULT_MEMORY_SWAP_MAX,
|
|
284
|
+
"--memory-swap-max",
|
|
285
|
+
),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
228
289
|
function num(value: unknown, fallback: number, label: string): number {
|
|
229
290
|
if (value === undefined || value === "") return fallback;
|
|
230
291
|
const n = Number(value);
|
|
@@ -381,5 +442,6 @@ export async function resolveConfig(values: RawOptions): Promise<Config> {
|
|
|
381
442
|
"cert-ttl",
|
|
382
443
|
),
|
|
383
444
|
sessions,
|
|
445
|
+
memory: memoryLimits(values),
|
|
384
446
|
};
|
|
385
447
|
}
|
package/server/db.ts
CHANGED
|
@@ -62,6 +62,9 @@ export const profiles = sqliteTable(
|
|
|
62
62
|
/** JSON array of argv tokens, so each can be quoted on its own. */
|
|
63
63
|
args: text("args").notNull().default("[]"),
|
|
64
64
|
prompt: text("prompt").notNull().default(""),
|
|
65
|
+
/** systemd sizes for this role's scope, or null to use the server's. */
|
|
66
|
+
memoryHigh: text("memory_high"),
|
|
67
|
+
memoryMax: text("memory_max"),
|
|
65
68
|
/** Order in the rail. */
|
|
66
69
|
position: integer("position").notNull(),
|
|
67
70
|
createdAt: integer("created_at").notNull(),
|
|
@@ -98,6 +101,14 @@ export const windows = sqliteTable(
|
|
|
98
101
|
handoffAt: integer("handoff_at"),
|
|
99
102
|
/** Set once a terminal has actually attached to this window's session. */
|
|
100
103
|
handoffSeen: integer("handoff_seen").notNull().default(0),
|
|
104
|
+
/**
|
|
105
|
+
* 'ask' when the session died without its harness exiting (a reboot, a
|
|
106
|
+
* kill), so the desktop offers to bring it back rather than starting one.
|
|
107
|
+
* 'resume' once the answer was yes, until the next session is created.
|
|
108
|
+
*/
|
|
109
|
+
restore: text("restore"),
|
|
110
|
+
/** Free text the caller tagged the window with, so it can find it again. */
|
|
111
|
+
label: text("label"),
|
|
101
112
|
createdAt: integer("created_at").notNull(),
|
|
102
113
|
},
|
|
103
114
|
(t) => [index("windows_workspace_idx").on(t.workspaceId)],
|
|
@@ -131,6 +142,8 @@ const DDL = [
|
|
|
131
142
|
command TEXT,
|
|
132
143
|
args TEXT NOT NULL DEFAULT '[]',
|
|
133
144
|
prompt TEXT NOT NULL DEFAULT '',
|
|
145
|
+
memory_high TEXT,
|
|
146
|
+
memory_max TEXT,
|
|
134
147
|
position INTEGER NOT NULL,
|
|
135
148
|
created_at INTEGER NOT NULL
|
|
136
149
|
)`,
|
|
@@ -150,6 +163,8 @@ const DDL = [
|
|
|
150
163
|
handoff TEXT,
|
|
151
164
|
handoff_at INTEGER,
|
|
152
165
|
handoff_seen INTEGER NOT NULL DEFAULT 0,
|
|
166
|
+
restore TEXT,
|
|
167
|
+
label TEXT,
|
|
153
168
|
created_at INTEGER NOT NULL
|
|
154
169
|
)`,
|
|
155
170
|
`CREATE INDEX IF NOT EXISTS windows_workspace_idx ON windows (workspace_id)`,
|
|
@@ -173,6 +188,10 @@ const ADDED_COLUMNS: [table: string, column: string, decl: string][] = [
|
|
|
173
188
|
["windows", "handoff", "TEXT"],
|
|
174
189
|
["windows", "handoff_at", "INTEGER"],
|
|
175
190
|
["windows", "handoff_seen", "INTEGER NOT NULL DEFAULT 0"],
|
|
191
|
+
["windows", "restore", "TEXT"],
|
|
192
|
+
["windows", "label", "TEXT"],
|
|
193
|
+
["profiles", "memory_high", "TEXT"],
|
|
194
|
+
["profiles", "memory_max", "TEXT"],
|
|
176
195
|
];
|
|
177
196
|
|
|
178
197
|
function ensureColumn(
|