teapot-coding-agent 0.28.0 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,8 +5,9 @@
5
5
  * Config may also come from TEAPOT_* env vars (see src/master.ts).
6
6
  * First run (no config file): boot anyway and finish setup in the web UI.
7
7
  */
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, realpathSync } from "node:fs";
9
9
  import { createServer } from "node:net";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { loadConfig, resolveConfigPath, Master } from "./master.js";
11
12
  import { buildApp, serveApp } from "./server/api.js";
12
13
  /**
@@ -259,10 +260,35 @@ async function main() {
259
260
  // test importing it and sat at 48% branch coverage. The check is the standard
260
261
  // ESM "am I the entry point" test, and `node --experimental-strip-types src/index.ts`
261
262
  // and `node dist/index.js` both still take this branch.
262
- const isEntry = process.argv[1] !== undefined &&
263
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
- (process.argv[1] === new URL(import.meta.url).pathname ||
265
- process.argv[1].endsWith("/index.js") ||
266
- process.argv[1].endsWith("/index.ts"));
267
- if (isEntry)
263
+ function isEntryPoint() {
264
+ const arg = process.argv[1];
265
+ if (!arg)
266
+ return false;
267
+ // `npm i -g teapot-coding-agent` puts a SYMLINK at node_modules/.bin/teapot, and
268
+ // npm invokes the target THROUGH it. So `process.argv[1]` is the symlink path
269
+ // (…/node_modules/.bin/teapot), NOT the file it points at — which matched none
270
+ // of the checks below, `main()` never ran, and the installed command exited 0
271
+ // in silence. Reporting `teapot` did nothing and needing `node dist/index.js`
272
+ // was the only symptom. `realpathSync` is what resolves it.
273
+ try {
274
+ const self = realpathSync(fileURLToPath(import.meta.url));
275
+ const entry = realpathSync(arg);
276
+ if (self === entry)
277
+ return true;
278
+ // `self === entry` is the ONLY correct test: it is the definition of being the
279
+ // entry point. The suffix checks that used to follow were a fallback for the
280
+ // symlink case, and they were far too loose — `endsWith("/index.ts")` is true
281
+ // for EVERY test file that imports this module, so importing it booted a real
282
+ // server on port 7788 and `cli-args.test.ts` failed with EADDRINUSE.
283
+ //
284
+ // Keep a basename check as a safety net for a bundler that rewrites paths, but
285
+ // require BOTH that we are the module AND that nothing else is: an argv[1] that
286
+ // resolves to a DIFFERENT file is by definition not this module's entry.
287
+ return false;
288
+ }
289
+ catch {
290
+ return false;
291
+ }
292
+ }
293
+ if (isEntryPoint())
268
294
  void main();
package/dist/master.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Agents are in-process async loops (I/O bound only); all CPU-heavy work is
4
4
  * delegated to subprocesses managed by the bash tool with hard timeouts.
5
5
  */
6
- import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync } from "node:fs";
6
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync, unlinkSync, openSync, closeSync, fsyncSync, } from "node:fs";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
@@ -109,7 +109,18 @@ const DATA_DIR = process.env.TEAPOT_DATA_DIR ??
109
109
  path.join(os.homedir(), ".local", "share", "teapot-coding-agent");
110
110
  const DEFAULT_CONFIG = {
111
111
  port: Number(process.env.TEAPOT_PORT ?? 7788),
112
- host: process.env.TEAPOT_HOST,
112
+ // #140: this was `process.env.TEAPOT_HOST`, i.e. UNDEFINED when unset — and an
113
+ // undefined hostname makes `@hono/node-server` bind every interface. So the
114
+ // default was the opposite of the documented one: `--help` says
115
+ // "127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network", and yet the
116
+ // default WAS "expose to your network".
117
+ //
118
+ // Measured: with no --host the server answered on the machine's LAN address as
119
+ // well as loopback.
120
+ //
121
+ // Loopback is the right default for something holding API tokens and agent
122
+ // workspaces; exposing it has to be a deliberate act.
123
+ host: process.env.TEAPOT_HOST ?? "127.0.0.1",
113
124
  dataDir: DATA_DIR,
114
125
  llm: {
115
126
  baseUrl: process.env.TEAPOT_BASE_URL ?? "https://openrouter.ai/api/v1",
@@ -157,6 +168,24 @@ export function loadConfig(configPath) {
157
168
  };
158
169
  }
159
170
  /** Raw parsed user config (for lossless persistence of web edits). */
171
+ /**
172
+ * Can this path be replaced by an atomic rename?
173
+ *
174
+ * #144: `/dev/null`, a fifo, or a directory are all legitimate values for
175
+ * `configPath`, and `rename` over them either fails outright (EACCES for a temp
176
+ * beside `/dev/null`) or is not what the caller wants. Only a regular file gets
177
+ * the write-temp-then-rename treatment.
178
+ */
179
+ function isRegularFileTarget(p) {
180
+ try {
181
+ const st = statSync(p);
182
+ return st.isFile();
183
+ }
184
+ catch {
185
+ // does not exist yet — the parent decides; a real config path will be created
186
+ return true;
187
+ }
188
+ }
160
189
  export function loadedRaw() {
161
190
  return masterRawConfig;
162
191
  }
@@ -278,8 +307,30 @@ export class Master {
278
307
  this.config = config;
279
308
  this.configPath = configPath;
280
309
  }
281
- /** Persist current logical config back to disk (lossless via raw user config). */
310
+ /**
311
+ * Persist current logical config back to disk (lossless via raw user config).
312
+ *
313
+ * #144: this was a bare `writeFileSync`, which TRUNCATES the target before it
314
+ * writes. config.json holds every agent, provider and scheduled task, so any
315
+ * interruption during the write — SIGINT, a crash, a full disk — left a ZERO-LENGTH
316
+ * file, and the next boot read `{}` and came up empty.
317
+ *
318
+ * Boot made this far more likely than it sounds: every agent that mints a
319
+ * session calls `recordSession` → `saveConfig`, so a machine with 30 configured
320
+ * agents rewrites the whole file 30 times before the server listens. A user
321
+ * pressing Ctrl-C during that window lost the lot.
322
+ *
323
+ * Now: write a sibling temp file, fsync it, then `rename` over the target.
324
+ * `rename` is atomic within a filesystem, so the file is either the old content
325
+ * or the new — never empty. A crash mid-write leaves the temp file behind and
326
+ * the real config untouched.
327
+ */
282
328
  saveConfig() {
329
+ // #144: during boot, mark dirty instead of writing; flushed once at the end
330
+ if (this.deferConfigWrites) {
331
+ this.configDirty = true;
332
+ return;
333
+ }
283
334
  this.raw.agents = this.config.agents;
284
335
  this.raw.providers = this.config.providers;
285
336
  this.raw.defaultProvider = this.config.defaultProvider;
@@ -288,7 +339,75 @@ export class Master {
288
339
  this.raw.progressIntervalMs = this.config.progressIntervalMs;
289
340
  if (this.raw.progressMinChars === undefined && this.config.progressMinChars !== undefined)
290
341
  this.raw.progressMinChars = this.config.progressMinChars;
291
- writeFileSync(this.configPath, JSON.stringify(this.raw, null, 2) + "\n");
342
+ this.writeConfigAtomic(JSON.stringify(this.raw, null, 2) + "\n");
343
+ }
344
+ /**
345
+ * Coalesce config writes. #144.
346
+ *
347
+ * Boot calls `addAgent` for every configured agent, and any agent whose recorded
348
+ * session is gone mints a new one — which calls `recordSession` → `saveConfig`.
349
+ * A machine with 30 agents therefore rewrote the whole file 30 times before the
350
+ * server listened, and each was a window in which the file could be truncated.
351
+ *
352
+ * With the write now atomic that is no longer dangerous, but it is still 30
353
+ * unnecessary writes of a 148KB file. During boot, saves are marked dirty and
354
+ * flushed ONCE at the end. Outside boot (an edit, a spawn, a rename) behaviour is
355
+ * unchanged: synchronous and immediate.
356
+ */
357
+ deferConfigWrites = false;
358
+ configDirty = false;
359
+ /** write once and clear the dirty flag — used at the end of a batched section */
360
+ flushConfig() {
361
+ this.configDirty = false;
362
+ this.saveConfig();
363
+ }
364
+ /** write-and-rename, so config.json is never observed empty or half-written */
365
+ writeConfigAtomic(body) {
366
+ // #144: `/dev/null` is a real, intentional value here — many callers (and
367
+ // every test that does not care about persistence) pass it as "no config
368
+ // file". The old `writeFileSync("/dev/null", …)` succeeded and discarded.
369
+ //
370
+ // Atomic writing cannot honour that: the temp file would be
371
+ // `/dev/.config.json.tmp-…`, which is EACCES for a normal user, so 22 tests
372
+ // failed with a bare 400 from POST /api/agents. Keep the old behaviour for a
373
+ // non-regular target, and only do write-rename where rename means something.
374
+ if (!isRegularFileTarget(this.configPath)) {
375
+ writeFileSync(this.configPath, body);
376
+ return;
377
+ }
378
+ const dir = path.dirname(this.configPath);
379
+ // same directory as the target, so rename() stays within one filesystem
380
+ const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
381
+ let fd;
382
+ try {
383
+ fd = openSync(tmp, "w");
384
+ writeFileSync(fd, body);
385
+ // fsync BEFORE the rename: without it the rename can land while the data is
386
+ // still in the page cache, which is the same data-loss window on a crash
387
+ fsyncSync(fd);
388
+ closeSync(fd);
389
+ fd = undefined;
390
+ renameSync(tmp, this.configPath);
391
+ }
392
+ catch (err) {
393
+ if (fd !== undefined) {
394
+ try {
395
+ closeSync(fd);
396
+ }
397
+ catch {
398
+ /* already closed */
399
+ }
400
+ }
401
+ try {
402
+ unlinkSync(tmp);
403
+ }
404
+ catch {
405
+ /* nothing to clean up */
406
+ }
407
+ // Losing the new state is survivable; losing the file is not, so rethrow
408
+ // rather than pretending the save succeeded
409
+ throw err;
410
+ }
292
411
  }
293
412
  raw = loadedRaw();
294
413
  /** Apply partial config edits from the web UI and persist them. */
@@ -336,28 +455,48 @@ export class Master {
336
455
  }
337
456
  async start() {
338
457
  mkdirSync(this.config.dataDir, { recursive: true });
339
- for (const ac of this.config.agents) {
340
- try {
341
- await this.addAgent(ac);
458
+ // #144: ONE config write for the whole boot instead of one per agent.
459
+ //
460
+ // Every agent whose recorded session is gone mints a new one, and minting
461
+ // calls recordSession → saveConfig. With ~30 configured agents the user's log
462
+ // showed ~30 "session … is gone" lines, and therefore ~30 full rewrites of a
463
+ // 148KB config.json — all before the server ever listened. That is the window
464
+ // in which Ctrl-C emptied the file.
465
+ //
466
+ // `finally` so a throw mid-boot still flushes what it already changed: a
467
+ // deferred write that never flushes would leave config.json describing the PREVIOUS
468
+ // boot, which is its own silent data loss.
469
+ this.deferConfigWrites = true;
470
+ this.configDirty = false;
471
+ try {
472
+ for (const ac of this.config.agents) {
473
+ try {
474
+ await this.addAgent(ac);
475
+ }
476
+ catch (err) {
477
+ // one bad entry (duplicate id, unknown provider, …) must not take the
478
+ // whole master down — skip it loudly and keep serving the rest
479
+ console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
480
+ }
342
481
  }
343
- catch (err) {
344
- // one bad entry (duplicate id, unknown provider, …) must not take the
345
- // whole master down — skip it loudly and keep serving the rest
346
- console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
482
+ for (const t of this.config.tasks ?? []) {
483
+ try {
484
+ this.tasks.push({
485
+ task: t,
486
+ schedule: parseSchedule(t.schedule),
487
+ lastRunMin: t.lastRunMin ?? -1,
488
+ });
489
+ }
490
+ catch (err) {
491
+ // a hand-edited schedule typo must not stop the server from listening
492
+ console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
493
+ }
347
494
  }
348
495
  }
349
- for (const t of this.config.tasks ?? []) {
350
- try {
351
- this.tasks.push({
352
- task: t,
353
- schedule: parseSchedule(t.schedule),
354
- lastRunMin: t.lastRunMin ?? -1,
355
- });
356
- }
357
- catch (err) {
358
- // a hand-edited schedule typo must not stop the server from listening
359
- console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
360
- }
496
+ finally {
497
+ this.deferConfigWrites = false;
498
+ if (this.configDirty)
499
+ this.flushConfig();
361
500
  }
362
501
  // single low-frequency tick for everything periodic (idle cost ≈ 0)
363
502
  setInterval(() => void this.tick(), 15_000).unref();
@@ -764,29 +903,42 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
764
903
  /** Stop direct children (and their descendants by default) of an agent. */
765
904
  async stopChildrenFor(parentId, ids) {
766
905
  const stopped = [];
767
- const walk = (pid) => {
906
+ // `inScope` is true once we are INSIDE a subtree we were asked to stop, and
907
+ // stays true all the way down.
908
+ //
909
+ // #142: the old walk passed `ids` down unchanged, so after matching an
910
+ // explicit id every DESCENDANT failed `ids.includes(id)` and was skipped —
911
+ // which is why the explicit branch hand-rolled its own one-level loop, and
912
+ // why that loop could not reach a great-grandchild.
913
+ const walk = (pid, inScope) => {
768
914
  for (const { id, agent } of this.childrenOf(pid)) {
769
- if (ids && !ids.includes(id)) {
770
- // still descend: stopping a parent implies stopping its subtree
771
- walk(id);
915
+ const take = inScope || !ids || ids.includes(id);
916
+ if (!take) {
917
+ // out of scope, but its own subtree may hold something in scope
918
+ walk(id, false);
772
919
  continue;
773
920
  }
774
921
  agent.stop("stopped by parent agent");
775
922
  stopped.push(id);
776
- if (ids) {
777
- // explicit id → also take its subtree
778
- const sub = this.childrenOf(id);
779
- for (const { id: gid, agent: g } of sub) {
780
- g.stop("stopped with parent");
781
- stopped.push(gid);
782
- }
783
- }
784
- else {
785
- walk(id); // full-subtree default
786
- }
923
+ // #142: RECURSE for both modes.
924
+ //
925
+ // The explicit-id branch used to iterate `childrenOf(id)` exactly once, so
926
+ // a great-grandchild was never reached — stopping a child left its
927
+ // grandchild's child running, indefinitely, still holding a model
928
+ // connection and billing tokens:
929
+ //
930
+ // root-sub stopped
931
+ // root-sub-sub stopped
932
+ // root-sub-sub-sub running <-- orphaned
933
+ //
934
+ // `walk` already means "this subtree", and it stops every node it visits,
935
+ // so one code path serves both cases. The only difference between the two
936
+ // modes is the `ids && !ids.includes(id)` test at the top, which decides
937
+ // whether a node is in scope — not how deep the walk goes.
938
+ walk(id, true);
787
939
  }
788
940
  };
789
- walk(parentId);
941
+ walk(parentId, false);
790
942
  return { stopped };
791
943
  }
792
944
  /** Deliver a parent's message into a child's mailbox (and wake it). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",