teapot-coding-agent 0.37.59 → 0.37.61

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 (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/master.js CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Master server: owns all agents and one low-frequency scheduler tick.
3
- * Agents are in-process async loops (I/O bound only); all CPU-heavy work is
4
- * delegated to subprocesses managed by the bash tool with hard timeouts.
5
- */
6
1
  import { closeAllTerminals, closeTerminalsFor } from "./server/terminals.js";
7
2
  import { MIGRATIONS, runMigrations } from "./migrations/registry.js";
8
3
  import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, lstatSync, renameSync, unlinkSync, openSync, closeSync, readSync, fsyncSync, rmSync, } from "node:fs";
@@ -19,30 +14,9 @@ import { bus } from "./bus.js";
19
14
  import { mcp } from "./agent/mcp-registry.js";
20
15
  import { loadPlugins } from "./agent/plugins.js";
21
16
  import { fetchModelList, peekModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
22
- /**
23
- * Did the effective data dir come from an ENV OVERRIDE that disagrees with the
24
- * config file's own `dataDir`?
25
- *
26
- * When it does, the config's session pointers are meaningless: they name
27
- * directories under the OLD root, and this process is looking at a different
28
- * one. Every agent therefore mints a fresh session — and `recordSession` writes
29
- * those new pointers back into the config, destroying the real mapping. The
30
- * caller then throws the new root away (a test's temp dir), leaving a config that
31
- * points at sessions that never existed.
32
- *
33
- * That is not hypothetical: `test/cli-boot-and-host.test.ts` spawns the built CLI
34
- * with `TEAPOT_DATA_DIR=<temp>` but NO config override, so `pnpm test` on a
35
- * developer machine rewrote their real config and orphaned 493 sessions' pointers
36
- * — twice, before this guard. On CI it is invisible, because CI has no config
37
- * file to damage.
38
- *
39
- * So: a mismatched root makes session resolution READ-ONLY. The boot still works
40
- * (agents get working sessions in the new root) — it just does not rewrite a
41
- * config that is describing somebody else's data dir.
42
- */
43
17
  function dataDirOverrideMismatch(configFilePath, effective) {
44
18
  if (!process.env.TEAPOT_DATA_DIR)
45
- return false; // not an override: the file is the truth
19
+ return false;
46
20
  try {
47
21
  const raw = JSON.parse(readFileSync(configFilePath, "utf8"));
48
22
  if (typeof raw.dataDir !== "string" || !raw.dataDir)
@@ -50,23 +24,11 @@ function dataDirOverrideMismatch(configFilePath, effective) {
50
24
  return path.resolve(raw.dataDir) !== path.resolve(effective);
51
25
  }
52
26
  catch {
53
- return false; // unreadable/new config — nothing to protect
27
+ return false;
54
28
  }
55
29
  }
56
30
  const MAX_SPAWN_DEPTH = 3;
57
- /**
58
- * How long boot waits for an endpoint's model catalogue.
59
- *
60
- * The window and the reasoning-effort gate both come from `/models`, so a boot
61
- * that waits on it is waiting for metadata, not for anything it needs to start.
62
- * The lookup is fired concurrently for every distinct endpoint before the agent
63
- * loop (see prefetchModelLists), so this bound is paid once per ENDPOINT rather
64
- * than once per agent — and only when the endpoint is slow or dead.
65
- */
66
31
  const MODEL_META_BOOT_MS = 1_500;
67
- /** statuses that mean a child has finished working, for #130 */
68
- // `needs_operator` is settled too: the child has stopped working and is waiting on a human, so
69
- // the parent must not treat it as "still busy" and wait forever for it to finish.
70
32
  const SETTLED = new Set([
71
33
  "idle",
72
34
  "stopped",
@@ -74,20 +36,7 @@ const SETTLED = new Set([
74
36
  "needs_operator",
75
37
  "error",
76
38
  ]);
77
- /**
78
- * Skills shipped with the package — resolved relative to this module so it
79
- * works from a global `npm install -g` install, an npx cache, or a repo
80
- * checkout alike. Lowest-priority skill root (workspace > global > bundled).
81
- */
82
39
  const BUNDLED_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../skills");
83
- /**
84
- * Application version, read from package.json next to this module.
85
- *
86
- * Walks up from `dist/master.js` / `src/master.ts` to the package root so it
87
- * resolves in every layout (repo checkout, global install, npx cache). Falls
88
- * back to `"0.0.0"` rather than throwing: a missing/unreadable package.json
89
- * must never stop the server from booting.
90
- */
91
40
  function readAppVersion() {
92
41
  try {
93
42
  let dir = path.dirname(fileURLToPath(import.meta.url));
@@ -105,33 +54,15 @@ function readAppVersion() {
105
54
  }
106
55
  }
107
56
  catch {
108
- /* fall through to the sentinel below */
109
57
  }
110
58
  return "0.0.0";
111
59
  }
112
- /**
113
- * Turn a user-supplied workspace path into an absolute one.
114
- *
115
- * A relative path used to be resolved against `process.cwd()`, so a config
116
- * entry like `"workspace": "workspaces/alpha"` (the form shipped in
117
- * teapot.config.example.json) silently pointed at whatever directory the
118
- * daemon happened to be started from — and the agent's bash tool then ran in
119
- * the wrong directory. Relative workspaces are now anchored to the
120
- * config file's own directory, which is what "the workspace" means inside a
121
- * config file.
122
- *
123
- * `~` is expanded for both forms; absolute paths pass through untouched.
124
- */
125
60
  export function resolveWorkspace(input, base) {
126
- // `~` must not expand to the LITERAL "~" — that is how a directory named
127
- // `~` got created on Windows, where process.env.HOME is unset. See src/home.ts.
128
61
  const expanded = expandTilde(input.trim());
129
62
  if (path.isAbsolute(expanded))
130
63
  return path.normalize(expanded);
131
64
  return path.resolve(base, expanded);
132
65
  }
133
- /** default sub-agent personas — mentionable from the composer (@name) and
134
- * usable by agents via spawn_agent({persona}) */
135
66
  export const SUB_PERSONAS = {
136
67
  reviewer: {
137
68
  label: "🔍️ reviewer",
@@ -165,16 +96,6 @@ const DATA_DIR = process.env.TEAPOT_DATA_DIR ??
165
96
  path.join(os.homedir(), ".local", "share", "teapot-coding-agent");
166
97
  const DEFAULT_CONFIG = {
167
98
  port: Number(process.env.TEAPOT_PORT ?? 7788),
168
- // this was `process.env.TEAPOT_HOST`, i.e. UNDEFINED when unset — and an
169
- // undefined hostname makes `@hono/node-server` bind every interface. So the
170
- // default was the opposite of the documented one: `--help` says
171
- // "127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network", and yet the
172
- // default WAS "expose to your network".
173
- //
174
- // well as loopback.
175
- //
176
- // Loopback is the right default for something holding API tokens and agent
177
- // workspaces; exposing it has to be a deliberate act.
178
99
  host: process.env.TEAPOT_HOST ?? "127.0.0.1",
179
100
  dataDir: DATA_DIR,
180
101
  llm: {
@@ -186,17 +107,9 @@ const DEFAULT_CONFIG = {
186
107
  agents: [],
187
108
  tasks: [],
188
109
  };
189
- /** Where config is looked up / stored. */
190
110
  export function configDir() {
191
111
  return CONFIG_DIR;
192
112
  }
193
- /**
194
- * Resolution order:
195
- * 1. explicit path argument (CLI)
196
- * 2. $TEAPOT_CONFIG
197
- * 3. ~/.config/teapot-coding-agent/config.json
198
- * 4. ./teapot.config.json (legacy project-local)
199
- */
200
113
  export function resolveConfigPath(explicitPath) {
201
114
  if (explicitPath)
202
115
  return path.resolve(explicitPath);
@@ -208,24 +121,6 @@ export function resolveConfigPath(explicitPath) {
208
121
  return path.resolve("teapot.config.json");
209
122
  }
210
123
  let masterRawConfig = {};
211
- /**
212
- * Normalise a provider entry's credentials into `apiKeys`.
213
- *
214
- * Both spellings are read, and the result is written back in the shape the UI and
215
- * the pool consume. The rules:
216
- *
217
- * - `apiKeys` present -> used as-is (trimmed, blanks and duplicates dropped)
218
- * - `apiKey` only -> becomes `apiKeys: [apiKey]` (the OLD shape, which is
219
- * every config written before this feature)
220
- * - neither -> left alone (a local endpoint may need no key)
221
- *
222
- * `apiKey` is KEPT in step with `apiKeys[0]` rather than deleted, because other
223
- * readers still expect it (the setup wizard, `/api/providers`, hand-edited files) and
224
- * a migration that removes a field the operator can see is a migration they will
225
- * report as data loss.
226
- *
227
- * Returns a NEW entry; the caller decides whether to persist.
228
- */
229
124
  export function migrateProviderKeys(p) {
230
125
  const seen = new Set();
231
126
  const clean = (list) => {
@@ -239,26 +134,16 @@ export function migrateProviderKeys(p) {
239
134
  }
240
135
  return out;
241
136
  };
242
- // `apiKeys` is AUTHORITATIVE once present. The single `apiKey` only SEEDS the pool
243
- // when there is no list — appending it as well would resurrect a credential the
244
- // operator had already replaced (they set a list precisely because the old key was
245
- // exhausted), and the pool would happily rotate back onto it.
246
137
  const hasList = Array.isArray(p.apiKeys);
247
138
  const keys = hasList
248
139
  ? clean(p.apiKeys)
249
140
  : clean(typeof p.apiKey === "string" ? [p.apiKey] : []);
250
141
  if (keys.length === 0) {
251
- // no credential at all (a local endpoint may need none): do not invent an empty
252
- // array, which would read as "this provider has keys" to anything counting them
253
142
  const { apiKeys: _drop, ...rest } = p;
254
143
  return rest;
255
144
  }
256
- // `apiKey` is kept in step with the FIRST key rather than deleted: other readers
257
- // still expect it (the setup wizard, `/api/providers`), and a migration that
258
- // removes a field the operator can see reads as data loss.
259
145
  return { ...p, apiKeys: keys, apiKey: keys[0] };
260
146
  }
261
- /** Every key for a provider, in order, with the legacy single key folded in. */
262
147
  export function providerKeys(p) {
263
148
  if (!p)
264
149
  return [];
@@ -275,43 +160,21 @@ export function loadConfig(configPath) {
275
160
  ...user,
276
161
  dataDir: user.dataDir ? path.resolve(user.dataDir.replace(/^~/, os.homedir())) : DEFAULT_CONFIG.dataDir,
277
162
  llm: { ...DEFAULT_CONFIG.llm, ...user.llm },
278
- // fold a legacy single `apiKey` into `apiKeys` on load, so every consumer
279
- // below sees one shape. Done here rather than at each use site because the pool,
280
- // the UI and the spawn path all need the same answer.
281
163
  providers: Object.fromEntries(Object.entries(user.providers ?? {}).map(([name, p]) => [name, migrateProviderKeys(p)])),
282
164
  };
283
165
  }
284
- /**
285
- * Can this path be replaced by an atomic rename?
286
- *
287
- * `/dev/null`, a fifo, or a directory are all legitimate values for
288
- * `configPath`, and `rename` over them either fails outright (EACCES for a temp
289
- * beside `/dev/null`) or is not what the caller wants. Only a regular file gets
290
- * the write-temp-then-rename treatment.
291
- */
292
166
  function isRegularFileTarget(p) {
293
167
  try {
294
- // lstat, not stat: a SYMLINK must take the plain-write path. statSync
295
- // follows the link, so a symlink→regular-file passed this check and the
296
- // atomic rename then REPLACED THE LINK with a real file — the link's
297
- // target (a dotfile repo, stow/chezmoi) kept the old value and a restart
298
- // resurrected it. Same reasoning covers hardlinks (rename splits the
299
- // inode; the write must go through the existing directory entry).
300
168
  const st = lstatSync(p);
301
169
  return st.isFile() && !st.isSymbolicLink();
302
170
  }
303
171
  catch {
304
- // Does not exist yet. The ATOMIC path needs a real directory to rename within, so
305
- // only take it when the parent exists — otherwise a test that passes `/dev/null`
306
- // (present on POSIX, absent on Windows) tried to create `/dev/.config.json.tmp…`
307
- // and failed with a bare 400 that read like a schema rejection.
308
172
  return existsSync(path.dirname(p));
309
173
  }
310
174
  }
311
175
  export function loadedRaw() {
312
176
  return masterRawConfig;
313
177
  }
314
- /* ---------- console activity log ---------- */
315
178
  const isTTY = process.stdout.isTTY;
316
179
  const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s);
317
180
  const dim = (s) => c("2", s);
@@ -320,10 +183,9 @@ const clip1 = (s, n) => {
320
183
  return one.length > n ? one.slice(0, n) + "…" : one;
321
184
  };
322
185
  const hhmmss = () => new Date().toTimeString().slice(0, 8);
323
- /** Print one agent event as a compact human-readable console line. */
324
186
  function printAgentEvent(e) {
325
187
  const ts = dim(hhmmss());
326
- const who = c("36", e.agent); // cyan
188
+ const who = c("36", e.agent);
327
189
  const d = e.data;
328
190
  let line = null;
329
191
  switch (e.type) {
@@ -352,7 +214,7 @@ function printAgentEvent(e) {
352
214
  case "message":
353
215
  if (d.final && d.content)
354
216
  line = `${c("34", "🏁 final")} ${clip1(d.content, 140)}`;
355
- break; // regular assistant messages are visible in the UI
217
+ break;
356
218
  case "progress":
357
219
  line = `${c("32", "📈 progress")} ${clip1(d.doing, 100)}`;
358
220
  break;
@@ -376,10 +238,6 @@ function printAgentEvent(e) {
376
238
  else if (d.event === "model-changed")
377
239
  line = `${c("33", "🧦 model")} → ${d.model} (${d.provider})`;
378
240
  else if (d.event === "round-turn-cap")
379
- // The code deliberately does NOT nudge the model here (see the note at the
380
- // emit site): telling it "the round ended, consider finish()" invited premature
381
- // finishes. The old text claimed a nudge that does not happen, which sends
382
- // anyone reading the log looking for behaviour that was removed on purpose.
383
241
  line = `${c("33", "⏸ round cap")} ${d.turns} turns — round ended, auto-continue starts the next one`;
384
242
  break;
385
243
  }
@@ -389,78 +247,29 @@ function printAgentEvent(e) {
389
247
  if (line)
390
248
  console.log(`${dim(ts)} ${who} ${line}`);
391
249
  }
392
- /**
393
- * Reduce an agent id to a safe single path segment.
394
- *
395
- * Agent ids become directory names, so anything path-like in one is an escape
396
- * from the sessions root. Kept in step with the per-call-site
397
- * `.replace(/[^\w.-]/g, "-")` calls, and applied in `addAgent` too so the
398
- * guarantee holds for every caller.
399
- */
400
250
  export function sanitizeAgentId(id) {
401
251
  const cleaned = String(id ?? "")
402
252
  .replace(/[^\w.-]/g, "-")
403
- // a leading dot would create a hidden dir; ".." would climb out
404
253
  .replace(/^\.+/, "")
405
254
  .slice(0, 60);
406
255
  return cleaned || "agent";
407
256
  }
408
257
  export class Master {
409
258
  agents = new Map();
410
- /**
411
- * Configured agents that could NOT be loaded, with the reason.
412
- *
413
- * `addAgent` throws when an entry is unusable (no model configured, duplicate id, unknown
414
- * provider) and the boot loop skips it so one bad entry cannot take the server down. The skip was
415
- * SILENT, though, and the agent's sessions stayed in `/api/sessions` — so the sidebar offered a
416
- * timeline that `/api/agents/<id>/events` answered with 404, and the UI sat on "loading timeline…"
417
- * (reported as "セッション切り替えがバカ重い"). Recording the reason turns that into something the
418
- * UI can explain.
419
- */
420
259
  unavailableAgents = new Map();
421
- /** Agent Plugins loaded at boot (#162); their skill roots are offered to every agent */
422
260
  plugins = [];
423
- /**
424
- * ONE key pool per provider name, shared by every agent on it.
425
- *
426
- * Shared on purpose: a 429 is a statement about the CREDENTIAL, not about the
427
- * agent that happened to send the request. Per-agent pools would let agent B walk
428
- * straight back into the key agent A just got rate-limited on — the rotation would
429
- * exist and do nothing, which is the failure shape this repo keeps re-learning.
430
- *
431
- * Keyed by PROVIDER NAME rather than baseUrl: two entries for the same endpoint may
432
- * legitimately hold different credentials, and those must not be merged.
433
- */
434
261
  keyPools = new Map();
435
- /**
436
- * pid of the process that replaced us in a live update, or null while we are
437
- * still the server. `restartServer()` sets it; `index.ts` reads it to keep
438
- * supervising the replacement instead of exiting and dropping the operator
439
- * back at the shell prompt.
440
- */
441
262
  replacementPid = null;
442
263
  tasks = [];
443
264
  childWaiters = new Map();
444
- /**
445
- * Children that have actually started, for #130.
446
- *
447
- * A child is `idle` between `addAgent` and `start()`, and that is its FIRST
448
- * status transition — so without this the parent is told a brand-new sub-agent is
449
- * idle before it has done anything.
450
- */
451
265
  reportedRunning = new Set();
452
266
  startedAt = Date.now();
453
267
  config;
454
268
  configPath;
455
- /** false on first boot with no config file → web UI shows the setup wizard */
456
269
  configFileExists = true;
457
270
  constructor(config, configPath) {
458
271
  this.config = config;
459
272
  this.configPath = configPath;
460
- // an env data-dir override that disagrees with the config file means
461
- // this config describes a DIFFERENT data dir — its session pointers cannot be
462
- // valid here, so rewriting them would destroy the real mapping. See
463
- // dataDirOverrideMismatch().
464
273
  this.sessionsReadOnly = dataDirOverrideMismatch(configPath, config.dataDir);
465
274
  if (this.sessionsReadOnly) {
466
275
  console.warn(`[teapot] TEAPOT_DATA_DIR (${config.dataDir}) disagrees with the config's own dataDir; ` +
@@ -468,26 +277,7 @@ export class Master {
468
277
  `Set TEAPOT_CONFIG to an isolated config to silence this.`);
469
278
  }
470
279
  }
471
- /**
472
- * Persist current logical config back to disk (lossless via raw user config).
473
- *
474
- * this was a bare `writeFileSync`, which TRUNCATES the target before it
475
- * writes. config.json holds every agent, provider and scheduled task, so any
476
- * interruption during the write — SIGINT, a crash, a full disk — left a ZERO-LENGTH
477
- * file, and the next boot read `{}` and came up empty.
478
- *
479
- * Boot made this far more likely than it sounds: every agent that mints a
480
- * session calls `recordSession` → `saveConfig`, so a machine with 30 configured
481
- * agents rewrites the whole file 30 times before the server listens. A user
482
- * pressing Ctrl-C during that window lost the lot.
483
- *
484
- * Now: write a sibling temp file, fsync it, then `rename` over the target.
485
- * `rename` is atomic within a filesystem, so the file is either the old content
486
- * or the new — never empty. A crash mid-write leaves the temp file behind and
487
- * the real config untouched.
488
- */
489
280
  saveConfig() {
490
- // during boot, mark dirty instead of writing; flushed once at the end
491
281
  if (this.deferConfigWrites) {
492
282
  this.configDirty = true;
493
283
  return;
@@ -496,49 +286,15 @@ export class Master {
496
286
  this.raw.providers = this.config.providers;
497
287
  this.raw.defaultProvider = this.config.defaultProvider;
498
288
  this.raw.tasks = this.config.tasks;
499
- // `raw` is what gets WRITTEN, and it is a hand-maintained mirror of `config`.
500
- // `mcpServers` was added to the config and to the API but never to this list, so the
501
- // servers existed in memory and in `GET /api/config` and were STILL lost on restart —
502
- // a save→reopen appeared to work while the file never carried them.
503
- //
504
- // Every field that must survive a restart belongs here. Caught by asserting the FILE
505
- // contents in test/mcp-config-roundtrip.test.ts, which was impossible while that test
506
- // wrote to /dev/null.
507
289
  this.raw.mcpServers = this.config.mcpServers;
508
- /**
509
- * Custom themes live in the config so they follow the machine, not one browser.
510
- *
511
- * This line is the whole persistence: `raw` is the hand-maintained mirror that actually gets
512
- * written, and a field missing from it exists in memory and in `GET /api/config` while never
513
- * reaching the file — exactly the #67 failure above, which is why it is called out there.
514
- */
515
290
  if (this.config.customThemes !== undefined)
516
291
  this.raw.customThemes = this.config.customThemes;
517
- /**
518
- * The API password — same mirror, same trap.
519
- *
520
- * It was never in `raw` because it was only ever written by the first-run setup path, which
521
- * wrote the file wholesale. Now that the settings panel can change it, a missing mirror would
522
- * make "set a password" appear to work and vanish on the next restart, leaving the server open
523
- * while the operator believes it is closed — the worst possible direction for this field.
524
- */
525
292
  if (this.config.password !== undefined)
526
293
  this.raw.password = this.config.password;
527
294
  else
528
295
  delete this.raw.password;
529
296
  if (this.config.themePrefs !== undefined)
530
297
  this.raw.themePrefs = this.config.themePrefs;
531
- /**
532
- * The live-update resume set — and the reason it needs this line is the whole point of it.
533
- *
534
- * `resumeAfterUpdate()` CLEARS the set so a crash cannot make a later boot resume hours-old
535
- * work. Without this mirror the clear lived only in memory, so the file kept the old set and the
536
- * NEXT update would resume the same sessions again — MEASURED: a boot with
537
- * `resumeAfterUpdate: ["worker"]` left the file unchanged at `["worker"]` after consuming it.
538
- *
539
- * Written unconditionally (not only when defined): `[]` is a meaningful value here, and skipping
540
- * it is what kept the stale list on disk.
541
- */
542
298
  this.raw.resumeAfterUpdate = this.config.resumeAfterUpdate;
543
299
  if (this.config.shortContext !== undefined)
544
300
  this.raw.shortContext = this.config.shortContext;
@@ -550,57 +306,19 @@ export class Master {
550
306
  this.raw.progressMinChars = this.config.progressMinChars;
551
307
  this.writeConfigAtomic(JSON.stringify(this.raw, null, 2) + "\n");
552
308
  }
553
- /**
554
- * Coalesce config writes. #144.
555
- *
556
- * Boot calls `addAgent` for every configured agent, and any agent whose recorded
557
- * session is gone mints a new one — which calls `recordSession` → `saveConfig`.
558
- * A machine with 30 agents therefore rewrote the whole file 30 times before the
559
- * server listened, and each was a window in which the file could be truncated.
560
- *
561
- * With the write now atomic that is no longer dangerous, but it is still 30
562
- * unnecessary writes of a 148KB file. During boot, saves are marked dirty and
563
- * flushed ONCE at the end. Outside boot (an edit, a spawn, a rename) behaviour is
564
- * unchanged: synchronous and immediate.
565
- */
566
309
  deferConfigWrites = false;
567
310
  configDirty = false;
568
- /**
569
- * Endpoints whose catalogue boot has already WAITED for.
570
- *
571
- * The wait must be paid once per endpoint, not once per agent. `Promise.race`
572
- * against a timeout is per-CALL: with a dead endpoint every agent on it paid
573
- * the full timeout again, because the shared request was still pending. 493
574
- * agents over one unreachable provider measured 4.4s of boot spent purely
575
- * idle, for a single request.
576
- */
577
311
  metaWaited = new Set();
578
- /**
579
- * `TEAPOT_DATA_DIR` points somewhere other than the config's own dataDir,
580
- * so session pointers must not be written back (see dataDirOverrideMismatch).
581
- */
582
312
  sessionsReadOnly = false;
583
- /** write once and clear the dirty flag — used at the end of a batched section */
584
313
  flushConfig() {
585
314
  this.configDirty = false;
586
315
  this.saveConfig();
587
316
  }
588
- /**
589
- * The shared pool for a provider, created on first use.
590
- *
591
- * Cached so the cooldown state survives across agents AND across a provider being
592
- * re-read: rebuilding the pool on every spawn would forget that a key is cooling
593
- * down and hand it straight back out. Rebuilt only if the KEY LIST changes (the
594
- * operator edited the provider), which is when the old cooldowns stop being
595
- * meaningful.
596
- */
597
317
  keyPoolFor(name, prov) {
598
318
  const keys = providerKeys(prov);
599
319
  if (keys.length === 0)
600
320
  return undefined;
601
321
  const existing = this.keyPools.get(name);
602
- // reuse while the KEY LIST is unchanged, so the pool keeps its position in the
603
- // cycle across spawns; a changed list is a new pool
604
322
  if (existing && existing.keys.length === keys.length && existing.keys.every((k, i) => k === keys[i])) {
605
323
  return existing;
606
324
  }
@@ -608,13 +326,6 @@ export class Master {
608
326
  this.keyPools.set(name, pool);
609
327
  return pool;
610
328
  }
611
- /**
612
- * Diagnostics: each provider's keys and which one is in use.
613
- *
614
- * Deliberately shows only position, not "spent until" timestamps: this pool has no
615
- * such state — a key is rotated past once its retries are spent, and a new turn
616
- * starts the cycle again.
617
- */
618
329
  keyPoolReport() {
619
330
  return [...this.keyPools.entries()].map(([provider, pool]) => ({
620
331
  provider,
@@ -622,7 +333,6 @@ export class Master {
622
333
  current: pool.current,
623
334
  }));
624
335
  }
625
- /** start every provider's cycle over from its first key */
626
336
  resetKeyPool(provider) {
627
337
  const names = provider ? [provider] : [...this.keyPools.keys()];
628
338
  for (const n of names) {
@@ -632,27 +342,16 @@ export class Master {
632
342
  }
633
343
  }
634
344
  writeConfigAtomic(body) {
635
- // `/dev/null` is a real, intentional value here — many callers (and
636
- // every test that does not care about persistence) pass it as "no config
637
- // file". The old `writeFileSync("/dev/null", …)` succeeded and discarded.
638
- //
639
- // Atomic writing cannot honour that: the temp file would be
640
- // `/dev/.config.json.tmp-…`, which is EACCES for a normal user, so 22 tests
641
- // failed with a bare 400 from POST /api/agents. Keep the old behaviour for a
642
- // non-regular target, and only do write-rename where rename means something.
643
345
  if (!isRegularFileTarget(this.configPath)) {
644
346
  writeFileSync(this.configPath, body);
645
347
  return;
646
348
  }
647
349
  const dir = path.dirname(this.configPath);
648
- // same directory as the target, so rename() stays within one filesystem
649
350
  const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
650
351
  let fd;
651
352
  try {
652
353
  fd = openSync(tmp, "w");
653
354
  writeFileSync(fd, body);
654
- // fsync BEFORE the rename: without it the rename can land while the data is
655
- // still in the page cache, which is the same data-loss window on a crash
656
355
  fsyncSync(fd);
657
356
  closeSync(fd);
658
357
  fd = undefined;
@@ -664,31 +363,20 @@ export class Master {
664
363
  closeSync(fd);
665
364
  }
666
365
  catch {
667
- /* already closed */
668
366
  }
669
367
  }
670
368
  try {
671
369
  unlinkSync(tmp);
672
370
  }
673
371
  catch {
674
- /* nothing to clean up */
675
372
  }
676
- // Losing the new state is survivable; losing the file is not, so rethrow
677
- // rather than pretending the save succeeded
678
373
  throw err;
679
374
  }
680
375
  }
681
376
  raw = loadedRaw();
682
- /** Apply partial config edits from the web UI and persist them. */
683
377
  updateConfig(patch) {
684
378
  if (patch.providers)
685
379
  this.config.providers = patch.providers;
686
- /**
687
- * `!== undefined`, so `""` is honoured as "clear it".
688
- *
689
- * A truthiness check would silently keep the old password when the operator emptied the field,
690
- * which is the one case where they are explicitly asking to remove the protection.
691
- */
692
380
  if (patch.password !== undefined)
693
381
  this.config.password = patch.password || undefined;
694
382
  if (patch.defaultProvider !== undefined)
@@ -705,22 +393,16 @@ export class Master {
705
393
  a.opts.progressMinChars =
706
394
  patch.progressMinChars;
707
395
  }
708
- // explicit null clears a previously saved budget → back to per-model
709
- // derivation (75% of each agent's known window); a positive number pins it
710
396
  if (patch.contextTokenBudget !== undefined) {
711
397
  this.config.contextTokenBudget =
712
398
  (patch.contextTokenBudget ?? 0) > 0 ? patch.contextTokenBudget : undefined;
713
399
  }
714
400
  if (patch.maxSpawnDepth !== undefined)
715
401
  this.config.maxSpawnDepth = patch.maxSpawnDepth;
716
- // the registry is applied by the caller (it also connects); this only
717
- // records the list so a restart comes back with the same servers.
718
402
  if (patch.mcpServers !== undefined)
719
403
  this.config.mcpServers = patch.mcpServers;
720
404
  if (patch.shortContext !== undefined) {
721
405
  this.config.shortContext = patch.shortContext;
722
- // apply to LIVE agents too: the mode is read every turn, so a restart should not
723
- // be the price of switching it
724
406
  for (const a of this.agents.values())
725
407
  a.opts.shortContext =
726
408
  patch.shortContext;
@@ -731,17 +413,10 @@ export class Master {
731
413
  this.config.onError = patch.onError;
732
414
  if (patch.retryDelayMs !== undefined)
733
415
  this.config.retryDelayMs = patch.retryDelayMs;
734
- // #PUT-config: an explicit null clears a saved window pin → back to
735
- // per-model inference; a positive number pins it (same contract as
736
- // contextTokenBudget above)
737
416
  if (patch.contextWindowTokens !== undefined) {
738
417
  this.config.contextWindowTokens =
739
418
  (patch.contextWindowTokens ?? 0) > 0 ? patch.contextWindowTokens : undefined;
740
419
  }
741
- // persist the scalar edits: saveConfig only mirrors agents/providers/etc.
742
- // into raw, so these would otherwise pass validation, live for one
743
- // process, and silently vanish from the file (a restart resurrected the
744
- // old values). undefined DELETES the key so a null-clear also persists.
745
420
  for (const k of [
746
421
  "contextTokenBudget", "contextWindowTokens", "maxSpawnDepth",
747
422
  "maxTurnsPerRound", "onError", "retryDelayMs",
@@ -754,7 +429,6 @@ export class Master {
754
429
  }
755
430
  if (patch.tasks) {
756
431
  this.config.tasks = patch.tasks;
757
- // rebuild schedule table live
758
432
  this.tasks = patch.tasks.map((t) => ({
759
433
  task: t,
760
434
  schedule: parseSchedule(t.schedule),
@@ -763,13 +437,6 @@ export class Master {
763
437
  }
764
438
  this.saveConfig();
765
439
  }
766
- /**
767
- * Load Agent Plugins and merge their MCP servers with the configured ones.
768
- *
769
- * Separate from `start()` so it can be exercised without booting a server: this is
770
- * where plugin servers get QUALIFIED with their plugin name, and a naming collision
771
- * here silently makes one plugin's tools disappear.
772
- */
773
440
  async loadPluginsAndMcp() {
774
441
  const pluginMcp = [];
775
442
  if (this.config.pluginsDir !== false) {
@@ -781,10 +448,6 @@ export class Master {
781
448
  for (const p of plugins) {
782
449
  for (const w of p.warnings)
783
450
  console.error(`[teapot] plugin "${p.name}": ${w}`);
784
- // QUALIFY each server with its plugin name. A plugin's own `mcp.json` names its
785
- // servers locally ("db"), and two plugins may each have one — the registry keys
786
- // by name, so an unqualified collision would silently replace one with the other
787
- // and its tools would appear to vanish. The plugin name is the natural prefix.
788
451
  pluginMcp.push(...p.mcpServers.map((s) => ({ ...s, name: `${p.name}:${s.name}` })));
789
452
  }
790
453
  if (plugins.length)
@@ -794,23 +457,7 @@ export class Master {
794
457
  }
795
458
  async start() {
796
459
  mkdirSync(this.config.dataDir, { recursive: true });
797
- /**
798
- * Clear the flag BEFORE the migrations, never after.
799
- *
800
- * `finally` below flushes only when `configDirty` is set, so a reset placed after the migration
801
- * pass erases exactly what a migration just asked to be written. MEASURED with it after: 37
802
- * titles derived in memory, `"sessionTitle"` in the config file: 0.
803
- */
804
460
  this.configDirty = false;
805
- /**
806
- * Run the on-disk migrations ONCE, before any agent resolves a session directory.
807
- *
808
- * They used to be spread across the boot path — the flat-layout move ran inside
809
- * `resolveSessionDir`, i.e. per agent, where it could interleave with another agent's
810
- * resolution. Ordering is the thing that matters when a machine skips several releases: an
811
- * operator jumping from an old build runs EVERY step at once, and a step that assumes another
812
- * has run must actually see that. See src/migrations/index.ts.
813
- */
814
461
  await runMigrations(MIGRATIONS, {
815
462
  configDir: path.dirname(this.configPath),
816
463
  raw: () => this.raw,
@@ -820,37 +467,9 @@ export class Master {
820
467
  dataDir: this.config.dataDir,
821
468
  log: (m) => console.log(`[teapot] ${m}`),
822
469
  });
823
- // ONE config write for the whole boot instead of one per agent.
824
- //
825
- // Every agent whose recorded session is gone mints a new one, and minting
826
- // calls recordSession → saveConfig. With ~30 configured agents the user's log
827
- // showed ~30 "session … is gone" lines, and therefore ~30 full rewrites of a
828
- // 148KB config.json — all before the server ever listened. That is the window
829
- // in which Ctrl-C emptied the file.
830
- //
831
- // `finally` so a throw mid-boot still flushes what it already changed: a
832
- // deferred write that never flushes would leave config.json describing the PREVIOUS
833
- // boot, which is its own silent data loss.
834
470
  this.deferConfigWrites = true;
835
- // start every endpoint's model-catalogue lookup NOW, concurrently.
836
- //
837
- // `addAgent` awaits this per agent, and the endpoints are shared by many
838
- // agents (493 agents over 6 providers here). Fired up front, the requests
839
- // overlap and each subsequent agent finds the promise already in flight or
840
- // resolved — instead of the requests being serialised behind the agent loop,
841
- // where a slow endpoint's timeout is paid again and again.
842
471
  this.prefetchModelLists();
843
- // connect configured MCP servers in the background. Deliberately NOT
844
- // awaited: a slow or unreachable server must not delay listening, and its tools
845
- // appear as the connection lands (an agent's next turn picks them up because
846
- // `allToolSpecs()` reads the registry each call).
847
- // Agent Plugins contribute skill roots and MCP servers. Loaded BEFORE the
848
- // MCP connect below so a plugin's servers connect in the same pass as configured
849
- // ones, and before any agent starts so its skills are discoverable on turn one.
850
472
  if (this.config.mcpServers?.length || this.config.pluginsDir !== false) {
851
- // OAuth grants live beside the data, not in config.json (a refresh token is a
852
- // credential, and config.json is the file operators copy around). Must be pointed
853
- // at the data dir BEFORE any server connects, or a stored grant is invisible.
854
473
  mcp.init(this.config.dataDir);
855
474
  const allMcp = await this.loadPluginsAndMcp();
856
475
  if (allMcp.length) {
@@ -860,11 +479,7 @@ export class Master {
860
479
  }
861
480
  try {
862
481
  for (const ac of this.config.agents) {
863
- // archived agents keep their config entry and their history but do not run;
864
- // starting one would spend a provider request and mint/rewrite a session for
865
- // something the operator deliberately put away.
866
482
  if (ac.archived) {
867
- // archived: not started, but its history must stay readable (the sidebar lists it)
868
483
  this.unavailableAgents.set(ac.id, "archived");
869
484
  continue;
870
485
  }
@@ -872,8 +487,6 @@ export class Master {
872
487
  await this.addAgent(ac);
873
488
  }
874
489
  catch (err) {
875
- // one bad entry (duplicate id, unknown provider, …) must not take the
876
- // whole master down — skip it loudly and keep serving the rest
877
490
  console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
878
491
  this.unavailableAgents.set(ac.id, err.message);
879
492
  }
@@ -887,7 +500,6 @@ export class Master {
887
500
  });
888
501
  }
889
502
  catch (err) {
890
- // a hand-edited schedule typo must not stop the server from listening
891
503
  console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
892
504
  }
893
505
  }
@@ -897,15 +509,8 @@ export class Master {
897
509
  if (this.configDirty)
898
510
  this.flushConfig();
899
511
  }
900
- // single low-frequency tick for everything periodic (idle cost ≈ 0)
901
512
  setInterval(() => void this.tick(), 15_000).unref();
902
513
  }
903
- /**
904
- * First-run wizard bootstrap: write a minimal config file, apply it to the
905
- * running master, and optionally create + start the first agent. Only
906
- * available while no config file exists — after this, edits go through
907
- * PUT /api/config behind whatever auth is configured.
908
- */
909
514
  async applySetup(body) {
910
515
  if (this.configFileExists)
911
516
  throw new Error("setup already completed — edit the config file instead");
@@ -922,7 +527,6 @@ export class Master {
922
527
  const dir = path.dirname(this.configPath);
923
528
  mkdirSync(dir, { recursive: true });
924
529
  writeFileSync(this.configPath, JSON.stringify(raw, null, 2) + "\n");
925
- // hot-apply to the running master (no restart needed)
926
530
  this.config.llm = { ...this.config.llm, ...llm };
927
531
  if (body.password)
928
532
  this.config.password = body.password;
@@ -930,19 +534,17 @@ export class Master {
930
534
  this.raw.llm = llm;
931
535
  if (body.password)
932
536
  this.raw.password = body.password;
933
- // learn the window size up front so compaction derives 75% of it
934
537
  try {
935
538
  const list = await fetchModelList(llm.baseUrl, body.apiKey);
936
539
  const cl = contextLengthFor(list, llm.model);
937
540
  if (cl) {
938
541
  this.config.contextWindowTokens = cl;
939
542
  this.raw.contextWindowTokens = cl;
940
- raw.contextWindowTokens = cl; // also persist into the fresh config file
543
+ raw.contextWindowTokens = cl;
941
544
  writeFileSync(this.configPath, JSON.stringify(raw, null, 2) + "\n");
942
545
  }
943
546
  }
944
547
  catch {
945
- /* metadata is best-effort */
946
548
  }
947
549
  let agentId;
948
550
  if (body.workspace?.trim()) {
@@ -961,25 +563,6 @@ export class Master {
961
563
  }
962
564
  return { ok: true, agentId };
963
565
  }
964
- /**
965
- * Create an agent; optionally persist it to the config file.
966
- * Each incarnation gets its own session directory under
967
- * <dataDir>/sessions/<agentId>-<uuid>/ (chat.jsonl, goal.md, memory.md).
968
- * Restarts reuse the latest existing session; fresh creations never touch
969
- * an older one's history.
970
- */
971
- /**
972
- * Fire the model-catalogue lookup for every distinct endpoint, concurrently.
973
- *
974
- * `fetchModelList` already shares an in-flight request per baseUrl, so this is
975
- * about STARTING them together: without it the requests begin one at a time, as
976
- * the agent loop reaches each provider, and a slow endpoint's timeout is paid
977
- * serially by every agent on it.
978
- *
979
- * Best-effort by design — nothing awaits this. A failure is cached briefly by
980
- * `fetchModelList`, and an agent whose lookup is not ready simply gets no
981
- * inferred window, which is the same outcome as today's timeout.
982
- */
983
566
  prefetchModelLists() {
984
567
  const endpoints = new Set();
985
568
  const add = (baseUrl, apiKey) => {
@@ -995,22 +578,10 @@ export class Master {
995
578
  const prov = this.config.providers?.[ac.provider ?? this.config.defaultProvider ?? ""];
996
579
  add(ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl, ac.apiKey ?? prov?.apiKey ?? this.config.llm.apiKey);
997
580
  }
998
- // no configured agents yet (first-run wizard) — still warm the default so the
999
- // first created agent does not wait
1000
581
  if (endpoints.size === 0)
1001
582
  add(this.config.llm.baseUrl, this.config.llm.apiKey);
1002
583
  }
1003
584
  async addAgent(ac, opts = {}) {
1004
- // an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
1005
- // must be a safe path segment. Every caller sanitised its own id — the HTTP
1006
- // route, spawnChildFor's child name — except the ACP adapter, which passed
1007
- // the raw client string through, so `session/load {"sessionId":"../../x"}`
1008
- // created an agent logged outside <dataDir>/sessions/.
1009
- //
1010
- // Sanitised HERE as well as at the call sites: this is the one place that
1011
- // guarantees the property, so a future caller cannot reintroduce the escape
1012
- // by forgetting. A caller that already sanitised is unaffected — the
1013
- // transform is idempotent on a safe id.
1014
585
  const safeId = sanitizeAgentId(ac.id);
1015
586
  if (safeId !== ac.id) {
1016
587
  console.warn(`[teapot] agent id ${JSON.stringify(ac.id)} contained unsafe characters; using ${JSON.stringify(safeId)}`);
@@ -1018,22 +589,15 @@ export class Master {
1018
589
  ac = { ...ac, id: safeId };
1019
590
  if (this.agents.has(ac.id))
1020
591
  throw new Error(`agent id already exists: ${ac.id}`);
1021
- // provider resolution: inline overrides > named provider > legacy llm block
1022
592
  const provName = ac.provider ?? this.config.defaultProvider ?? "openrouter";
1023
593
  const prov = this.config.providers?.[provName];
1024
594
  if (!prov && !ac.baseUrl && !this.config.llm.baseUrl) {
1025
595
  throw new Error(`agent ${ac.id}: unknown provider "${provName}" and no fallback`);
1026
596
  }
1027
- // an INLINE `ac.apiKey` is this agent's own credential, so it must NOT be
1028
- // joined to the provider's pool — the pool belongs to the named provider, and
1029
- // folding a one-off override in would rotate an agent onto keys its operator did
1030
- // not choose for it. Only the named provider gets a pool.
1031
597
  const inlineKey = typeof ac.apiKey === "string" && ac.apiKey.trim() ? ac.apiKey.trim() : undefined;
1032
598
  const pool = inlineKey ? undefined : this.keyPoolFor(provName, prov);
1033
599
  const llm = {
1034
600
  baseUrl: ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl,
1035
- // the pool's CURRENT key is the sticky one: a restart must resume on the
1036
- // account the operator's cycle had reached, not start over on a spent first one
1037
601
  apiKey: inlineKey ??
1038
602
  currentKey(pool) ??
1039
603
  prov?.apiKey ??
@@ -1044,23 +608,7 @@ export class Master {
1044
608
  };
1045
609
  if (!llm.model)
1046
610
  throw new Error(`agent ${ac.id}: no model configured (set model on the agent or on its provider)`);
1047
- // spawn-tree depth: computed from the config chain (ac may not be
1048
- // registered yet when this runs — spawnChildFor persists AFTER creation)
1049
611
  const myDepth = ac.parent ? this.depthOf(ac.parent) + 1 : 0;
1050
- // Model metadata: the context window (for the compaction budget) and the
1051
- // catalogue entry that gates reasoning_effort support come from the
1052
- // SAME `/models` list.
1053
- //
1054
- // boot must not WAIT for this. It only tunes the compaction budget and
1055
- // the effort controls, so a missing list costs nothing that matters; what it
1056
- // cost was boot time — measured at 4.4s of pure idle on 493 agents pointed at
1057
- // one unreachable endpoint, because the `Promise.race` timeout below is
1058
- // per-CALL and the shared request stayed pending for every agent.
1059
- //
1060
- // So: use the catalogue if `prefetchModelLists()` has already produced it
1061
- // (the common case — the lookups were fired when boot started), and wait only
1062
- // ONCE per endpoint for the stragglers, where the wait overlaps a request
1063
- // that is already in flight.
1064
612
  const needsWindow = !ac.contextWindowTokens && !this.config.contextWindowTokens;
1065
613
  const root = llm.baseUrl.replace(/\/+$/, "");
1066
614
  let list = peekModelList(llm.baseUrl);
@@ -1068,7 +616,6 @@ export class Master {
1068
616
  this.metaWaited.add(root);
1069
617
  list = await Promise.race([
1070
618
  fetchModelList(llm.baseUrl, llm.apiKey),
1071
- // a placeholder so the race can settle without the list
1072
619
  new Promise((r) => setTimeout(() => r(undefined), MODEL_META_BOOT_MS)),
1073
620
  ]);
1074
621
  }
@@ -1076,18 +623,10 @@ export class Master {
1076
623
  const modelMeta = list?.find((m) => m.id === llm.model);
1077
624
  const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
1078
625
  await mkdirSync(sessionDir, { recursive: true });
1079
- // every session dir this master hands out carries an ownership manifest —
1080
- // legacy dirs get backfilled on their first restart under the new code
1081
626
  this.writeSessionManifest(sessionDir, ac.id);
1082
627
  const agent = new Agent({
1083
628
  id: ac.id,
1084
629
  workspace: resolveWorkspace(ac.workspace, path.dirname(this.configPath)),
1085
- /**
1086
- * Derive the session label when the first user prompt lands, and STORE it.
1087
- *
1088
- * The alternative — reading every session log while listing — was the dominant cost of
1089
- * `/api/sessions`. Doing it here pays once per session instead of once per list.
1090
- */
1091
630
  onFirstUserPrompt: (sessionId) => {
1092
631
  void this.ensureSessionTitle(ac.id, sessionId);
1093
632
  },
@@ -1099,14 +638,7 @@ export class Master {
1099
638
  ...(this.config.onError ? { onError: this.config.onError } : {}),
1100
639
  ...(this.config.retryDelayMs ? { retryDelayMs: this.config.retryDelayMs } : {}),
1101
640
  autoContinue: ac.autoContinue ?? true,
1102
- // BUG found while wiring #161: `autoCompact` was a field on AgentConfig and on
1103
- // AgentOptions, but nothing ever carried it between the two — so a configured
1104
- // `"autoCompact": false` was silently ignored and compaction stayed on. Passed
1105
- // here for the same reason as shortContext: a config knob that does nothing is
1106
- // worse than no knob.
1107
641
  ...(ac.autoCompact === false ? { autoCompact: false } : {}),
1108
- // per-agent choice, else the global default. This mode DISCARDS information
1109
- // early by design, so it must never switch on by accident.
1110
642
  ...((ac.shortContext ?? this.config.shortContext) ? { shortContext: true } : {}),
1111
643
  ...(this.config.contextTokenBudget ? { contextTokenBudget: this.config.contextTokenBudget } : {}),
1112
644
  ...(ac.contextWindowTokens || this.config.contextWindowTokens || inferredWindow
@@ -1118,8 +650,6 @@ export class Master {
1118
650
  : {}),
1119
651
  globalSkillsDir: path.join(CONFIG_DIR, "skills"),
1120
652
  bundledSkillsDir: BUNDLED_SKILLS_DIR,
1121
- // a plugin's own skills/ is a skill root, ranked with the global ones so
1122
- // a workspace skill still wins on a name clash
1123
653
  pluginSkillRoots: this.plugins
1124
654
  .filter((p) => p.skillsDir)
1125
655
  .map((p) => ({ dir: p.skillsDir, source: `plugin:${p.name}` })),
@@ -1128,58 +658,24 @@ export class Master {
1128
658
  ...(ac.readOnly ? { readOnlyTools: true } : {}),
1129
659
  ...(ac.parent ? { parent: ac.parent } : {}),
1130
660
  ...(ac.chatFn ? { chatFn: ac.chatFn } : {}),
1131
- // reasoning effort is a per-agent choice; the catalogue decides
1132
- // whether it can actually be sent (see effortForRequest)
1133
661
  ...(ac.reasoningEffort ? { reasoningEffort: ac.reasoningEffort } : {}),
1134
- ...(modelMeta?.supportedParameters
1135
- ? { supportedParameters: modelMeta.supportedParameters }
1136
- : {}),
1137
- // the model's own reasoning levels, so the picker offers exactly those
1138
662
  ...(modelMeta?.supportedEfforts?.length ? { supportedEfforts: modelMeta.supportedEfforts } : {}),
1139
663
  });
1140
- // pricing metadata (USD/token) for the runtime cost estimate — best
1141
- // effort: providers without pricing in /models simply show no cost
1142
664
  void fetchModelList(llm.baseUrl, llm.apiKey)
1143
665
  .then((list) => agent.setModelPricing(list.find((m) => m.id === llm.model)?.pricing))
1144
666
  .catch(() => { });
1145
- // console line + broadcast: the web UI only refreshes on bus traffic, so
1146
- // every appended event must reach it (otherwise messages sit invisible
1147
- // until an unrelated status change happens to fire)
1148
667
  agent.log.onEvent = (e) => {
1149
668
  printAgentEvent(e);
1150
669
  bus.emit("update", { kind: "event", agentId: e.agent, event: e });
1151
670
  this.onChildEvent(ac, e);
1152
671
  };
1153
- // the CONTROL PLANE, separate from the event pipeline above.
1154
- //
1155
- // `onChildEvent` used to be what woke a parent's `wait_children`, and it runs
1156
- // after the event is persisted. So a failed or slow write left the parent
1157
- // parked forever — measured with every write failing, the wake never fired.
1158
- //
1159
- // A status change is CONTROL FLOW, not an observation, so it gets its own
1160
- // immediate path. `onChildEvent` keeps its other job (mirroring the child's
1161
- // activity into the parent's timeline, forwarding its final report), which
1162
- // legitimately belongs to the event stream.
1163
672
  agent.onControlStatusChange = (agentId, status) => {
1164
673
  if (!ac.parent)
1165
674
  return;
1166
675
  const parent = this.agents.get(ac.parent);
1167
676
  if (!parent)
1168
677
  return;
1169
- // the waiter re-checks whether ITS targets are all settled, so waking on
1170
- // every movement is correct and cheap. ANCESTORS too: a waiter's targets
1171
- // now span the whole subtree, so a grandchild's movement must reach it.
1172
678
  this.wakeAncestorWaiters(ac.parent);
1173
- // report only a SETTLED child, never its birth. A freshly spawned
1174
- // child is `idle` before `start()` runs, so that first idle->running flip
1175
- // would tell the parent "@kid is now idle" the instant it existed.
1176
- // Requiring a prior `running` is what filters it out; the wake above is
1177
- // unconditional, so `wait_children` is unaffected.
1178
- //
1179
- // the wording addresses the CHILD's outcome, not the parent's goal.
1180
- // "Continue your task, or finish()..." invited the parent to finish its own
1181
- // goal over a child's status; the child's report already states what
1182
- // happened, so this points at it instead.
1183
679
  if (this.reportedRunning.has(agentId) && SETTLED.has(status))
1184
680
  parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
1185
681
  (status === "error"
@@ -1192,24 +688,15 @@ export class Master {
1192
688
  else if (SETTLED.has(status))
1193
689
  this.reportedRunning.delete(agentId);
1194
690
  };
1195
- // sub-agent management hooks — only when this agent can legally spawn
1196
691
  if (myDepth < this.maxSpawnDepth()) {
1197
692
  const self = agent;
1198
693
  const hooks = {
1199
694
  depth: myDepth,
1200
695
  spawn: (o) => this.spawnChildFor(self, o),
1201
- list: () =>
1202
- // DIRECT children are the ones this agent can address (stop/message are
1203
- // refused for anything deeper), but a descendant still working is
1204
- // invisible to the parent unless it is LISTED — and the parent then
1205
- // resumes over it. So the whole subtree is listed, each row naming its
1206
- // own `parent` and flagged `nested`, which is what tells the model who
1207
- // to address instead of guessing a level (the error `message_agent`
1208
- // raises names the owner for the same reason).
1209
- this.subtreeOf(ac.id).flatMap(({ id, depth, parent }) => {
696
+ list: () => this.subtreeOf(ac.id).flatMap(({ id, depth, parent }) => {
1210
697
  const inst = this.agents.get(id);
1211
698
  if (!inst)
1212
- return []; // suspended: not addressable, so not listed
699
+ return [];
1213
700
  return [
1214
701
  {
1215
702
  id,
@@ -1222,11 +709,7 @@ export class Master {
1222
709
  }),
1223
710
  stop: (ids) => this.stopChildrenFor(ac.id, ids),
1224
711
  message: (id, text) => this.messageChild(ac.id, id, text),
1225
- wait: (ids, ms) =>
1226
- // wire the parent's tool-abort signal: a user stop / dispose must
1227
- // interrupt the park immediately instead of leaving the run chain
1228
- // blocked until the full timeout (up to 1h)
1229
- this.waitChildren(ac.id, ids, ms, self.toolCtx.signal),
712
+ wait: (ids, ms) => this.waitChildren(ac.id, ids, ms, self.toolCtx.signal),
1230
713
  };
1231
714
  agent.toolCtx.subAgents = hooks;
1232
715
  }
@@ -1236,39 +719,23 @@ export class Master {
1236
719
  this.config.agents.push(ac);
1237
720
  this.saveConfig();
1238
721
  }
1239
- // Remember which session this agent is bound to, so a restart reattaches
1240
- // deterministically instead of re-guessing from directory mtimes.
1241
- // Done for EVERY agent (persisted or spawned): a spawned child is re-created
1242
- // on boot too, and guessing there is the same resurrection bug.
1243
722
  this.recordSession(ac, path.basename(sessionDir));
1244
723
  return agent;
1245
724
  }
1246
- /** Persist an agent's current session id into the config, if it changed. */
1247
725
  recordSession(ac, sessionId) {
1248
726
  if (!sessionId || ac.session === sessionId)
1249
727
  return;
1250
728
  ac.session = sessionId;
1251
729
  const entry = this.config.agents.find((a) => a.id === ac.id);
1252
730
  if (!entry)
1253
- return; // not a configured agent — nothing to persist yet
731
+ return;
1254
732
  entry.session = sessionId;
1255
- /**
1256
- * A title names ONE session, so moving clears it.
1257
- *
1258
- * Leaving it would label the new session with the previous one's first prompt — the sidebar
1259
- * would show a conversation name for a conversation it does not describe. It is re-derived on
1260
- * the next prompt (see `ensureSessionTitle`).
1261
- */
1262
733
  delete entry.sessionTitle;
1263
734
  delete ac.sessionTitle;
1264
- // never write session pointers into a config that is describing another
1265
- // data dir — every one of them would be wrong, and the correct mapping is
1266
- // destroyed. The in-memory session above still works for this process.
1267
735
  if (this.sessionsReadOnly)
1268
736
  return;
1269
737
  this.saveConfig();
1270
738
  }
1271
- /** spawn-tree depth of an agent (0 = top level); unknown → 0 */
1272
739
  depthOf(id, seen = new Set()) {
1273
740
  const cap = this.maxSpawnDepth();
1274
741
  let depth = 0;
@@ -1280,12 +747,10 @@ export class Master {
1280
747
  }
1281
748
  return depth;
1282
749
  }
1283
- /** configured nesting limit (config.maxSpawnDepth, default 3) */
1284
750
  maxSpawnDepth() {
1285
751
  const v = this.config.maxSpawnDepth;
1286
752
  return typeof v === "number" && v >= 0 ? v : MAX_SPAWN_DEPTH;
1287
753
  }
1288
- /** direct children of an agent, with their Agent instances */
1289
754
  childrenOf(id) {
1290
755
  const kids = this.config.agents.filter((a) => a.parent === id);
1291
756
  const out = [];
@@ -1296,19 +761,6 @@ export class Master {
1296
761
  }
1297
762
  return out;
1298
763
  }
1299
- /**
1300
- * Every configured descendant of `id`, at any depth, with its depth (1 = direct
1301
- * child) and its own parent id.
1302
- *
1303
- * A parent's work is not finished while anything it caused is still running,
1304
- * and that includes a sub-agent's sub-agent: C can delegate to G and return its
1305
- * own turn, leaving C idle while G works. Deciding from DIRECT children alone
1306
- * therefore let an ancestor report "all settled" over a live grandchild — the
1307
- * reported "it delegates further and the parent never knows".
1308
- *
1309
- * Returns configured ids (not instances) so a suspended descendant still counts
1310
- * as part of the subtree. `seen` guards a cycle in `parent`.
1311
- */
1312
764
  subtreeOf(id, depth = 1, seen = new Set()) {
1313
765
  const out = [];
1314
766
  for (const a of this.config.agents) {
@@ -1321,16 +773,9 @@ export class Master {
1321
773
  }
1322
774
  return out;
1323
775
  }
1324
- /** the ids of every descendant of `id`, at any depth */
1325
776
  descendantsOf(id, seen = new Set()) {
1326
777
  return this.subtreeOf(id, 1, seen).map((x) => x.id);
1327
778
  }
1328
- /**
1329
- * Create a sub-agent on behalf of `parent`. context "fork" writes a
1330
- * sub_fork header into the child log pointing at the parent's current
1331
- * tip — the parent's history is NEVER copied into the child's file; the
1332
- * child resolves it at restore time.
1333
- */
1334
779
  async spawnChildFor(parent, o) {
1335
780
  const parentId = parent.opts_id();
1336
781
  const persona = o.persona && Object.prototype.hasOwnProperty.call(SUB_PERSONAS, o.persona)
@@ -1341,34 +786,14 @@ export class Master {
1341
786
  let n = 2;
1342
787
  while (this.agents.has(id) || this.config.agents.some((a) => a.id === id))
1343
788
  id = `${base.slice(0, 56)}-${n++}`;
1344
- // persona directives shape how the sub approaches the task
1345
789
  const directive = persona ? `${SUB_PERSONAS[persona].label}\n${SUB_PERSONAS[persona].directive}\n\n` : "";
1346
790
  const pcfg = this.config.agents.find((a) => a.id === parentId);
1347
791
  let forkTip;
1348
792
  let forkBranch;
1349
793
  if (o.context === "fork") {
1350
- // capture the fork tip BEFORE creating the child (parent keeps running)
1351
794
  forkTip = parent.log.lastEventId(parent.currentBranch);
1352
795
  forkBranch = parent.currentBranch;
1353
796
  }
1354
- // reasoning effort for the child.
1355
- //
1356
- // Precedence mirrors OpenAI Codex (codex-rs/core/src/agent/child_config.rs:
1357
- // `requested.or_else(configured_default).or(parent)`):
1358
- //
1359
- // 1. the spawn argument — explicit, per-call
1360
- // 2. `defaultSubagentReasoningEffort` from config — a global backstop
1361
- // 3. the parent's effort — last resort
1362
- //
1363
- // Codex deliberately does NOT inherit by default, and its own test proves the
1364
- // intent: parent at XHigh, spawn requests Low, and the child runs at Low
1365
- // (codex-rs/core/tests/suite/subagent_notifications.rs). Sub-agents are
1366
- // usually narrow and cheap, so inheriting a parent's `high` across twenty
1367
- // trivial scouts multiplies cost and latency for no gain.
1368
- //
1369
- // Before this, `reasoningEffort` was simply omitted here while `provider`,
1370
- // `model` and `contextWindowTokens` two lines below WERE inherited — so a
1371
- // parent's effort vanished silently, with no way for a spawn to express one.
1372
797
  const childEffort = o.reasoning_effort || this.config.defaultSubagentReasoningEffort || pcfg?.reasoningEffort;
1373
798
  const child = await this.addAgent({
1374
799
  id,
@@ -1376,8 +801,6 @@ export class Master {
1376
801
  provider: pcfg?.provider,
1377
802
  model: pcfg?.model,
1378
803
  contextWindowTokens: pcfg?.contextWindowTokens,
1379
- // persisted with the child, like every other inherited field, so a
1380
- // restart restores the same effort rather than reverting to the default
1381
804
  ...(childEffort ? { reasoningEffort: childEffort } : {}),
1382
805
  parent: parentId,
1383
806
  ...(persona && SUB_PERSONAS[persona]?.readOnly ? { readOnly: true } : {}),
@@ -1389,15 +812,9 @@ export class Master {
1389
812
  parentBranch: forkBranch,
1390
813
  upToEvent: forkTip,
1391
814
  });
1392
- // the inherited prefix becomes visible history for the child's loop
1393
815
  child.importMessages(parent.exportMessages());
1394
816
  }
1395
817
  await child.setGoal(`${directive}${o.task}`.slice(0, 2000));
1396
- // SCOPE GUARD: a forked child inherits the parent's whole conversation as
1397
- // reference context — without an explicit boundary, models have been seen
1398
- // "continuing" some EARLIER task that appears in that history instead of
1399
- // the task they were actually spawned for. State the boundary twice
1400
- // (goal + first prompt) so it survives goal-driven auto-continue.
1401
818
  await child.enqueuePrompt((o.context === "fork"
1402
819
  ? `[harness] You are sub-agent ${id}, spawned by @${parentId}.
1403
820
 
@@ -1416,68 +833,28 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1416
833
  console.log(`[teapot] sub-agent ${id} spawned by ${parentId} (context: ${o.context ?? "none"}${persona ? `, persona: ${persona}` : ""})`);
1417
834
  return { id };
1418
835
  }
1419
- /** Stop direct children (and their descendants by default) of an agent. */
1420
836
  async stopChildrenFor(parentId, ids) {
1421
837
  const stopped = [];
1422
- // `inScope` is true once we are INSIDE a subtree we were asked to stop, and
1423
- // stays true all the way down.
1424
- //
1425
- // the old walk passed `ids` down unchanged, so after matching an
1426
- // explicit id every DESCENDANT failed `ids.includes(id)` and was skipped —
1427
- // which is why the explicit branch hand-rolled its own one-level loop, and
1428
- // why that loop could not reach a great-grandchild.
1429
838
  const walk = (pid, inScope) => {
1430
839
  for (const { id, agent } of this.childrenOf(pid)) {
1431
840
  const take = inScope || !ids || ids.includes(id);
1432
841
  if (!take) {
1433
- // out of scope, but its own subtree may hold something in scope
1434
842
  walk(id, false);
1435
843
  continue;
1436
844
  }
1437
845
  agent.stop("stopped by parent agent");
1438
846
  stopped.push(id);
1439
- // RECURSE for both modes.
1440
- //
1441
- // The explicit-id branch used to iterate `childrenOf(id)` exactly once, so
1442
- // a great-grandchild was never reached — stopping a child left its
1443
- // grandchild's child running, indefinitely, still holding a model
1444
- // connection and billing tokens:
1445
- //
1446
- // root-sub stopped
1447
- // root-sub-sub stopped
1448
- // root-sub-sub-sub running <-- orphaned
1449
- //
1450
- // `walk` already means "this subtree", and it stops every node it visits,
1451
- // so one code path serves both cases. The only difference between the two
1452
- // modes is the `ids && !ids.includes(id)` test at the top, which decides
1453
- // whether a node is in scope — not how deep the walk goes.
1454
847
  walk(id, true);
1455
848
  }
1456
849
  };
1457
850
  walk(parentId, false);
1458
851
  return { stopped };
1459
852
  }
1460
- /** Deliver a parent's message into a child's mailbox (and wake it). */
1461
- /**
1462
- * Event-driven parking for a parent waiting on sub-agents: resolves as soon
1463
- * as ANY listed child settles (finish/error/stop/waiting) — zero API cost
1464
- * while parked. Falls back to a timeout so waits can never hang forever.
1465
- */
1466
853
  waitChildren(parentId, ids, timeoutMs, signal) {
1467
- // The whole DESCENDANT subtree, not just direct children: C can delegate to G
1468
- // and return its own turn, so C reads idle while G works. Waiting on C must
1469
- // therefore mean "C and everything C caused" — otherwise an ancestor resumes
1470
- // over live work it cannot see (see descendantsOf).
1471
854
  const named = ids && ids.length ? ids : this.descendantsOf(parentId);
1472
855
  const targets = [...new Set(named.flatMap((id) => [id, ...this.descendantsOf(id)]))].filter((id) => this.agents.has(id));
1473
856
  if (targets.length === 0)
1474
857
  return Promise.resolve({ note: "no live sub-agents to wait for" });
1475
- // isBusy(), not the raw status: parkForTool overwrites a parked child's
1476
- // status to "idle" (spinner suppression) while its loop is very much
1477
- // alive — the raw check counted that child as settled and let the parent
1478
- // resume over unfinished work. isBusy() sees parked tools, compactions
1479
- // and an open ask_user; queued prompts are DORMANT (isLive()'s "stop vs
1480
- // start" extra) and must not hold the wait. A stop request reads settled.
1481
858
  const active = () => targets.filter((id) => this.agents.get(id)?.isBusy());
1482
859
  const first = active();
1483
860
  if (first.length === 0)
@@ -1502,7 +879,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1502
879
  const wake = (_note) => {
1503
880
  const left = active();
1504
881
  if (left.length > 0)
1505
- return; // something is still working — keep parked
882
+ return;
1506
883
  clearTimeout(timer);
1507
884
  finish(`all sub-agents settled: ${targets.join(", ")}`);
1508
885
  };
@@ -1510,12 +887,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1510
887
  if (!this.childWaiters.has(parentId))
1511
888
  this.childWaiters.set(parentId, set);
1512
889
  set.add(wake);
1513
- // re-check immediately: children may have settled between snapshot & register
1514
890
  if (active().length === 0)
1515
891
  wake();
1516
892
  });
1517
893
  }
1518
- /** wake every waiter of a parent (child lifecycle event happened) */
1519
894
  wakeParentWaiters(parentId) {
1520
895
  const set = this.childWaiters.get(parentId);
1521
896
  if (!set)
@@ -1524,22 +899,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1524
899
  try {
1525
900
  fn("child event");
1526
901
  }
1527
- catch { /* ignore */ }
1528
- }
1529
- }
1530
- /**
1531
- * Wake the waiters of EVERY ancestor of an agent, not just its direct parent.
1532
- *
1533
- * A parked `wait_children` is woken by lifecycle events, and its targets now
1534
- * span the whole subtree — so a grandchild settling has to reach the
1535
- * grandparent, or the wake never arrives and the ancestor sits until its
1536
- * timeout even though the work finished.
1537
- *
1538
- * `parentId` is passed in rather than looked up: the caller already has it, and
1539
- * a spawned child that is not PERSISTED (a test, or a transient spawn) is absent
1540
- * from `config.agents`, so a lookup would find no parent at all and the wake
1541
- * would be silently skipped.
1542
- */
902
+ catch { }
903
+ }
904
+ }
1543
905
  wakeAncestorWaiters(parentId) {
1544
906
  const seen = new Set();
1545
907
  let cur = parentId;
@@ -1550,24 +912,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1550
912
  }
1551
913
  }
1552
914
  async messageChild(parentId, childId, text) {
1553
- // childId is normally the live agent id — but an internal session id
1554
- // (sessions/<id>, the stable timeline handle) also resolves, so callers
1555
- // holding only a session reference can still reach the owning agent.
1556
915
  let targetId = childId;
1557
916
  const owned = this.sessionById(childId);
1558
917
  if (owned)
1559
918
  targetId = owned.agentId;
1560
- // a sub-agent must be able to message its OWN PARENT.
1561
- //
1562
- // The rule below only ever permitted parent -> child, so a child asking its
1563
- // parent a question was refused with the actively misleading
1564
- //
1565
- // not your sub-agent: p1. no such sub-agent exists.
1566
- //
1567
- // — naming the parent as nonexistent, when it is the caller's own parent and
1568
- // plainly listed in config.agents. The parent link is the trust boundary: a
1569
- // child may address its parent, a parent may address its children, and
1570
- // NOTHING may address a sibling or an unrelated agent.
1571
919
  const caller = this.config.agents.find((a) => a.id === parentId);
1572
920
  const target = this.config.agents.find((a) => a.id === targetId);
1573
921
  const childToParent = target?.parent === parentId;
@@ -1575,7 +923,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1575
923
  const p = this.agents.get(targetId);
1576
924
  if (!p)
1577
925
  throw new Error(`sub-agent not running: ${targetId}`);
1578
- // mirrored wording, so the recipient knows who is writing
1579
926
  p.enqueuePrompt(`[harness] Message from sub-agent @${parentId}:\n\n${text}`, "harness");
1580
927
  if (p.status !== "running")
1581
928
  p.start(`message from sub-agent ${parentId}`);
@@ -1583,10 +930,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1583
930
  }
1584
931
  const cfg = target;
1585
932
  if (!cfg || (!childToParent && cfg.parent !== parentId)) {
1586
- // Scoping is correct — only the DIRECT parent may message a child, so a
1587
- // grandchild is rightly refused. But "not your sub-agent: <id>" reads
1588
- // like a bug when the agent simply guessed the wrong level of the tree.
1589
- // Name the real owner so the model can re-address the message.
1590
933
  const actual = cfg?.parent;
1591
934
  const live = actual ? this.agents.get(actual) : undefined;
1592
935
  const detail = actual
@@ -1601,96 +944,45 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1601
944
  if (child.status !== "running")
1602
945
  child.start(`message from ${parentId}`);
1603
946
  }
1604
- /**
1605
- * Mirror interesting child events into the parent's timeline (tagged with
1606
- * the acting sub id) and forward terminal outcomes to the parent's mailbox.
1607
- */
1608
947
  onChildEvent(ac, e) {
1609
948
  const parentId = ac.parent;
1610
949
  if (!parentId)
1611
950
  return;
1612
- // any child lifecycle movement wakes a parked wait_children (the waiter
1613
- // re-checks whether its targets are all settled) — ancestors included, since
1614
- // a waiter's targets span its whole subtree
1615
951
  this.wakeAncestorWaiters(parentId);
1616
952
  const parent = this.agents.get(parentId);
1617
953
  if (!parent)
1618
954
  return;
1619
- // forward outcomes so the parent hears the result without polling
1620
955
  if (e.type === "message" && e.data.final) {
1621
956
  const d = e.data;
1622
957
  const summary = String(d.content ?? "").trim();
1623
- // the finish() summary alone is often thin — the agent's last few real
1624
- // conversation turns (findings, paths, numbers) carry the substance.
1625
- // Include them so the parent can act without re-reading the child log.
1626
958
  let report = summary;
1627
959
  if (Array.isArray(d.recentContext) && d.recentContext.length) {
1628
960
  report +=
1629
961
  "\n\n[Last conversation turns before finishing]\n" +
1630
962
  d.recentContext.map((r) => r).join("\n\n");
1631
963
  }
1632
- parent.enqueuePrompt(
1633
- // NO truncation: the sub's report is the parent's primary input for
1634
- // deciding what to do next. Clipping it (the old 2000/6000 caps)
1635
- // silently dropped exactly the details the operator spawned the sub
1636
- // to gather. Context management is compaction's job, not the pipe's.
1637
- `[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
1638
- // a parent's report is delivered INTO a live agent — queueing it is
1639
- // not enough. If the parent is idle (finished, or parked) the report sat in
1640
- // the queue until something else started the loop, so the operator saw a
1641
- // finished sub-agent and a parent that never reacted.
964
+ parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
1642
965
  if (parent.status !== "running")
1643
966
  parent.start(`sub-agent ${ac.id} finished`);
1644
967
  }
1645
968
  else if (e.type === "error") {
1646
969
  parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
1647
- // same for an error report
1648
970
  if (parent.status !== "running")
1649
971
  parent.start(`sub-agent ${ac.id} error`);
1650
972
  }
1651
- // a child's activity is NOT mirrored into the parent's log. Mirroring
1652
- // bought nothing — `sub` rows are display-only (`rebuildMessagesFrom` never
1653
- // read them, so the model never saw them) and once they DID render the
1654
- // parent's timeline was flooded with a child's transcript. What the parent
1655
- // needs is the child's report, which already arrives as a harness prompt
1656
- // above. A chat's timeline is its own.
1657
- }
1658
- /** sessions root + helpers */
1659
- /**
1660
- * Where session directories live.
1661
- *
1662
- * Public: it is the layout contract every part of the harness depends on, and a test
1663
- * that plants a directory (a foreign manifest, an impostor owner) needs the same path
1664
- * the master resolves rather than a re-derivation that can drift from it.
1665
- */
973
+ }
1666
974
  sessionsRoot() {
1667
975
  return path.join(this.config.dataDir, "sessions");
1668
976
  }
1669
- /**
1670
- * Session-dir ownership manifest (sessions/<dir>/session.json). Ownership
1671
- * used to be GUESSED from the directory name's prefix, which is ambiguous:
1672
- * agent "teapot" matches "teapot-3-<hash>", and sibling ids where one is a
1673
- * prefix of the other ("prismrv-root-sub-review" vs
1674
- * "…-review-kernel") excluded each other's own dirs — every restart then
1675
- * generated a fresh empty session and history read as zero. The owner is
1676
- * now recorded explicitly at creation time; legacy dirs without a manifest
1677
- * fall back to longest-prefix inference and get one backfilled on first
1678
- * touch.
1679
- */
1680
977
  readSessionOwner(dirName) {
1681
978
  try {
1682
979
  const raw = JSON.parse(readFileSync(path.join(this.sessionsRoot(), dirName, "session.json"), "utf8"));
1683
980
  return typeof raw.agentId === "string" ? raw.agentId : null;
1684
981
  }
1685
982
  catch {
1686
- return null; // legacy dir or unreadable manifest → fall back to names
983
+ return null;
1687
984
  }
1688
985
  }
1689
- /**
1690
- * Owner of a session dir — manifest first; legacy dirs are inferred from
1691
- * the LONGEST configured/live id whose "<id>" or "<id>-<rest>" shape the
1692
- * name matches. Returns {owner, hadManifest} so callers can backfill.
1693
- */
1694
986
  inferSessionOwner(dirName, candidateIds) {
1695
987
  const fromManifest = this.readSessionOwner(dirName);
1696
988
  if (fromManifest)
@@ -1704,37 +996,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1704
996
  }
1705
997
  return { owner: best?.id ?? null, hadManifest: false };
1706
998
  }
1707
- /**
1708
- * Who does this session directory really belong to, according to its LOG?
1709
- *
1710
- * The manifest is a CACHE; the log is the truth. Every event carries the
1711
- * `agent` that wrote it, and only that agent ever appends — so the first
1712
- * event's value cannot be stale or wrong.
1713
- *
1714
- * This matters because ownership decided from the manifest ALONE is how a
1715
- * whole data directory gets abandoned. A manifest that is missing, unreadable,
1716
- * or simply wrong made `inferSessionOwner` reject every directory the agent
1717
- * owned, `findLatestSessionDir` return null, and the boot mint a fresh empty
1718
- * session — REWRITING the agent's pointer in config.json. Nothing warned, and
1719
- * the real history was left on disk, orphaned and unreachable.
1720
- *
1721
- * manifests: 493 agents were silently moved to empty sessions, and the
1722
- * mapping back was only recoverable from these `agent` fields.
1723
- */
1724
- /**
1725
- * The owning agent, from the log's FIRST line.
1726
- *
1727
- * Read with a bounded `readSync` rather than `readFileSync(...).split("\n").find(...)`.
1728
- *
1729
- * MEASURED on this machine: 831 session logs totalling 471.8MB, the largest 50.6MB, nine of them
1730
- * over 10MB. `readFileSync` decoded a whole log to reach its first line, and `split("\n")`
1731
- * allocated an array of every line in it — for ONE field of the first event. Called once per
1732
- * directory while resolving sessions, that is tens of megabytes of transient allocation per pass,
1733
- * which is the baseline this boot carried.
1734
- *
1735
- * The first line is the first event (the log is append-only, in order), so a single small read
1736
- * answers the question.
1737
- */
1738
999
  logOwner(dirName) {
1739
1000
  let fd;
1740
1001
  try {
@@ -1743,7 +1004,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1743
1004
  const n = readSync(fd, buf, 0, buf.length, 0);
1744
1005
  if (n <= 0)
1745
1006
  return null;
1746
- // a torn final line cannot be the one we want: take the first COMPLETE line
1747
1007
  const head = buf.subarray(0, n).toString("utf8");
1748
1008
  const nl = head.indexOf("\n");
1749
1009
  const line = (nl >= 0 ? head.slice(0, nl) : head).trim();
@@ -1753,7 +1013,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1753
1013
  return typeof parsed.agent === "string" && parsed.agent ? parsed.agent : null;
1754
1014
  }
1755
1015
  catch {
1756
- return null; // no log / unreadable head — cannot prove anything
1016
+ return null;
1757
1017
  }
1758
1018
  finally {
1759
1019
  if (fd !== undefined) {
@@ -1761,39 +1021,17 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1761
1021
  closeSync(fd);
1762
1022
  }
1763
1023
  catch {
1764
- /* already closed */
1765
1024
  }
1766
1025
  }
1767
1026
  }
1768
1027
  }
1769
- /**
1770
- * A human title for a session dir, from its first user prompt, or null.
1771
- *
1772
- * Reads in bounded chunks and stops at the FIRST prompt event of any source —
1773
- * see the note in allSessions() for why that early exit is what makes this
1774
- * affordable. A torn final line is skipped rather than parsed: a chunk boundary
1775
- * can cut a JSON object in half, and that must not abort the scan.
1776
- */
1777
- /**
1778
- * The stored title for a session, if the agent's config entry names THAT session.
1779
- *
1780
- * A single config lookup — no filesystem access — which is the point: this runs once per session
1781
- * per list, and the log scan it replaced dominated the cost of listing.
1782
- */
1783
1028
  titleFor(agentId, sessionId) {
1784
1029
  const a = this.config.agents.find((x) => x.id === agentId);
1785
1030
  if (!a || a.session !== sessionId)
1786
- return undefined; // the label belongs to the CURRENT session
1031
+ return undefined;
1787
1032
  const t = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
1788
1033
  return t ? t : undefined;
1789
1034
  }
1790
- /**
1791
- * Derive and STORE a title for the agent's current session, once.
1792
- *
1793
- * Called when a session starts producing prompts, so the cost is paid per session rather than per
1794
- * list. Idempotent: an existing title is never overwritten, which is also what will let the
1795
- * operator rename a session by hand — a derived value must not clobber a chosen one.
1796
- */
1797
1035
  async ensureSessionTitle(agentId, sessionId) {
1798
1036
  const a = this.config.agents.find((x) => x.id === agentId);
1799
1037
  if (!a || a.session !== sessionId)
@@ -1801,8 +1039,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1801
1039
  if (a.sessionTitle?.trim())
1802
1040
  return;
1803
1041
  const dir = path.join(this.sessionsRoot(), sessionId);
1804
- // the session id when nothing is derivable — see the migration: it never changes, so this
1805
- // cannot leave the field unset and have the derivation re-run on every boot
1806
1042
  a.sessionTitle = this.sessionTitleOf(dir) ?? sessionId;
1807
1043
  this.saveConfig();
1808
1044
  }
@@ -1811,7 +1047,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1811
1047
  try {
1812
1048
  fd = openSync(path.join(dirPath, "chat.jsonl"), "r");
1813
1049
  const CHUNK = 16 * 1024;
1814
- // cap per session so one enormous log cannot dominate boot
1815
1050
  const CAP = 256 * 1024;
1816
1051
  const buf = Buffer.alloc(CHUNK);
1817
1052
  let offset = 0;
@@ -1831,12 +1066,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1831
1066
  e = JSON.parse(l);
1832
1067
  }
1833
1068
  catch {
1834
- continue; // torn line at a chunk boundary
1069
+ continue;
1835
1070
  }
1836
1071
  if (e.type !== "prompt")
1837
1072
  continue;
1838
- // a sub-agent session receives only harness prompts and is labelled from
1839
- // its id, so a harness prompt is not a title
1840
1073
  if (e.data?.source !== "user")
1841
1074
  return null;
1842
1075
  return sessionTitleFrom(typeof e.data.text === "string" ? e.data.text : null);
@@ -1845,7 +1078,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1845
1078
  return null;
1846
1079
  }
1847
1080
  catch {
1848
- return null; // no log / unreadable — fall back to the id
1081
+ return null;
1849
1082
  }
1850
1083
  finally {
1851
1084
  if (fd !== undefined)
@@ -1853,7 +1086,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1853
1086
  closeSync(fd);
1854
1087
  }
1855
1088
  catch {
1856
- /* already closed */
1857
1089
  }
1858
1090
  }
1859
1091
  }
@@ -1861,31 +1093,22 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1861
1093
  try {
1862
1094
  const file = path.join(dirPath, "session.json");
1863
1095
  if (existsSync(file)) {
1864
- // A WRONG manifest must be repairable, or the mistake is permanent: the
1865
- // old code returned here unconditionally, so one bad value kept every
1866
- // later boot from recognising the directory (see logOwner()).
1867
1096
  const current = this.readSessionOwner(path.basename(dirPath));
1868
1097
  if (current === agentId)
1869
1098
  return;
1870
1099
  const proven = this.logOwner(path.basename(dirPath));
1871
1100
  if (proven !== agentId)
1872
- return; // not provably ours — leave it alone
1101
+ return;
1873
1102
  console.log(`[teapot] repairing session manifest for ${path.basename(dirPath)}: ` +
1874
1103
  `${JSON.stringify(current)} → ${JSON.stringify(agentId)} (the log says ${JSON.stringify(agentId)})`);
1875
1104
  }
1876
1105
  writeFileSync(file, `${JSON.stringify({ v: 1, agentId }, null, 2)}\n`);
1877
1106
  }
1878
1107
  catch {
1879
- /* best-effort metadata */
1880
1108
  }
1881
1109
  }
1882
1110
  findLatestSessionDir(agentId) {
1883
- // Directory names alone are ambiguous: agent "teapot" prefix-matches
1884
- // "teapot-3-<hash>", and sibling ids where one is a prefix of the other
1885
- // ("prismrv-root-sub-review" vs "…-review-kernel") used to exclude each
1886
- // other's own dirs. Ownership comes from the manifest; legacy dirs are
1887
- // inferred by longest matching id.
1888
- const candidateIds = new Set([agentId]); // the agent itself owns its bare-name dir
1111
+ const candidateIds = new Set([agentId]);
1889
1112
  for (const a of this.config.agents)
1890
1113
  if (a.id)
1891
1114
  candidateIds.add(a.id);
@@ -1897,10 +1120,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1897
1120
  const empty = [];
1898
1121
  for (const d of readdirSync(this.sessionsRoot())) {
1899
1122
  const { owner } = this.inferSessionOwner(d, candidateIds);
1900
- // A wrong manifest must not hide a directory: consult the LOG before
1901
- // rejecting. Without this, one bad manifest value made every dir the
1902
- // agent owned invisible here, so the caller minted a fresh session and
1903
- // rewrote config — silently orphaning the real history.
1904
1123
  const mineByLog = owner !== agentId && this.logOwner(d) === agentId;
1905
1124
  if (owner !== agentId && !mineByLog)
1906
1125
  continue;
@@ -1908,20 +1127,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1908
1127
  this.writeSessionManifest(path.join(this.sessionsRoot(), d), agentId);
1909
1128
  const dirPath = path.join(this.sessionsRoot(), d);
1910
1129
  const m = this.sessionMtime(dirPath);
1911
- // an EMPTY chat.jsonl means the session was never really used — but it
1912
- // must not SHADOW a real history dir: an incarnation's state events
1913
- // keep refreshing the empty log's mtime, and newest-wins would then
1914
- // pick the empty one forever ("timeline of zero" on every restart).
1915
1130
  let size = 0;
1916
1131
  try {
1917
1132
  size = statSync(path.join(dirPath, "chat.jsonl")).size;
1918
1133
  }
1919
- catch { /* no log yet */ }
1134
+ catch { }
1920
1135
  (size > 0 ? withHistory : empty).push({ d, m });
1921
1136
  }
1922
1137
  const pick = (list) => list.sort((a, b) => b.m - a.m)[0]?.d;
1923
- // prefer a dir that actually carries history; fall back to the newest
1924
- // empty one ONLY when no history exists under this id at all
1925
1138
  dirs = [pick(withHistory) ?? pick(empty)].filter((x) => !!x);
1926
1139
  }
1927
1140
  catch {
@@ -1940,54 +1153,34 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1940
1153
  resolveSessionDir(agentId, fresh) {
1941
1154
  const root = this.sessionsRoot();
1942
1155
  mkdirSync(root, { recursive: true });
1943
- // the ≤0.5.0 flat-layout migration runs once at boot, in src/migrations/ — doing it here meant
1944
- // it ran per agent and could interleave with another agent's resolution
1945
- // Prefer the recorded session: the config says exactly which timeline this
1946
- // agent was on. Guessing by mtime let a stale-or-deleted dir whose
1947
- // chat.jsonl happened to be newer win, handing the agent a timeline the
1948
- // operator had moved off — the "deleted session comes back" report.
1949
1156
  const recorded = this.config.agents.find((a) => a.id === agentId)?.session;
1950
1157
  if (!fresh && recorded) {
1951
1158
  const dir = path.join(root, recorded);
1952
- // Trust it when it exists and is really THIS agent's dir. Ownership comes
1953
- // from the manifest, but the LOG is consulted too: a wrong/absent manifest
1954
- // must not make teapot abandon history it can plainly see (see logOwner).
1955
1159
  const owner = this.readSessionOwner(recorded);
1956
1160
  const mineByManifest = owner === null || owner === agentId;
1957
1161
  const mineByLog = owner !== agentId && this.logOwner(recorded) === agentId;
1958
1162
  if (existsSync(dir) && (mineByManifest || mineByLog)) {
1959
1163
  if (mineByLog)
1960
- this.writeSessionManifest(dir, agentId); // repair it
1164
+ this.writeSessionManifest(dir, agentId);
1961
1165
  return dir;
1962
1166
  }
1963
- // the recorded dir is gone, or provably belongs to someone else — fall
1964
- // through and mint a new one. Say WHICH, and do not do it quietly: this
1965
- // line is the only trace that an agent changed session.
1966
1167
  console.log(`[teapot] session ${recorded} for ${agentId} is unusable ` +
1967
1168
  `(${existsSync(dir) ? `manifest owner ${JSON.stringify(owner)}` : "directory missing"}); ` +
1968
1169
  `starting a new one`);
1969
1170
  }
1970
- // Prefer the LATEST session directory that actually carries history.
1971
- // This handles the case where a bare-name directory (e.g. from legacy
1972
- // migration) would otherwise shadow a newer UUID-suffixed incarnation
1973
- // (the "example-session-2 timeline empty" bug).
1974
1171
  if (!fresh) {
1975
1172
  const existing = this.findLatestSessionDir(agentId);
1976
1173
  if (existing)
1977
- return existing; // restart → continue where we left off
1174
+ return existing;
1978
1175
  }
1979
- // exact-restart match: bare <agentId> directory — only if no history-carrying
1980
- // UUID-suffixed directory was found above. This covers the very first run
1981
- // before any UUID-suffixed dir exists, and legacy flat migrations.
1982
1176
  const selfDir = path.join(root, agentId);
1983
1177
  const selfOwner = this.readSessionOwner(agentId);
1984
1178
  const selfUsable = selfOwner === null || selfOwner === agentId;
1985
1179
  if (!fresh &&
1986
1180
  selfUsable &&
1987
1181
  statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
1988
- return selfDir; // restart → continue where we left off
1182
+ return selfDir;
1989
1183
  }
1990
- // fresh incarnation: guaranteed-unique directory
1991
1184
  let sid = "";
1992
1185
  do {
1993
1186
  sid = `${agentId}-${randomUUID().slice(0, 8)}`;
@@ -1995,18 +1188,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1995
1188
  this.writeSessionManifest(path.join(root, sid), agentId);
1996
1189
  return path.join(root, sid);
1997
1190
  }
1998
- /**
1999
- * All session dirs on disk with their inferred/manifested owner, newest
2000
- * log first. One readdir powers every "list sessions" need — the UI calls
2001
- * the aggregated endpoint instead of fanning out one request per agent.
2002
- */
2003
1191
  allSessions() {
2004
1192
  const candidateIds = new Set(this.config.agents.map((a) => a.id).filter(Boolean));
2005
1193
  for (const [liveId] of this.agents)
2006
1194
  candidateIds.add(liveId);
2007
- // dir names themselves are candidate owners too: "s" owns legacy "s",
2008
- // "teapot-3-3c0048d7" is best matched by the longest configured id —
2009
- // inference needs the id set only, so nothing extra to add here.
2010
1195
  let names = [];
2011
1196
  try {
2012
1197
  names = readdirSync(this.sessionsRoot());
@@ -2019,8 +1204,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2019
1204
  const dirPath = path.join(this.sessionsRoot(), d);
2020
1205
  const { owner, hadManifest } = this.inferSessionOwner(d, candidateIds);
2021
1206
  if (!owner)
2022
- continue; // orphan dir (unknown owner) — not addressable
2023
- // backfill legacy dirs so the inference result becomes durable
1207
+ continue;
2024
1208
  if (!hadManifest)
2025
1209
  this.writeSessionManifest(dirPath, owner);
2026
1210
  let m = 0;
@@ -2030,36 +1214,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2030
1214
  m = st.mtimeMs;
2031
1215
  size = st.size;
2032
1216
  }
2033
- catch { /* no log yet — still listable */ }
2034
- /**
2035
- * The title is STORED, not derived.
2036
- *
2037
- * It used to be read from each session's log here, which meant this method opened up to 830
2038
- * files and allocated a buffer for each — every time it ran, on every `/api/sessions`. The
2039
- * value now lives on the agent's config entry (see `AgentConfig.sessionTitle`), so listing
2040
- * sessions does no I/O beyond the directory and the stat above.
2041
- *
2042
- * `session` is the agent's CURRENT session, so a title only names `d` when they agree; a past
2043
- * session of the same agent has no stored label and falls back to its id in the sidebar.
2044
- */
1217
+ catch { }
2045
1218
  const title = owner ? this.titleFor(owner, d) : undefined;
2046
1219
  out.push({ id: d, agentId: owner, mtimeMs: m, sizeBytes: size, ...(title ? { title } : {}) });
2047
1220
  }
2048
1221
  return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
2049
1222
  }
2050
- /**
2051
- * Internal session ids (= directory names) owned by an agent, newest log
2052
- * first. These are the stable handles the web UI routes by — agent ids can
2053
- * be recycled across incarnations, internal session ids cannot.
2054
- */
2055
1223
  sessionsOf(agentId) {
2056
1224
  return this.allSessions()
2057
1225
  .filter((s) => s.agentId === agentId)
2058
1226
  .map(({ id, mtimeMs, sizeBytes }) => ({ id, mtimeMs, sizeBytes }));
2059
1227
  }
2060
- /** resolve an internal session id to (ownerAgentId, absolute dir) — null when it doesn't exist */
2061
1228
  sessionById(sessionId) {
2062
- // internal ids are directory names: refuse anything path-like up front
2063
1229
  if (!sessionId || sessionId.includes("/") || sessionId.includes("\\") || sessionId.startsWith("."))
2064
1230
  return null;
2065
1231
  const root = this.sessionsRoot();
@@ -2088,32 +1254,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2088
1254
  this.config.agents = this.config.agents.filter((a) => a.id !== id);
2089
1255
  this.saveConfig();
2090
1256
  }
2091
- /**
2092
- * Put an agent away without losing it: stop it, drop it from the running set, and
2093
- * mark its config entry archived. The entry and its session history stay on disk,
2094
- * so `restoreAgent` can bring it back byte-identically.
2095
- *
2096
- * This exists because the old "remove" conflated two very different wishes —
2097
- * "I am done with this for now" and "destroy it" — and implemented the first as
2098
- * the second: it deleted the config entry, which orphaned the session directory
2099
- * (the files survived, but nothing in the UI could reach them again).
2100
- */
2101
- /**
2102
- * Rename a session by hand.
2103
- *
2104
- * The stored title is what every surface shows (the sidebar row, the header, the tab), and
2105
- * `ensureSessionTitle` deliberately never overwrites an existing value — its own comment says so
2106
- * ("which is also what will let the operator rename a session by hand"). This is that path.
2107
- *
2108
- * Refuses an empty name rather than storing one: an empty `sessionTitle` is indistinguishable
2109
- * from "never derived", so the next `ensureSessionTitle` would silently re-derive and the
2110
- * operator's intent would vanish. Clearing to the derived default is what `reset` is for.
2111
- */
2112
1257
  async renameSession(agentId, sessionId, title) {
2113
1258
  const a = this.config.agents.find((x) => x.id === agentId);
2114
1259
  if (!a)
2115
1260
  throw new Error(`no such agent: ${agentId}`);
2116
- // the title names THAT session, so it may only be set on the one the agent is on
2117
1261
  if (a.session !== sessionId)
2118
1262
  throw new Error(`agent ${agentId} is not on session ${sessionId}`);
2119
1263
  const clean = title.replace(/\s+/g, " ").trim().slice(0, 80);
@@ -2121,10 +1265,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2121
1265
  throw new Error("a session title cannot be empty");
2122
1266
  a.sessionTitle = clean;
2123
1267
  this.saveConfig();
2124
- // the live agent derives its own title from the log, so it has to be told — otherwise the header
2125
- // and sidebar keep the old label until a restart re-reads the config
2126
1268
  this.agents.get(agentId)?.setSessionTitle(clean);
2127
- // the sidebar caches titles by session id, so it has to learn about this one
2128
1269
  bus.emit("update", { kind: "agent-update", agentId });
2129
1270
  return { title: clean };
2130
1271
  }
@@ -2132,11 +1273,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2132
1273
  const root = this.config.agents.find((a) => a.id === id);
2133
1274
  if (!root)
2134
1275
  throw new Error(`no such agent: ${id}`);
2135
- // The whole SUBTREE goes with it. A sub-agent whose parent is archived can never
2136
- // run again — its parent is what spawns and messages it — so leaving it un-archived
2137
- // put orphans in the list that look usable and are not. The descendants are found
2138
- // from the config (not the live map), so sub-agents that are already stopped or
2139
- // suspended are included.
2140
1276
  const ids = [id, ...this.subtreeOf(id).map((s) => s.id)];
2141
1277
  const now = Date.now();
2142
1278
  for (const one of ids) {
@@ -2145,8 +1281,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2145
1281
  this.agents.delete(one);
2146
1282
  await live.dispose();
2147
1283
  }
2148
- // an archived agent's shells are unreachable from the UI (its tabs go with it), so
2149
- // leaving the ptys running would strand them until the idle reaper noticed
2150
1284
  closeTerminalsFor(one);
2151
1285
  const ac = this.config.agents.find((a) => a.id === one);
2152
1286
  if (!ac)
@@ -2157,13 +1291,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2157
1291
  this.saveConfig();
2158
1292
  return { archived: true, ids };
2159
1293
  }
2160
- /**
2161
- * Bring an archived agent back. Its session pointer was never touched.
2162
- *
2163
- * Restores the SUBTREE too, because archiving took it: a parent restored without its
2164
- * children would come back unable to reach the work it delegated, and the children
2165
- * would sit invisibly in the archive with no way to know why.
2166
- */
2167
1294
  async restoreAgent(id) {
2168
1295
  const ac = this.config.agents.find((a) => a.id === id);
2169
1296
  if (!ac)
@@ -2177,32 +1304,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2177
1304
  delete e.archivedAt;
2178
1305
  }
2179
1306
  this.saveConfig();
2180
- // start it again unless it was never configured to run (it is in the list, so it
2181
- // was) — a restore is the operator asking for it back, so it comes back LIVE
2182
1307
  try {
2183
1308
  await this.addAgent(ac);
2184
1309
  return { restored: true, live: true, ids };
2185
1310
  }
2186
1311
  catch (err) {
2187
- // the entry is restored either way; starting it can fail on a provider/auth
2188
- // problem, and reporting that is more useful than undoing the restore
2189
1312
  console.error(`[teapot] restored "${id}" but could not start it: ${err.message}`);
2190
1313
  return { restored: true, live: false, ids };
2191
1314
  }
2192
1315
  }
2193
- /** Archived entries, newest first — what the settings panel lists. */
2194
1316
  archivedAgents() {
2195
1317
  const archived = this.config.agents.filter((a) => a.archived);
2196
1318
  const ids = new Set(archived.map((a) => a.id));
2197
- /**
2198
- * ONE session scan for the whole list.
2199
- *
2200
- * `sessionsOf(id)` calls `allSessions()`, which reads every session directory, stats each log
2201
- * and backfills a manifest for any that lacks one — so mapping over the archived entries made
2202
- * this O(archived × sessions). Reported as "🗄 archive のロードが遅すぎる". Counting from a
2203
- * single scan makes it O(sessions) once, which is what the list actually needs: how many
2204
- * sessions each entry has.
2205
- */
2206
1319
  const sessionCounts = new Map();
2207
1320
  for (const s of this.allSessions())
2208
1321
  sessionCounts.set(s.agentId, (sessionCounts.get(s.agentId) ?? 0) + 1);
@@ -2212,20 +1325,11 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2212
1325
  workspace: a.workspace,
2213
1326
  ...(a.archivedAt !== undefined ? { archivedAt: a.archivedAt } : {}),
2214
1327
  sessions: sessionCounts.get(a.id) ?? 0,
2215
- // A sub-agent is listed under its parent rather than as an equal entry: the
2216
- // operator archived ONE thing, and a flat list of every descendant makes it
2217
- // look like they did several. Only an ARCHIVED parent counts — otherwise a
2218
- // live parent would hide a genuinely standalone entry.
2219
1328
  ...(a.parent && ids.has(a.parent) ? { parent: a.parent } : {}),
2220
1329
  subs: this.subtreeOf(a.id).filter((s) => ids.has(s.id)).length,
2221
1330
  }))
2222
1331
  .sort((a, b) => (b.archivedAt ?? 0) - (a.archivedAt ?? 0));
2223
1332
  }
2224
- /**
2225
- * Delete an agent FOR REAL: config entry, live instance, and its session
2226
- * directories on disk. Irreversible — the UI asks first and says how much is
2227
- * being destroyed.
2228
- */
2229
1333
  async purgeAgent(id) {
2230
1334
  const ac = this.config.agents.find((a) => a.id === id);
2231
1335
  if (!ac)
@@ -2235,8 +1339,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2235
1339
  this.agents.delete(id);
2236
1340
  await live.dispose();
2237
1341
  }
2238
- // collect the directories BEFORE dropping the config entry, because
2239
- // `sessionsOf` resolves ownership through the config + live map
2240
1342
  const dirs = this.sessionsOf(id);
2241
1343
  this.config.agents = this.config.agents.filter((a) => a.id !== id);
2242
1344
  this.saveConfig();
@@ -2252,12 +1354,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2252
1354
  }
2253
1355
  return { purged: true, dirs: removed };
2254
1356
  }
2255
- /**
2256
- * Switch a running agent's model/provider mid-session.
2257
- * With provider: use that named provider's baseUrl/key (+ model override).
2258
- * Without: keep the current endpoint, swap only the model name.
2259
- * The choice is persisted on the agent config so it survives restarts.
2260
- */
2261
1357
  async setAgentModel(id, providerName, model, contextWindowTokens, reasoningEffort) {
2262
1358
  const agent = this.agents.get(id);
2263
1359
  if (!agent)
@@ -2282,38 +1378,27 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2282
1378
  const ac = this.config.agents.find((a) => a.id === id);
2283
1379
  effectiveProvider = ac?.provider ?? this.config.defaultProvider ?? "";
2284
1380
  }
2285
- // re-resolve the catalogue for the (possibly new) model so the
2286
- // effort gate reflects THIS model's advertised capabilities
2287
1381
  const effortList = await fetchModelList(llm.baseUrl, llm.apiKey).catch(() => []);
2288
1382
  const meta = effortList.find((m) => m.id === (model || llm.model));
2289
1383
  const opts = agent;
2290
1384
  if (reasoningEffort !== undefined) {
2291
- // validated here so an unsupported value never reaches the request body;
2292
- // "" or an unrecognised level clears it (provider default)
2293
1385
  opts.opts.reasoningEffort = isReasoningEffort(reasoningEffort) ? reasoningEffort : "";
2294
1386
  const cfgEntry = this.config.agents.find((a) => a.id === id);
2295
1387
  if (cfgEntry)
2296
1388
  cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
2297
1389
  }
2298
- // keep `undefined` for "published nothing" — see the note on the option
2299
- opts.opts.supportedParameters = meta?.supportedParameters;
2300
1390
  opts.opts.supportedEfforts = meta?.supportedEfforts;
2301
1391
  if (!llm.model)
2302
1392
  throw new Error("no model resolved (pass model or set one on the provider)");
2303
1393
  if (!llm.baseUrl)
2304
1394
  throw new Error("no baseUrl resolved");
2305
1395
  agent.setLlmConfig(llm);
2306
- // keep the snapshot's provider in sync — the UI's model switcher reads
2307
- // sel().provider, and the old code only updated the config entry (ac)
2308
- // so the live snapshot kept the stale provider (the "right panel model
2309
- // switching doesn't work" backend half).
2310
1396
  agent.opts.provider = effectiveProvider;
2311
1397
  const ac = this.config.agents.find((a) => a.id === id);
2312
1398
  if (ac && providerName)
2313
1399
  ac.provider = providerName;
2314
1400
  if (ac)
2315
1401
  ac.model = llm.model;
2316
- // learn the new model's window (metadata lookup) unless explicitly given
2317
1402
  let win = contextWindowTokens;
2318
1403
  if (win === undefined && ac && !ac.contextWindowTokens && !this.config.contextWindowTokens) {
2319
1404
  try {
@@ -2321,7 +1406,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2321
1406
  win = contextLengthFor(list, llm.model);
2322
1407
  }
2323
1408
  catch {
2324
- /* metadata is best-effort */
2325
1409
  }
2326
1410
  }
2327
1411
  if (win !== undefined) {
@@ -2329,18 +1413,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2329
1413
  ac.contextWindowTokens = win;
2330
1414
  const opts = agent;
2331
1415
  opts.opts.contextWindowTokens = win;
2332
- // re-derive the compaction budget for the NEW window — unless the
2333
- // operator pinned one. Without this, a model switch kept the previous
2334
- // budget and the panel showed "manual override" out of nowhere.
2335
1416
  if (!agent.compactBudgetIsManual)
2336
1417
  opts.opts.contextTokenBudget = Math.round(win * 0.75);
2337
1418
  }
2338
- // re-price for the NEW model (cost estimate follows the model switch)
2339
1419
  try {
2340
1420
  const list = await fetchModelList(llm.baseUrl, llm.apiKey);
2341
1421
  await agent.setModelPricing(list.find((m) => m.id === llm.model)?.pricing);
2342
1422
  }
2343
- catch { /* pricing stays stale — cosmetic only */ }
1423
+ catch { }
2344
1424
  this.saveConfig();
2345
1425
  void agent.log.append("system_note", agent.currentSession, agent.currentBranch, {
2346
1426
  event: "model-changed",
@@ -2349,18 +1429,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2349
1429
  });
2350
1430
  return { provider: effectiveProvider, model: llm.model };
2351
1431
  }
2352
- /** 4 ticks/min; each tick is a few integer compares per task. */
2353
1432
  tick() {
2354
1433
  const now = new Date();
2355
1434
  const minuteKey = Math.floor(now.getTime() / 60_000);
2356
- /**
2357
- * Push the runtime numbers instead of making every tab poll them.
2358
- *
2359
- * `/api/metrics` was fetched on a 30s timer by each connected client, which is a GET whose
2360
- * ANSWER the server already has — the one shape a push socket makes redundant. It rides this
2361
- * existing 15s tick (the same "no extra timers" rule as the rest of the master) and a client that
2362
- * was asleep simply re-reads over HTTP on wake, which is what the recovery path is for.
2363
- */
2364
1435
  if (bus.listenerCount("update") > 0)
2365
1436
  bus.emit("update", { kind: "metrics", metrics: this.metrics() });
2366
1437
  for (const t of this.tasks) {
@@ -2368,17 +1439,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2368
1439
  if (!matches(t.schedule, now))
2369
1440
  continue;
2370
1441
  if (t.lastRunMin === minuteKey)
2371
- continue; // dedupe within the same minute
1442
+ continue;
2372
1443
  t.lastRunMin = minuteKey;
2373
1444
  t.lastRunAt = Date.now();
2374
- // persist so a master restart doesn't re-fire the same minute
2375
1445
  t.task.lastRunMin = minuteKey;
2376
1446
  this.saveConfig();
2377
1447
  const agent = this.agents.get(t.task.agent);
2378
1448
  if (!agent)
2379
1449
  continue;
2380
1450
  console.log(`[teapot] scheduled task "${t.task.id}" -> agent ${t.task.agent}`);
2381
- // "last"/"next" changed, so the schedule table the UI shows is now stale
2382
1451
  bus.emit("update", { kind: "tasks", tasks: this.tasksView() });
2383
1452
  void (async () => {
2384
1453
  if (t.task.forked)
@@ -2393,7 +1462,6 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2393
1462
  }
2394
1463
  }
2395
1464
  }
2396
- /** Everything the UI needs to make cron schedules legible. */
2397
1465
  tasksView() {
2398
1466
  return this.tasks.map((t) => ({
2399
1467
  id: t.task.id,
@@ -2410,36 +1478,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2410
1478
  const cu = process.cpuUsage();
2411
1479
  return {
2412
1480
  uptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
2413
- /**
2414
- * `rssMb` is the process's RESIDENT size, which is not a leak gauge.
2415
- *
2416
- * V8 keeps freed pages in its arenas instead of returning them, so RSS sits well above the
2417
- * live set and only falls on a major GC — reported as "メモリ使用量が多い ... さっきはheapが
2418
- * 400MBあったのでGCされた?". MEASURED on this machine (442 agents, 836 sessions): a fresh boot
2419
- * was 37MB heap, and 490 `/api/sessions` calls took it to 50MB heap / 203MB RSS and no
2420
- * further — flat, GC-reclaimable, not a leak. `heapUsedMb` is the number that answers "is
2421
- * something growing"; `rssMb` alone cannot, which is why both are reported.
2422
- */
2423
1481
  rssMb: +(mu.rss / 1048576).toFixed(1),
2424
1482
  heapUsedMb: +(mu.heapUsed / 1048576).toFixed(1),
2425
- /**
2426
- * What V8 has actually committed to the heap.
2427
- *
2428
- * `heapUsedMb` includes garbage that has not been collected yet, so it can read 400MB one
2429
- * minute and 200MB the next without anything having been freed to the OS. `heapTotal` after a
2430
- * GC is the live-set answer, reported by node's own counters so it exists on every platform.
2431
- */
2432
1483
  heapTotalMb: +(mu.heapTotal / 1048576).toFixed(1),
2433
- /** non-heap memory: buffers, native strings, the compiled bundle — the usual RSS/heap gap */
2434
1484
  externalMb: +(mu.external / 1048576).toFixed(1),
2435
- /**
2436
- * `process.cpuUsage()` reports MICROseconds, so this is divided by 1000.
2437
- *
2438
- * The field is named `cpuMsTotal` and was not converted, so it read 1000x too high — a
2439
- * 1900-second-old process reported 18,313,198 "ms" against a real 18,310ms of CPU, which
2440
- * looks like 9.6 cores burning continuously. That is how a healthy idle server got
2441
- * investigated as a runaway loop.
2442
- */
2443
1485
  cpuMsTotal: Math.round((cu.user + cu.system) / 1000),
2444
1486
  loadavg1: +(os.loadavg()[0]).toFixed(2),
2445
1487
  agents: [...this.agents.values()].map((a) => ({
@@ -2451,21 +1493,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2451
1493
  })),
2452
1494
  };
2453
1495
  }
2454
- /**
2455
- * Current application version.
2456
- *
2457
- * Read from package.json at runtime rather than via a `"__APP_VERSION__"`
2458
- * literal: `__APP_VERSION__` is substituted by *vite* only, so the tsc-built
2459
- * backend used to ship the raw placeholder and the settings panel rendered
2460
- * "current version: __APP_VERSION__". A runtime read is correct in the
2461
- * built app, in `pnpm dev` and under `node --test`, which never run vite.
2462
- */
2463
1496
  static VERSION = readAppVersion();
2464
- /** Return the current server version. */
2465
1497
  getVersion() {
2466
1498
  return Master.VERSION;
2467
1499
  }
2468
- /** Gracefully stop all agents with a timeout. Returns when all are stopped or timeout. */
2469
1500
  async stopAllAgents(timeoutMs = 30_000) {
2470
1501
  console.log(`[teapot] stopping all ${this.agents.size} agents...`);
2471
1502
  const ids = [...this.agents.keys()];
@@ -2479,21 +1510,8 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2479
1510
  catch (err) {
2480
1511
  console.error("[teapot] dispose error:", err);
2481
1512
  }
2482
- // remove from map so a re-spawn after restart doesn't carry state
2483
1513
  this.agents.delete(id);
2484
1514
  });
2485
- // two problems with racing a timeout against the real work.
2486
- //
2487
- // 1. The timer was never cleared, so it kept the event loop alive for the
2488
- // FULL timeout after a fast, successful stop — a 30s handle on a stop
2489
- // that took milliseconds.
2490
- // 2. On timeout, the promises that HAD completed had already deleted their
2491
- // agents from `this.agents`, while their peers were still mid-dispose.
2492
- // `restartServer` then threw BEFORE spawning its replacement, so the
2493
- // process was left serving with a half-empty agent map and no successor.
2494
- //
2495
- // Fixed by clearing the timer, and by reporting which agents did NOT stop
2496
- // so the caller can decide rather than being left half-dead.
2497
1515
  let timer;
2498
1516
  const timeout = new Promise((_, reject) => {
2499
1517
  timer = setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs);
@@ -2505,84 +1523,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2505
1523
  finally {
2506
1524
  if (timer)
2507
1525
  clearTimeout(timer);
2508
- /**
2509
- * Close the human terminals too.
2510
- *
2511
- * Their ptys are spawned `detached` so one close can signal the whole process group (see
2512
- * terminals.ts). The cost of that is that they do NOT die with this process — a restart
2513
- * would leave every open shell orphaned and unreachable, still holding the operator's
2514
- * workspace. Closing here is what makes `detached` safe.
2515
- */
2516
1526
  const n = closeAllTerminals();
2517
1527
  if (n)
2518
1528
  console.log(`[teapot] ⌨ closed ${n} terminal(s)`);
2519
1529
  }
2520
1530
  }
2521
- /**
2522
- * Live update: stop at a turn boundary, write the resume set, then REPLACE THIS PROCESS IN PLACE.
2523
- *
2524
- * ## Why exec instead of spawning a successor
2525
- *
2526
- * The previous design spawned a child and kept the old process alive as its supervisor, polling
2527
- * `pidAlive(replacement)` forever. That is the reported memory leak — and it was structural, not
2528
- * a stray handle:
2529
- *
2530
- * - the OLD process stayed resident for the ENTIRE LIFETIME of the new one, holding its agent
2531
- * objects, its log file handles, every `bus` listener and its 15s tick;
2532
- * - `supervise()` added three MORE signal listeners (`SIGINT`/`SIGTERM`/`SIGHUP`) to a process
2533
- * that had already registered the same three for `shutdown()`, so each update left a process
2534
- * with doubled handlers;
2535
- * - and the handover needed a 1.5s sleep plus a port-polling dance in the new process, because
2536
- * two processes cannot hold one port.
2537
- *
2538
- * `process.execve()` (Node 26+) removes the whole category: the image is replaced under the SAME
2539
- * pid, so the process group, the controlling terminal and therefore the operator's foreground job
2540
- * all stay put — the daemonization problem #35 fixed by hand cannot occur. The port is released
2541
- * as part of the exec (verified: a listener bound before `execve` is free immediately after), so
2542
- * the successor binds with no polling and no window in which both are half-served.
2543
- *
2544
- * `execveSync` does not exist; this is the async form. It does not return on success.
2545
- */
2546
- /**
2547
- * How long a live update waits for agents to reach a turn boundary.
2548
- *
2549
- * It must be comparable to a TOOL's own ceiling, not to a human's patience. MEASURED: a live update
2550
- * waited 180s while a `bash` call was in flight, gave up, and killed it — the timeline then showed
2551
- * `✖ fail … not completed — the agent restarted while this tool was running` for work that was
2552
- * about to return (that call had been running ~300s, well within the 3600s a `bash` call may take).
2553
- * Waiting too SHORT is what turns a clean update into a truncated turn.
2554
- *
2555
- * 15 minutes: three times the longest call seen in the wild (a ~300s `bash`) and still short of a
2556
- * `bash` call's own 3600s ceiling. Waiting an entire tool ceiling would block the update for an
2557
- * hour, which defeats the purpose of updating; waiting 180s interrupted work that was about to
2558
- * finish. The interrupted case is not lost — the resumed session is told exactly what happened.
2559
- */
2560
1531
  updateWindDownMs = 900_000;
2561
- /**
2562
- * True from the moment a live update starts until the process is replaced.
2563
- *
2564
- * A Ctrl-C during the wind-down used to run the ordinary shutdown path — dispose every agent, then
2565
- * exit — which discarded the resume set that had just been written and left the operator's work
2566
- * stopped with no explanation.
2567
- */
2568
1532
  updateInFlight = false;
2569
- /**
2570
- * Ask every agent to stop at its next turn boundary; returns the ids that did not make it.
2571
- *
2572
- * Distinct from `stopAllAgents` (which DISPOSES): disposal aborts in-flight work, and the whole
2573
- * point here is to leave the log on a completed round. A live update must not truncate the turn
2574
- * it interrupted.
2575
- */
2576
1533
  async windDownAllAgents(timeoutMs = this.updateWindDownMs) {
2577
- /**
2578
- * Only agents with WORK IN FLIGHT need to be waited for.
2579
- *
2580
- * This asked every loaded agent, and the log then said "waiting for 440 agent(s)" on a machine
2581
- * with one working session (MEASURED: 511 configured, 71 archived, 440 loaded). The count was
2582
- * false in the most alarming direction — it read as 440 interrupted jobs — and the call itself is
2583
- * not free: each idle agent gets a `windDownRequested` flag set and a `wake()`, which is
2584
- * pointless state churn on agents that are doing nothing.
2585
- */
2586
1534
  const ids = [...this.agents.entries()]
2587
1535
  .filter(([, a]) => a.isLive())
2588
1536
  .map(([id]) => id);
@@ -2605,28 +1553,14 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2605
1553
  }));
2606
1554
  return stuck.filter((v) => v !== null);
2607
1555
  }
2608
- /**
2609
- * The sessions the successor must resume: everything that was WORKING at this moment.
2610
- *
2611
- * Captured here (not derived on boot) because after the exec the log cannot distinguish "stopped
2612
- * by a live update mid-round" from "finished" or "stopped by the operator".
2613
- */
2614
1556
  takeResumeSet() {
2615
1557
  const out = [];
2616
1558
  for (const [id, a] of this.agents) {
2617
- // `live` covers running, parked on a tool, and needs_operator — all states where the
2618
- // operator's mental model is "this thing is in the middle of something".
2619
1559
  if (a.isLive())
2620
1560
  out.push(id);
2621
1561
  }
2622
1562
  return out;
2623
1563
  }
2624
- /**
2625
- * Resume what the previous image was running, then FORGET the set.
2626
- *
2627
- * Called once at boot. Clearing it immediately means a crash before the agents start cannot make
2628
- * the next boot resume work that has been idle for hours: the set describes one handover.
2629
- */
2630
1564
  async resumeAfterUpdate() {
2631
1565
  const ids = this.config.resumeAfterUpdate ?? [];
2632
1566
  if (!ids.length)
@@ -2637,7 +1571,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2637
1571
  for (const id of ids) {
2638
1572
  const a = this.agents.get(id);
2639
1573
  if (!a)
2640
- continue; // the agent was removed while we were restarting
1574
+ continue;
2641
1575
  try {
2642
1576
  a.start("resumed after a live update");
2643
1577
  started.push(id);
@@ -2655,122 +1589,39 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2655
1589
  }
2656
1590
  async restartServer() {
2657
1591
  console.log("[teapot] live update: restarting...");
2658
- /**
2659
- * A Ctrl-C from here until the exec is ambiguous; signal handlers consult this.
2660
- *
2661
- * Every early RETURN below clears it again. `execve` only resolves if it FAILED, and the
2662
- * `!execve` branch throws — on either path the process keeps running, so leaving the flag set
2663
- * would make the FIRST ^C refuse forever with a message that promises a second one works. The
2664
- * flag is therefore a promise about the CURRENT moment, not a one-way switch.
2665
- */
2666
1592
  this.updateInFlight = true;
2667
1593
  try {
2668
1594
  await this.handoverToNewImage();
2669
1595
  }
2670
1596
  finally {
2671
- // only reached when the handover did NOT replace the process
2672
1597
  this.updateInFlight = false;
2673
1598
  }
2674
1599
  }
2675
- /** wind down, record what to resume, and exec. Never returns on success. */
2676
1600
  async handoverToNewImage() {
2677
- /**
2678
- * 1. WIND DOWN — do not abort.
2679
- *
2680
- * `stopAllAgents` calls `dispose()`, which aborts the in-flight LLM call and the running tool.
2681
- * For an operator pressing ■ that is correct; for a live update it cuts a tool off mid-write
2682
- * and leaves a call whose result never arrives, so the resumed session starts from a state that
2683
- * never existed. `windDownForUpdate()` instead lets each agent finish its current round.
2684
- */
2685
- /**
2686
- * 2. REMEMBER WHAT TO RESUME — before winding down, not after.
2687
- *
2688
- * `takeResumeSet()` asks `isLive()`, and `windDownForUpdate()` SETS `status = "idle"` as its
2689
- * success condition. Taking the set afterwards therefore collected nothing: MEASURED in a real
2690
- * update whose log reads
2691
- *
2692
- * waiting for 1 agent(s) to reach a turn boundary...
2693
- * 0 session(s) will resume
2694
- * resumed 0 session(s)
2695
- *
2696
- * — the one agent that was working was recorded as "not working" by the very step that had just
2697
- * made it stop, so the work never came back.
2698
- *
2699
- * Captured first, the set describes what the operator saw when they pressed update.
2700
- */
2701
1601
  this.config.resumeAfterUpdate = this.takeResumeSet();
2702
1602
  const interrupted = await this.windDownAllAgents(this.updateWindDownMs);
2703
1603
  if (interrupted.length)
2704
1604
  console.log(`[teapot] live update: ${interrupted.length} agent(s) did not reach a turn boundary in ` +
2705
1605
  `time and were stopped: ${interrupted.join(", ")}`);
2706
- /**
2707
- * Written to the config BEFORE the exec, because after it this process no longer exists. Only
2708
- * agents that were actually working go in — an idle agent must NOT be started by the new
2709
- * process, or a live update would begin doing work the operator never asked for.
2710
- */
2711
1606
  this.saveConfig();
2712
1607
  console.log(`[teapot] live update: ${this.config.resumeAfterUpdate.length} session(s) will resume`);
2713
- // 3. terminals: closing them is what makes their `detached` spawns safe (see stopAllAgents)
2714
1608
  const closed = closeAllTerminals();
2715
1609
  if (closed)
2716
1610
  console.log(`[teapot] ⌻ closed ${closed} terminal(s)`);
2717
- /**
2718
- * 4. EXEC.
2719
- *
2720
- * `TEAPOT_RESUMED` tells the new image this was a live update, so it resumes rather than
2721
- * treating the sessions as merely idle. It also suppresses the "config was edited" banner in
2722
- * cases where the operator did not touch anything.
2723
- */
2724
1611
  const env = { ...process.env, TEAPOT_RESUMED: "1" };
2725
1612
  const execve = process.execve;
2726
1613
  if (!execve) {
2727
1614
  throw new Error("live update needs process.execve (Node 22+); restart the server by hand on this runtime");
2728
1615
  }
2729
1616
  console.log("[teapot] live update: replacing this process in place (same pid, same terminal)");
2730
- /**
2731
- * Give the heap back BEFORE the new image allocates its own.
2732
- *
2733
- * `execve` replaces the image in place, so the outgoing heap is still resident while the incoming
2734
- * one grows. MEASURED on a long-lived server: `VmHWM` reached 1092MB after an update while the
2735
- * steady state was 323MB, and the boot peak was visible in the status banner as
2736
- * `602.4MB / 423.3MB`. A sandbox boot of the same 832 sessions measured only 250MB, so the extra
2737
- * gigabyte is not the data — it is two heaps overlapping.
2738
- *
2739
- * `global.gc` exists only with `--expose-gc`; without it this is a cheap no-op and the peak simply
2740
- * stays as it was, which is the old behaviour.
2741
- */
2742
1617
  const gc = globalThis.gc;
2743
1618
  if (typeof gc === "function") {
2744
- // twice: the first pass frees what is unreachable, the second collects what the first freed
2745
1619
  gc();
2746
1620
  gc();
2747
1621
  }
2748
- /**
2749
- * Build the argv for the replacement image.
2750
- *
2751
- * `process.execArgv` carries runtime flags that must precede the entry file — but under
2752
- * `node --test` it is polluted with the RUNNER's own flags (`--test-concurrency`,
2753
- * `--test-isolation=process`, dozens of tuning knobs). Re-passing those made the new image
2754
- * re-enter test-runner mode instead of booting: MEASURED as
2755
- * `src/index.ts: bad option: --config`, i.e. the new image never started.
2756
- *
2757
- * So only the flags that make a TypeScript entry runnable are forwarded. A flag missing here
2758
- * simply means the new image boots with node's defaults, which is what a live update wants;
2759
- * anything else the operator set lives in `env` and the shell.
2760
- */
2761
1622
  const FORWARDABLE = /^--(experimental-strip-types|experimental-transform-types|no-warnings|enable-source-maps|import|require|conditions|expose-gc)$/;
2762
1623
  const forwardable = process.execArgv.filter((a) => FORWARDABLE.test(a.split("=")[0] ?? ""));
2763
- /**
2764
- * `args[0]` MUST be the program, exactly as a shell would pass it.
2765
- *
2766
- * `execve` does not use the `file` argument as `argv[0]` — node then reads what it thinks is
2767
- * the first ARGUMENT as the entry point and hands the rest to its own option parser. Verified
2768
- * in isolation: passing `[script, "--config", …]` yields `script: bad option: --config`, while
2769
- * `[execPath, script, "--config", …]` boots. The new image would otherwise never start, leaving
2770
- * the port dark after a live update.
2771
- */
2772
1624
  await execve(process.execPath, [process.execPath, ...forwardable, ...process.argv.slice(1)], env);
2773
- // execve only resolves if it FAILED to exec
2774
1625
  throw new Error("execve returned without replacing the process");
2775
1626
  }
2776
1627
  }