@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.
Files changed (45) hide show
  1. package/README.md +19 -847
  2. package/THIRD_PARTY_NOTICES.md +28 -0
  3. package/dist/web/assets/index-82MhMOB4.css +1 -0
  4. package/dist/web/assets/index-82MhMOB4.css.br +0 -0
  5. package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
  6. package/dist/web/assets/index-DCB2nO0u.js +36 -0
  7. package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
  8. package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
  9. package/dist/web/index.html +2 -2
  10. package/package.json +2 -1
  11. package/scripts/notices.ts +3 -1
  12. package/server/api.ts +449 -20
  13. package/server/assets.generated.ts +13 -13
  14. package/server/attach.ts +65 -16
  15. package/server/browser.ts +1 -65
  16. package/server/cli.ts +48 -150
  17. package/server/client.ts +440 -0
  18. package/server/config.ts +64 -2
  19. package/server/db.ts +19 -0
  20. package/server/doctor.ts +176 -123
  21. package/server/harness.ts +378 -590
  22. package/server/harnesses/claude.ts +248 -0
  23. package/server/harnesses/codex.ts +241 -0
  24. package/server/harnesses/cursor.ts +202 -0
  25. package/server/harnesses/hermes.ts +259 -0
  26. package/server/harnesses/util.ts +181 -0
  27. package/server/headless.ts +358 -0
  28. package/server/index.ts +47 -14
  29. package/server/memory.ts +418 -0
  30. package/server/profiles.ts +27 -15
  31. package/server/projects.ts +52 -3
  32. package/server/reaper.ts +5 -3
  33. package/server/screens.ts +372 -0
  34. package/server/session.ts +84 -95
  35. package/server/setup.ts +73 -7
  36. package/server/windows.ts +51 -1
  37. package/shared/blanks.ts +113 -0
  38. package/shared/harness.ts +375 -0
  39. package/shared/wire.ts +105 -94
  40. package/dist/web/assets/index-B77Qk8B5.js +0 -35
  41. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  42. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  43. package/dist/web/assets/index-CH5Em-Mo.css +0 -1
  44. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  45. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
@@ -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
+ }
@@ -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(input: ProfileInput, partial: true): Fields;
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(input: ProfileInput, partial: boolean): Fields {
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 (!(HARNESSES as readonly string[]).includes(harness))
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();
@@ -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
- const masters = await pgrep(["-f", `^dtach -n ${sock}`]);
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>();