@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/memory.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// Keeping agent sessions from taking the box down (#50).
|
|
2
|
+
//
|
|
3
|
+
// An agent session costs 300 to 700 MB and grows with age, and the boxes this
|
|
4
|
+
// runs on are small. On a 4 GB VPS a handful of them push the kernel into
|
|
5
|
+
// page-cache thrash: near 100% system time, heavy block reads, almost no swap
|
|
6
|
+
// traffic. With swap present the kernel never decides to OOM-kill anything, so
|
|
7
|
+
// the box stays unusable until a person intervenes.
|
|
8
|
+
//
|
|
9
|
+
// Three defences, in the order they act:
|
|
10
|
+
//
|
|
11
|
+
// 1. Each session starts in its own systemd user scope with MemoryHigh and
|
|
12
|
+
// MemoryMax. Past High the kernel reclaims from that scope alone, so one
|
|
13
|
+
// runaway agent slows itself down instead of the box. Past Max it is
|
|
14
|
+
// OOM-killed inside its own scope.
|
|
15
|
+
// 2. The desktop shows what each window costs and what the box's memory
|
|
16
|
+
// pressure is, read from here.
|
|
17
|
+
// 3. Opening another agent when the box cannot fit one asks first.
|
|
18
|
+
//
|
|
19
|
+
// Everything here degrades to nothing rather than failing. A container has no
|
|
20
|
+
// user systemd and a Mac has no /proc; windows still open in both, without the
|
|
21
|
+
// scope and without the numbers.
|
|
22
|
+
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { access, readdir, readFile } from "node:fs/promises";
|
|
25
|
+
import { promisify } from "node:util";
|
|
26
|
+
import type { MemoryReport } from "../shared/wire.ts";
|
|
27
|
+
import { markerFromEnviron } from "./reaper.ts";
|
|
28
|
+
|
|
29
|
+
const run = promisify(execFile);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A size systemd accepts for MemoryHigh and MemoryMax.
|
|
33
|
+
*
|
|
34
|
+
* Bytes with an optional K/M/G/T suffix, a percentage of physical memory, or
|
|
35
|
+
* `infinity`. Validated strictly because the value is pasted into the shell
|
|
36
|
+
* command a certificate forces, and because systemd-run rejects a property it
|
|
37
|
+
* cannot parse, which would mean a window that never opens.
|
|
38
|
+
*/
|
|
39
|
+
const SIZE = /^(\d+(?:\.\d+)?)([KMGT]?)$/;
|
|
40
|
+
const PERCENT = /^(\d+)%$/;
|
|
41
|
+
|
|
42
|
+
/** Normalises a size, or throws with the reason. Empty means "not set". */
|
|
43
|
+
export function parseSize(raw: unknown, label: string): string | null {
|
|
44
|
+
if (raw === undefined || raw === null) return null;
|
|
45
|
+
const value = String(raw).trim().toUpperCase().replace(/B$/, "");
|
|
46
|
+
if (value === "") return null;
|
|
47
|
+
if (value === "INFINITY" || value === "NONE") return "infinity";
|
|
48
|
+
const pct = PERCENT.exec(value);
|
|
49
|
+
if (pct) {
|
|
50
|
+
const n = Number(pct[1]);
|
|
51
|
+
if (n <= 0 || n > 100)
|
|
52
|
+
throw new Error(`${label} must be between 1% and 100%`);
|
|
53
|
+
return `${pct[1]}%`;
|
|
54
|
+
}
|
|
55
|
+
const size = SIZE.exec(value);
|
|
56
|
+
if (!size || Number(size[1]) <= 0)
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${label} must look like 1500M, 2G, 40% or infinity, not ${String(raw)}`,
|
|
59
|
+
);
|
|
60
|
+
return `${size[1]}${size[2]}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const UNITS: Record<string, number> = {
|
|
64
|
+
"": 1,
|
|
65
|
+
K: 1024,
|
|
66
|
+
M: 1024 ** 2,
|
|
67
|
+
G: 1024 ** 3,
|
|
68
|
+
T: 1024 ** 4,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Bytes for a parsed size, given the box's physical memory. null is no limit. */
|
|
72
|
+
export function sizeToBytes(size: string | null, total: number): number | null {
|
|
73
|
+
if (size === null || size === "infinity") return null;
|
|
74
|
+
const pct = PERCENT.exec(size);
|
|
75
|
+
if (pct) return Math.floor((Number(pct[1]) / 100) * total);
|
|
76
|
+
const m = SIZE.exec(size);
|
|
77
|
+
return m ? Math.floor(Number(m[1]) * UNITS[m[2]]) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MemoryLimits {
|
|
81
|
+
high: string | null;
|
|
82
|
+
max: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* MemorySwapMax. Without it MemoryMax is no limit at all on a box with swap:
|
|
85
|
+
* a scope at its maximum pushes pages out to swap instead of being killed,
|
|
86
|
+
* which is the thrash all of this exists to prevent. Measured on the
|
|
87
|
+
* reference box: a 100M scope allocated 400M and carried on. Server-wide
|
|
88
|
+
* only; a profile sets the other two.
|
|
89
|
+
*/
|
|
90
|
+
swapMax: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What a session is actually started with: the profile's own value where it
|
|
95
|
+
* set one, the server's otherwise.
|
|
96
|
+
*/
|
|
97
|
+
export function effectiveLimits(
|
|
98
|
+
global: MemoryLimits,
|
|
99
|
+
profile?: { memoryHigh: string | null; memoryMax: string | null } | null,
|
|
100
|
+
): MemoryLimits {
|
|
101
|
+
return {
|
|
102
|
+
high: profile?.memoryHigh ?? global.high,
|
|
103
|
+
max: profile?.memoryMax ?? global.max,
|
|
104
|
+
swapMax: global.swapMax,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The prefix that starts a command in its own scope, or "" for no scope.
|
|
110
|
+
*
|
|
111
|
+
* `--scope` runs the command directly rather than handing it to the manager,
|
|
112
|
+
* so it inherits this environment (the session marker included) and dtach's
|
|
113
|
+
* daemonised child stays in the scope after systemd-run's own process exits.
|
|
114
|
+
* `--collect` stops a scope that ended badly from lingering as a failed unit.
|
|
115
|
+
*
|
|
116
|
+
* Returned as argv words already quoted for the shell, because session.ts
|
|
117
|
+
* splices it into a command that is quoted twice more on its way to sshd.
|
|
118
|
+
*/
|
|
119
|
+
export function scopePrefix(limits: MemoryLimits, session: string): string {
|
|
120
|
+
const props: string[] = [];
|
|
121
|
+
if (limits.high !== null) props.push(`-p MemoryHigh=${limits.high}`);
|
|
122
|
+
if (limits.max !== null) props.push(`-p MemoryMax=${limits.max}`);
|
|
123
|
+
if (limits.swapMax !== null) props.push(`-p MemorySwapMax=${limits.swapMax}`);
|
|
124
|
+
if (props.length === 0) return "";
|
|
125
|
+
// The session name is `vibe-<words>-<n>`, so it needs no quoting of its own,
|
|
126
|
+
// and the description is what `systemctl --user status` shows for the scope.
|
|
127
|
+
return `systemd-run --user --scope --quiet --collect ${props.join(" ")} --description=vibe-os:${session} -- `;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Where logind records that a user lingers. */
|
|
131
|
+
export const LINGER_DIR = "/var/lib/systemd/linger";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Shell that is true when a user systemd manager is there to take a scope, and
|
|
135
|
+
* will still be there after this login ends.
|
|
136
|
+
*
|
|
137
|
+
* The second half is the one that matters. sshd gives every login a manager
|
|
138
|
+
* through pam_systemd, but without lingering logind stops that manager when the
|
|
139
|
+
* user's last login closes, and every unit under it goes too. A scope would
|
|
140
|
+
* then end each window the moment the last browser tab did: on the reference
|
|
141
|
+
* box a session died at exactly the second the only ssh login disconnected.
|
|
142
|
+
* Unscoped, the session sits in the login's own scope, which logind leaves
|
|
143
|
+
* running. So no linger, no scope; doctor says so and setup offers the fix.
|
|
144
|
+
*
|
|
145
|
+
* A container usually has no manager at all, and then the session starts the
|
|
146
|
+
* way it always did. Checked per create, not once at startup, because both are
|
|
147
|
+
* properties of the login the force-command runs in, not of the server.
|
|
148
|
+
*/
|
|
149
|
+
export const SCOPE_PROBE = `[ -e "${LINGER_DIR}/$(id -un)" ] && command -v systemd-run > /dev/null 2>&1 && systemctl --user show-environment > /dev/null 2>&1`;
|
|
150
|
+
|
|
151
|
+
// ── what the box has ─────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export type BoxMemory = NonNullable<MemoryReport["box"]>;
|
|
154
|
+
|
|
155
|
+
/** Reads `MemTotal: 3905536 kB` lines into bytes. */
|
|
156
|
+
export function parseMeminfo(text: string): Record<string, number> {
|
|
157
|
+
const out: Record<string, number> = {};
|
|
158
|
+
for (const line of text.split("\n")) {
|
|
159
|
+
const m = /^(\w+):\s+(\d+)(?:\s+kB)?/.exec(line);
|
|
160
|
+
if (m) out[m[1]] = Number(m[2]) * (line.includes("kB") ? 1024 : 1);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function parsePressure(
|
|
166
|
+
text: string,
|
|
167
|
+
): { some10: number; full10: number } | null {
|
|
168
|
+
const avg = (kind: string) => {
|
|
169
|
+
const m = new RegExp(`^${kind} avg10=([\\d.]+)`, "m").exec(text);
|
|
170
|
+
return m ? Number(m[1]) : null;
|
|
171
|
+
};
|
|
172
|
+
const some10 = avg("some");
|
|
173
|
+
if (some10 === null) return null;
|
|
174
|
+
return { some10, full10: avg("full") ?? 0 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function readBoxMemory(): Promise<BoxMemory | null> {
|
|
178
|
+
const meminfo = await readFile("/proc/meminfo", "utf8").catch(() => null);
|
|
179
|
+
if (meminfo === null) return null;
|
|
180
|
+
const m = parseMeminfo(meminfo);
|
|
181
|
+
const pressure = await readFile("/proc/pressure/memory", "utf8")
|
|
182
|
+
.then(parsePressure)
|
|
183
|
+
.catch(() => null);
|
|
184
|
+
return {
|
|
185
|
+
total: m.MemTotal ?? 0,
|
|
186
|
+
available: m.MemAvailable ?? m.MemFree ?? 0,
|
|
187
|
+
swapTotal: m.SwapTotal ?? 0,
|
|
188
|
+
swapFree: m.SwapFree ?? 0,
|
|
189
|
+
pressure,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── what each session costs ──────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
export type SessionMemory = MemoryReport["windows"][string];
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The cgroup a process is in, from `/proc/<pid>/cgroup` on cgroup v2.
|
|
199
|
+
*
|
|
200
|
+
* Only a scope under the user manager counts as the session's own. A session
|
|
201
|
+
* started before this feature, or without user systemd, sits in the login's
|
|
202
|
+
* `session-N.scope`, which it shares with whatever else that login ran.
|
|
203
|
+
*/
|
|
204
|
+
export function ownScope(cgroupFile: string): string | null {
|
|
205
|
+
const m = /^0::(\/.*)$/m.exec(cgroupFile);
|
|
206
|
+
if (!m) return null;
|
|
207
|
+
const p = m[1].trim();
|
|
208
|
+
return /\/user@\d+\.service\/.*\.scope$/.test(p) ? p : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const readNumber = async (file: string): Promise<number | null> => {
|
|
212
|
+
const text = await readFile(file, "utf8").catch(() => null);
|
|
213
|
+
if (text === null) return null;
|
|
214
|
+
const n = Number(text.trim());
|
|
215
|
+
return Number.isFinite(n) ? n : null;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const rssBytes = (statusText: string): number => {
|
|
219
|
+
const m = /^VmRSS:\s+(\d+)\s+kB/m.exec(statusText);
|
|
220
|
+
return m ? Number(m[1]) * 1024 : 0;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Memory per session socket, for the sockets asked about.
|
|
225
|
+
*
|
|
226
|
+
* One pass over /proc. Every process that inherited the session marker is
|
|
227
|
+
* that session's, which reaches grandchildren a process tree would miss (see
|
|
228
|
+
* reaper.ts). A process owned by another user fails every read and is skipped,
|
|
229
|
+
* which is the right answer: it is not ours.
|
|
230
|
+
*/
|
|
231
|
+
export async function sessionMemory(
|
|
232
|
+
sockets: string[],
|
|
233
|
+
): Promise<Map<string, SessionMemory>> {
|
|
234
|
+
const wanted = new Set(sockets);
|
|
235
|
+
const out = new Map<string, SessionMemory>();
|
|
236
|
+
if (wanted.size === 0) return out;
|
|
237
|
+
const pids = await readdir("/proc").catch(() => [] as string[]);
|
|
238
|
+
|
|
239
|
+
const rss = new Map<string, number>();
|
|
240
|
+
const scopes = new Map<string, string>();
|
|
241
|
+
await Promise.all(
|
|
242
|
+
pids
|
|
243
|
+
.filter((name) => /^\d+$/.test(name))
|
|
244
|
+
.map(async (pid) => {
|
|
245
|
+
const environ = await readFile(`/proc/${pid}/environ`, "utf8").catch(
|
|
246
|
+
() => "",
|
|
247
|
+
);
|
|
248
|
+
const sock = markerFromEnviron(environ);
|
|
249
|
+
if (!sock || !wanted.has(sock)) return;
|
|
250
|
+
const [status, cgroup] = await Promise.all([
|
|
251
|
+
readFile(`/proc/${pid}/status`, "utf8").catch(() => ""),
|
|
252
|
+
readFile(`/proc/${pid}/cgroup`, "utf8").catch(() => ""),
|
|
253
|
+
]);
|
|
254
|
+
rss.set(sock, (rss.get(sock) ?? 0) + rssBytes(status));
|
|
255
|
+
const scope = ownScope(cgroup);
|
|
256
|
+
if (scope) scopes.set(sock, scope);
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
for (const sock of wanted) {
|
|
261
|
+
const scope = scopes.get(sock);
|
|
262
|
+
if (scope) {
|
|
263
|
+
const dir = `/sys/fs/cgroup${scope}`;
|
|
264
|
+
const current = await readNumber(`${dir}/memory.current`);
|
|
265
|
+
if (current !== null) {
|
|
266
|
+
out.set(sock, {
|
|
267
|
+
bytes: current,
|
|
268
|
+
// "max" reads as NaN through readNumber, which is "no limit".
|
|
269
|
+
high: await readNumber(`${dir}/memory.high`),
|
|
270
|
+
max: await readNumber(`${dir}/memory.max`),
|
|
271
|
+
scoped: true,
|
|
272
|
+
});
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (rss.has(sock))
|
|
277
|
+
out.set(sock, {
|
|
278
|
+
bytes: rss.get(sock) ?? 0,
|
|
279
|
+
high: null,
|
|
280
|
+
max: null,
|
|
281
|
+
scoped: false,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── will another one fit? ────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* What one more agent session is budgeted at: the top of the range measured on
|
|
291
|
+
* the reference box, where a cursor-agent reached 683 MB after four hours.
|
|
292
|
+
*/
|
|
293
|
+
export const AGENT_BUDGET = 700 * 1024 ** 2;
|
|
294
|
+
|
|
295
|
+
/** Memory left over for the kernel's page cache and everything else. */
|
|
296
|
+
const RESERVE_SHARE = 0.1;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Why opening another agent now is a bad idea, or null when it is fine.
|
|
300
|
+
*
|
|
301
|
+
* Pure, so it can be tested without a box. The budget is the agent's MemoryHigh
|
|
302
|
+
* when the profile sets an absolute one, since that is what it is allowed to
|
|
303
|
+
* grow to before being throttled, and the measured typical cost otherwise.
|
|
304
|
+
*
|
|
305
|
+
* Available memory only, not pressure. A window held at its MemoryHigh is
|
|
306
|
+
* throttled by sleeping, and the kernel counts that sleep as a memory stall:
|
|
307
|
+
* on the reference box one throttled scope put `/proc/pressure/memory` at 95%
|
|
308
|
+
* while the CPU sat 95% idle with no I/O. High pressure no longer means the box
|
|
309
|
+
* cannot take another session.
|
|
310
|
+
*/
|
|
311
|
+
export function capacityWarning(
|
|
312
|
+
box: BoxMemory | null,
|
|
313
|
+
high: string | null,
|
|
314
|
+
): string | null {
|
|
315
|
+
if (!box || box.total === 0) return null;
|
|
316
|
+
const limit = sizeToBytes(high, box.total);
|
|
317
|
+
const budget = limit !== null ? Math.min(limit, AGENT_BUDGET) : AGENT_BUDGET;
|
|
318
|
+
const reserve = box.total * RESERVE_SHARE;
|
|
319
|
+
if (box.available - budget < reserve) {
|
|
320
|
+
return `Only ${formatBytes(box.available)} of ${formatBytes(box.total)} is free, and an agent session needs about ${formatBytes(budget)}. Opening it may make the box unresponsive.`;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function formatBytes(bytes: number): string {
|
|
326
|
+
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
327
|
+
return `${Math.round(bytes / 1024 ** 2)} MB`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── what doctor and setup look at ────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
const unitActive = async (unit: string): Promise<boolean> => {
|
|
333
|
+
try {
|
|
334
|
+
const { stdout } = await run("systemctl", ["is-active", unit], {
|
|
335
|
+
timeout: 5_000,
|
|
336
|
+
});
|
|
337
|
+
return stdout.trim() === "active";
|
|
338
|
+
} catch {
|
|
339
|
+
// is-active exits non-zero for every state but active.
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const exists = (file: string) =>
|
|
345
|
+
access(file).then(
|
|
346
|
+
() => true,
|
|
347
|
+
() => false,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
export interface OomState {
|
|
351
|
+
oomd: boolean;
|
|
352
|
+
/** Running, installed but not running, or absent. */
|
|
353
|
+
earlyoom: "active" | "installed" | null;
|
|
354
|
+
swapTotal: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Whether anything will kill a process before the box thrashes.
|
|
359
|
+
*
|
|
360
|
+
* The kernel's own OOM killer acts only when reclaim has completely failed,
|
|
361
|
+
* and with swap present it practically never has: the box spends its time
|
|
362
|
+
* refaulting page cache instead. systemd-oomd and earlyoom act on pressure or
|
|
363
|
+
* free memory, early enough to matter.
|
|
364
|
+
*/
|
|
365
|
+
export async function oomState(): Promise<OomState> {
|
|
366
|
+
const [oomd, earlyoomActive, earlyoomBinary, meminfo] = await Promise.all([
|
|
367
|
+
unitActive("systemd-oomd"),
|
|
368
|
+
unitActive("earlyoom"),
|
|
369
|
+
exists("/usr/bin/earlyoom"),
|
|
370
|
+
readFile("/proc/meminfo", "utf8").catch(() => ""),
|
|
371
|
+
]);
|
|
372
|
+
return {
|
|
373
|
+
oomd,
|
|
374
|
+
earlyoom: earlyoomActive ? "active" : earlyoomBinary ? "installed" : null,
|
|
375
|
+
swapTotal: parseMeminfo(meminfo).SwapTotal ?? 0,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface ScopeState {
|
|
380
|
+
/** systemd is PID 1 and systemd-run exists. */
|
|
381
|
+
systemd: boolean;
|
|
382
|
+
/** The user's manager outlives their logins, which a scope needs. */
|
|
383
|
+
linger: boolean;
|
|
384
|
+
/**
|
|
385
|
+
* The unified hierarchy. Under cgroup v1 a user manager is never delegated
|
|
386
|
+
* memory, so a scope is created and its limits silently do nothing.
|
|
387
|
+
*/
|
|
388
|
+
cgroup2: boolean;
|
|
389
|
+
/**
|
|
390
|
+
* The user manager's delegated controllers, or null when they could not be
|
|
391
|
+
* read: no manager running for that user yet, or no cgroup v2.
|
|
392
|
+
*/
|
|
393
|
+
controllers: string[] | null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function scopeState(
|
|
397
|
+
user: string,
|
|
398
|
+
uid: number | null,
|
|
399
|
+
): Promise<ScopeState> {
|
|
400
|
+
const systemd =
|
|
401
|
+
(await exists("/run/systemd/system")) &&
|
|
402
|
+
((await exists("/usr/bin/systemd-run")) ||
|
|
403
|
+
(await exists("/bin/systemd-run")));
|
|
404
|
+
const linger = await exists(`${LINGER_DIR}/${user}`);
|
|
405
|
+
const cgroup2 = await exists("/sys/fs/cgroup/cgroup.controllers");
|
|
406
|
+
if (!systemd || uid === null)
|
|
407
|
+
return { systemd, linger, cgroup2, controllers: null };
|
|
408
|
+
const text = await readFile(
|
|
409
|
+
`/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`,
|
|
410
|
+
"utf8",
|
|
411
|
+
).catch(() => null);
|
|
412
|
+
return {
|
|
413
|
+
systemd,
|
|
414
|
+
linger,
|
|
415
|
+
cgroup2,
|
|
416
|
+
controllers: text === null ? null : text.trim().split(/\s+/),
|
|
417
|
+
};
|
|
418
|
+
}
|
package/server/profiles.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { asc, eq } from "drizzle-orm";
|
|
|
9
9
|
import { detokenize, tokenize } from "../shared/args.ts";
|
|
10
10
|
import type { Harness, Profile, ProfileInput } from "../shared/wire.ts";
|
|
11
11
|
import { type Db, newId, profiles, windows } from "./db.ts";
|
|
12
|
+
import { isHarness } from "./harness.ts";
|
|
13
|
+
import { parseSize } from "./memory.ts";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* The ten colours a profile can be.
|
|
@@ -30,18 +32,6 @@ export const PALETTE = [
|
|
|
30
32
|
"blue",
|
|
31
33
|
] as const;
|
|
32
34
|
|
|
33
|
-
/**
|
|
34
|
-
* `satisfies` rather than a plain literal: it is what makes the runtime list and
|
|
35
|
-
* the wire type fail to compile if they ever stop agreeing.
|
|
36
|
-
*/
|
|
37
|
-
export const HARNESSES = [
|
|
38
|
-
"claude",
|
|
39
|
-
"hermes",
|
|
40
|
-
"cursor",
|
|
41
|
-
"shell",
|
|
42
|
-
"custom",
|
|
43
|
-
] as const satisfies readonly Harness[];
|
|
44
|
-
|
|
45
35
|
const MAX_NAME = 40;
|
|
46
36
|
const MAX_PROMPT = 16_000;
|
|
47
37
|
const MAX_ARGS = 64;
|
|
@@ -70,6 +60,8 @@ const hydrate = (row: typeof profiles.$inferSelect): Profile => ({
|
|
|
70
60
|
command: row.command,
|
|
71
61
|
args: parseStoredArgs(row.args),
|
|
72
62
|
prompt: row.prompt,
|
|
63
|
+
memoryHigh: row.memoryHigh,
|
|
64
|
+
memoryMax: row.memoryMax,
|
|
73
65
|
position: row.position,
|
|
74
66
|
createdAt: row.createdAt,
|
|
75
67
|
});
|
|
@@ -85,9 +77,17 @@ type Fields = Partial<typeof profiles.$inferInsert>;
|
|
|
85
77
|
type CompleteFields = Fields &
|
|
86
78
|
Required<Pick<typeof profiles.$inferInsert, "name" | "color" | "harness">>;
|
|
87
79
|
|
|
88
|
-
function validate(
|
|
80
|
+
function validate(
|
|
81
|
+
input: ProfileInput,
|
|
82
|
+
partial: true,
|
|
83
|
+
current?: Harness,
|
|
84
|
+
): Fields;
|
|
89
85
|
function validate(input: ProfileInput, partial: false): CompleteFields;
|
|
90
|
-
function validate(
|
|
86
|
+
function validate(
|
|
87
|
+
input: ProfileInput,
|
|
88
|
+
partial: boolean,
|
|
89
|
+
current?: Harness,
|
|
90
|
+
): Fields {
|
|
91
91
|
const out: Fields = {};
|
|
92
92
|
|
|
93
93
|
if (input.name !== undefined || !partial) {
|
|
@@ -107,7 +107,9 @@ function validate(input: ProfileInput, partial: boolean): Fields {
|
|
|
107
107
|
|
|
108
108
|
if (input.harness !== undefined || !partial) {
|
|
109
109
|
const harness = input.harness ?? "claude";
|
|
110
|
-
if
|
|
110
|
+
// The harness a profile already has stays valid even if its spec file has
|
|
111
|
+
// since gone, so the rest of the profile can still be edited.
|
|
112
|
+
if (!isHarness(harness) && harness !== current)
|
|
111
113
|
throw new Error(`unknown harness ${harness}`);
|
|
112
114
|
out.harness = harness;
|
|
113
115
|
}
|
|
@@ -137,6 +139,13 @@ function validate(input: ProfileInput, partial: boolean): Fields {
|
|
|
137
139
|
out.prompt = prompt;
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// Checked here rather than at launch: systemd-run refuses a size it cannot
|
|
143
|
+
// read, and the window would never open.
|
|
144
|
+
if (input.memoryHigh !== undefined || !partial)
|
|
145
|
+
out.memoryHigh = parseSize(input.memoryHigh, "memory high");
|
|
146
|
+
if (input.memoryMax !== undefined || !partial)
|
|
147
|
+
out.memoryMax = parseSize(input.memoryMax, "memory max");
|
|
148
|
+
|
|
140
149
|
// A custom harness with no command would launch nothing at all, silently.
|
|
141
150
|
const harness = out.harness ?? input.harness;
|
|
142
151
|
if (harness === "custom" && out.command === null)
|
|
@@ -179,6 +188,8 @@ export function createProfile(
|
|
|
179
188
|
command: fields.command ?? null,
|
|
180
189
|
args: fields.args ?? "[]",
|
|
181
190
|
prompt: fields.prompt ?? "",
|
|
191
|
+
memoryHigh: fields.memoryHigh ?? null,
|
|
192
|
+
memoryMax: fields.memoryMax ?? null,
|
|
182
193
|
position: input.position ?? siblings.length,
|
|
183
194
|
createdAt: Date.now(),
|
|
184
195
|
})
|
|
@@ -202,6 +213,7 @@ export function updateProfile(
|
|
|
202
213
|
const fields = validate(
|
|
203
214
|
{ harness: current.harness, command: current.command, ...input },
|
|
204
215
|
true,
|
|
216
|
+
current.harness,
|
|
205
217
|
);
|
|
206
218
|
if (Object.keys(fields).length > 0) {
|
|
207
219
|
db.update(profiles).set(fields).where(eq(profiles.id, id)).run();
|
package/server/projects.ts
CHANGED
|
@@ -574,17 +574,63 @@ async function baseForWorkspace(
|
|
|
574
574
|
return { ref, warning: null };
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Where a new workspace's branch starts when the caller named a branch.
|
|
579
|
+
*
|
|
580
|
+
* Origin's copy wins over a local branch of the same name, after a fetch, for
|
|
581
|
+
* the same reason the default does: a workspace should start from the current
|
|
582
|
+
* state of the work. A local branch or a commit is the fallback. Unlike the
|
|
583
|
+
* default, a name that resolves to nothing is an error, because the caller
|
|
584
|
+
* asked for something specific.
|
|
585
|
+
*/
|
|
586
|
+
async function baseFromRef(
|
|
587
|
+
repo: string,
|
|
588
|
+
projectName: string,
|
|
589
|
+
from: string,
|
|
590
|
+
): Promise<{ ref: string; warning: string | null }> {
|
|
591
|
+
if (from.startsWith("-") || /\s/.test(from))
|
|
592
|
+
throw new Error(`invalid branch ${JSON.stringify(from)}`);
|
|
593
|
+
|
|
594
|
+
let warning: string | null = null;
|
|
595
|
+
const origin = await hasOrigin(repo);
|
|
596
|
+
if (origin) {
|
|
597
|
+
try {
|
|
598
|
+
await git(repo, ["fetch", "origin"], FETCH_TIMEOUT_MS);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
const failure = gitReason(err);
|
|
601
|
+
log.warn(`could not fetch origin for ${projectName}: ${failure}`);
|
|
602
|
+
warning = `Could not fetch origin (${failure}). This branch starts from ${from} as it was at the last fetch.`;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const candidates = origin ? [`origin/${from}`, from] : [from];
|
|
607
|
+
for (const ref of candidates) {
|
|
608
|
+
const found = await git(repo, [
|
|
609
|
+
"rev-parse",
|
|
610
|
+
"--verify",
|
|
611
|
+
"--quiet",
|
|
612
|
+
`${ref}^{commit}`,
|
|
613
|
+
]).then(
|
|
614
|
+
() => true,
|
|
615
|
+
() => false,
|
|
616
|
+
);
|
|
617
|
+
if (found) return { ref, warning };
|
|
618
|
+
}
|
|
619
|
+
throw new Error(`no branch or commit called ${from} in ${projectName}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
577
622
|
/**
|
|
578
623
|
* Creates a git worktree and records it as a workspace.
|
|
579
624
|
*
|
|
580
625
|
* The worktree gets its own branch named after the workspace, branched from a
|
|
581
|
-
* freshly fetched origin — see `baseForWorkspace
|
|
626
|
+
* freshly fetched origin — see `baseForWorkspace` — or from `from` when given.
|
|
582
627
|
*/
|
|
583
628
|
export async function createWorkspace(
|
|
584
629
|
db: Db,
|
|
585
630
|
workspaceRoot: string,
|
|
586
631
|
projectId: string,
|
|
587
632
|
requested?: string,
|
|
633
|
+
from?: string,
|
|
588
634
|
): Promise<{
|
|
589
635
|
id: string;
|
|
590
636
|
name: string;
|
|
@@ -612,11 +658,14 @@ export async function createWorkspace(
|
|
|
612
658
|
} while (taken.has(name));
|
|
613
659
|
}
|
|
614
660
|
|
|
661
|
+
// Resolved before anything is made on disk, so a bad --from leaves nothing.
|
|
662
|
+
const base = from?.trim()
|
|
663
|
+
? await baseFromRef(project.path, project.name, from.trim())
|
|
664
|
+
: await baseForWorkspace(project.path, project.name);
|
|
665
|
+
|
|
615
666
|
const target = path.join(workspaceRoot, WORKTREE_DIR, project.name, name);
|
|
616
667
|
await mkdir(path.dirname(target), { recursive: true });
|
|
617
668
|
|
|
618
|
-
const base = await baseForWorkspace(project.path, project.name);
|
|
619
|
-
|
|
620
669
|
// -b creates the branch; git refuses if it already exists, which is the
|
|
621
670
|
// behaviour we want rather than silently reusing someone else's work.
|
|
622
671
|
//
|
package/server/reaper.ts
CHANGED
|
@@ -106,7 +106,7 @@ export function parseEtime(value: string): number {
|
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
async function pgrep(args: string[]): Promise<number[]> {
|
|
109
|
+
export async function pgrep(args: string[]): Promise<number[]> {
|
|
110
110
|
try {
|
|
111
111
|
const { stdout } = await run("pgrep", args, { timeout: 10_000 });
|
|
112
112
|
return stdout
|
|
@@ -174,7 +174,7 @@ export function classifyLeak(
|
|
|
174
174
|
if (p.sock && gone(p.sock))
|
|
175
175
|
return { pid: p.pid, args: p.args, reason: "its session socket is gone" };
|
|
176
176
|
|
|
177
|
-
const dtach = p.args.match(/^dtach -[nap] (\S+\.sock)\b/);
|
|
177
|
+
const dtach = p.args.match(/^(?:\S*\/)?dtach -[nap] (\S+\.sock)\b/);
|
|
178
178
|
if (dtach && gone(dtach[1]))
|
|
179
179
|
return { pid: p.pid, args: p.args, reason: "its session socket is gone" };
|
|
180
180
|
|
|
@@ -225,7 +225,9 @@ export async function findLeaks(config: Config): Promise<Leak[]> {
|
|
|
225
225
|
export async function sessionProcesses(sock: string): Promise<number[]> {
|
|
226
226
|
const pids = new Set<number>();
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
// A scoped session's master is `/usr/bin/dtach`: systemd-run execs the
|
|
229
|
+
// resolved path, so the name alone does not anchor at the start.
|
|
230
|
+
const masters = await pgrep(["-f", `^([^ ]*/)?dtach -n ${sock}`]);
|
|
229
231
|
for (const pid of masters) pids.add(pid);
|
|
230
232
|
|
|
231
233
|
const leaders = new Set<number>();
|