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 +33 -7
- package/dist/master.js +191 -39
- package/package.json +1 -1
- package/public/assets/{index-DWsd_EOl.js → index-j-byVujL.js} +19 -19
- package/public/assets/index-j-byVujL.js.map +1 -0
- package/public/assets/{shiki-D0LgHyHH.js → shiki-DCpmqeFE.js} +2 -2
- package/public/assets/{shiki-D0LgHyHH.js.map → shiki-DCpmqeFE.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DWsd_EOl.js.map +0 -1
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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). */
|