teapot-coding-agent 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent.js +414 -152
- package/dist/agent/skills.js +29 -15
- package/dist/agent/tools.js +22 -1
- package/dist/config-schema.js +2 -1
- package/dist/master.js +184 -26
- package/dist/scheduler/cron.js +5 -3
- package/dist/server/api.js +41 -20
- package/dist/server/term-size.js +13 -1
- package/package.json +1 -1
- package/public/assets/index-DzJviYpH.js +30 -0
- package/public/assets/index-DzJviYpH.js.map +1 -0
- package/public/assets/{shiki-B3arOyuY.js → shiki-F2TFV45_.js} +2 -2
- package/public/assets/{shiki-B3arOyuY.js.map → shiki-F2TFV45_.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DTHHmNVF.js +0 -30
- package/public/assets/index-DTHHmNVF.js.map +0 -1
package/dist/agent/skills.js
CHANGED
|
@@ -46,26 +46,40 @@ export async function skillRootsFingerprint(roots) {
|
|
|
46
46
|
continue;
|
|
47
47
|
}
|
|
48
48
|
parts.push(`${st.mtimeMs}:${st.size}`);
|
|
49
|
-
|
|
49
|
+
// stamp every SKILL.md the discovery walk could adopt (same depth bound):
|
|
50
|
+
// the one-level stat missed a NESTED skill's add/edit entirely, so the
|
|
51
|
+
// stamp never changed, refreshSkills early-returned, and the skill stayed
|
|
52
|
+
// invisible/stale in list_skills and the system-prompt catalogue
|
|
53
|
+
await stampSkillFiles(root.dir, 0, parts);
|
|
54
|
+
}
|
|
55
|
+
return parts.join("|");
|
|
56
|
+
}
|
|
57
|
+
/** recursive fingerprint walk — mirrors walkSkills' bound and containment rules */
|
|
58
|
+
async function stampSkillFiles(dir, depth, parts) {
|
|
59
|
+
if (depth > MAX_SKILL_DEPTH)
|
|
60
|
+
return;
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
if (e.name.startsWith("."))
|
|
70
|
+
continue;
|
|
71
|
+
const full = path.join(dir, e.name);
|
|
72
|
+
if (!e.isDirectory())
|
|
73
|
+
continue;
|
|
50
74
|
try {
|
|
51
|
-
|
|
75
|
+
const s = await fs.stat(path.join(full, SKILL_FILE));
|
|
76
|
+
parts.push(`${full}=${s.mtimeMs}:${s.size}`);
|
|
52
77
|
}
|
|
53
78
|
catch {
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
for (const e of entries) {
|
|
57
|
-
if (!e.isDirectory() || e.name.startsWith("."))
|
|
58
|
-
continue;
|
|
59
|
-
try {
|
|
60
|
-
const s = await fs.stat(path.join(root.dir, e.name, SKILL_FILE));
|
|
61
|
-
parts.push(`${e.name}=${s.mtimeMs}:${s.size}`);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
parts.push(`${e.name}=none`);
|
|
65
|
-
}
|
|
79
|
+
// not a skill dir itself — still a container to descend into
|
|
66
80
|
}
|
|
81
|
+
await stampSkillFiles(full, depth + 1, parts);
|
|
67
82
|
}
|
|
68
|
-
return parts.join("|");
|
|
69
83
|
}
|
|
70
84
|
export function isValidSkillName(name) {
|
|
71
85
|
return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name);
|
package/dist/agent/tools.js
CHANGED
|
@@ -1645,9 +1645,30 @@ export const TOOLS = [
|
|
|
1645
1645
|
return { ok: false, result: "sub-agents are not available here" };
|
|
1646
1646
|
const ids = Array.isArray(args.ids) ? args.ids.map(String) : undefined;
|
|
1647
1647
|
const ms = Math.min(Math.max(num(args.timeout_ms, 300_000), 1_000), 3_600_000);
|
|
1648
|
+
// #140: a prompt ALREADY in the queue must be consumed FIRST. The
|
|
1649
|
+
// enqueue-time wake only fires for prompts arriving DURING the park —
|
|
1650
|
+
// one queued before the park is invisible to it, and parking over it
|
|
1651
|
+
// strands the operator's message ("idle" display, nothing processed)
|
|
1652
|
+
// until a child settles. Stand down and let the next turn boundary
|
|
1653
|
+
// drain it; the model can call wait_children again afterwards.
|
|
1654
|
+
if (ctx.hasPendingPrompts?.()) {
|
|
1655
|
+
ctx.onProgressGateReset?.();
|
|
1656
|
+
return {
|
|
1657
|
+
ok: true,
|
|
1658
|
+
result: "A prompt is already queued for you — handle it first, then call wait_children again if you still need to wait.",
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1648
1661
|
// park VISIBLY: the UI shows idle ("waiting on sub-agents") instead of a
|
|
1649
1662
|
// running spinner, and any user prompt / stop wakes us instantly
|
|
1650
|
-
ctx.onIdlePark?.(`waiting on sub-agent${ids?.length === 1 ? ` ${ids[0]}` : "s"}`);
|
|
1663
|
+
const parked = ctx.onIdlePark?.(`waiting on sub-agent${ids?.length === 1 ? ` ${ids[0]}` : "s"}`);
|
|
1664
|
+
if (parked === false) {
|
|
1665
|
+
// the agent refused the park (a prompt raced in) — same answer as above
|
|
1666
|
+
ctx.onProgressGateReset?.();
|
|
1667
|
+
return {
|
|
1668
|
+
ok: true,
|
|
1669
|
+
result: "A prompt arrived while you were about to wait — handle it first, then call wait_children again if you still need to wait.",
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1651
1672
|
// the child's report is about to land in our mailbox — a progress report
|
|
1652
1673
|
// right after it would be pure noise, so re-arm the gate here
|
|
1653
1674
|
ctx.onProgressGateReset?.();
|
package/dist/config-schema.js
CHANGED
|
@@ -39,7 +39,8 @@ export const ConfigPatchSchema = z.object({
|
|
|
39
39
|
progressMinChars: z.number().int().min(100).optional(),
|
|
40
40
|
// null clears a previously pinned compact budget → back to per-model derivation
|
|
41
41
|
contextTokenBudget: z.number().int().min(1_000).nullable().optional(),
|
|
42
|
-
|
|
42
|
+
// null clears a saved window pin → back to per-model inference (same contract as contextTokenBudget)
|
|
43
|
+
contextWindowTokens: z.number().int().min(1_000).nullable().optional(),
|
|
43
44
|
maxSpawnDepth: z.number().int().min(0).max(8).optional(),
|
|
44
45
|
// soft cap on LLM turns in one round; reaching it nudges the model to wrap
|
|
45
46
|
// up (not an error) — 0 disables the cap
|
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, lstatSync, 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";
|
|
@@ -168,6 +168,30 @@ export function loadConfig(configPath) {
|
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
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
|
+
// lstat, not stat: a SYMLINK must take the plain-write path. statSync
|
|
182
|
+
// follows the link, so a symlink→regular-file passed this check and the
|
|
183
|
+
// atomic rename then REPLACED THE LINK with a real file — the link's
|
|
184
|
+
// target (a dotfile repo, stow/chezmoi) kept the old value and a restart
|
|
185
|
+
// resurrected it. Same reasoning covers hardlinks (rename splits the
|
|
186
|
+
// inode; the write must go through the existing directory entry).
|
|
187
|
+
const st = lstatSync(p);
|
|
188
|
+
return st.isFile() && !st.isSymbolicLink();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// does not exist yet — the parent decides; a real config path will be created
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
171
195
|
export function loadedRaw() {
|
|
172
196
|
return masterRawConfig;
|
|
173
197
|
}
|
|
@@ -289,8 +313,30 @@ export class Master {
|
|
|
289
313
|
this.config = config;
|
|
290
314
|
this.configPath = configPath;
|
|
291
315
|
}
|
|
292
|
-
/**
|
|
316
|
+
/**
|
|
317
|
+
* Persist current logical config back to disk (lossless via raw user config).
|
|
318
|
+
*
|
|
319
|
+
* #144: this was a bare `writeFileSync`, which TRUNCATES the target before it
|
|
320
|
+
* writes. config.json holds every agent, provider and scheduled task, so any
|
|
321
|
+
* interruption during the write — SIGINT, a crash, a full disk — left a ZERO-LENGTH
|
|
322
|
+
* file, and the next boot read `{}` and came up empty.
|
|
323
|
+
*
|
|
324
|
+
* Boot made this far more likely than it sounds: every agent that mints a
|
|
325
|
+
* session calls `recordSession` → `saveConfig`, so a machine with 30 configured
|
|
326
|
+
* agents rewrites the whole file 30 times before the server listens. A user
|
|
327
|
+
* pressing Ctrl-C during that window lost the lot.
|
|
328
|
+
*
|
|
329
|
+
* Now: write a sibling temp file, fsync it, then `rename` over the target.
|
|
330
|
+
* `rename` is atomic within a filesystem, so the file is either the old content
|
|
331
|
+
* or the new — never empty. A crash mid-write leaves the temp file behind and
|
|
332
|
+
* the real config untouched.
|
|
333
|
+
*/
|
|
293
334
|
saveConfig() {
|
|
335
|
+
// #144: during boot, mark dirty instead of writing; flushed once at the end
|
|
336
|
+
if (this.deferConfigWrites) {
|
|
337
|
+
this.configDirty = true;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
294
340
|
this.raw.agents = this.config.agents;
|
|
295
341
|
this.raw.providers = this.config.providers;
|
|
296
342
|
this.raw.defaultProvider = this.config.defaultProvider;
|
|
@@ -299,7 +345,75 @@ export class Master {
|
|
|
299
345
|
this.raw.progressIntervalMs = this.config.progressIntervalMs;
|
|
300
346
|
if (this.raw.progressMinChars === undefined && this.config.progressMinChars !== undefined)
|
|
301
347
|
this.raw.progressMinChars = this.config.progressMinChars;
|
|
302
|
-
|
|
348
|
+
this.writeConfigAtomic(JSON.stringify(this.raw, null, 2) + "\n");
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Coalesce config writes. #144.
|
|
352
|
+
*
|
|
353
|
+
* Boot calls `addAgent` for every configured agent, and any agent whose recorded
|
|
354
|
+
* session is gone mints a new one — which calls `recordSession` → `saveConfig`.
|
|
355
|
+
* A machine with 30 agents therefore rewrote the whole file 30 times before the
|
|
356
|
+
* server listened, and each was a window in which the file could be truncated.
|
|
357
|
+
*
|
|
358
|
+
* With the write now atomic that is no longer dangerous, but it is still 30
|
|
359
|
+
* unnecessary writes of a 148KB file. During boot, saves are marked dirty and
|
|
360
|
+
* flushed ONCE at the end. Outside boot (an edit, a spawn, a rename) behaviour is
|
|
361
|
+
* unchanged: synchronous and immediate.
|
|
362
|
+
*/
|
|
363
|
+
deferConfigWrites = false;
|
|
364
|
+
configDirty = false;
|
|
365
|
+
/** write once and clear the dirty flag — used at the end of a batched section */
|
|
366
|
+
flushConfig() {
|
|
367
|
+
this.configDirty = false;
|
|
368
|
+
this.saveConfig();
|
|
369
|
+
}
|
|
370
|
+
/** write-and-rename, so config.json is never observed empty or half-written */
|
|
371
|
+
writeConfigAtomic(body) {
|
|
372
|
+
// #144: `/dev/null` is a real, intentional value here — many callers (and
|
|
373
|
+
// every test that does not care about persistence) pass it as "no config
|
|
374
|
+
// file". The old `writeFileSync("/dev/null", …)` succeeded and discarded.
|
|
375
|
+
//
|
|
376
|
+
// Atomic writing cannot honour that: the temp file would be
|
|
377
|
+
// `/dev/.config.json.tmp-…`, which is EACCES for a normal user, so 22 tests
|
|
378
|
+
// failed with a bare 400 from POST /api/agents. Keep the old behaviour for a
|
|
379
|
+
// non-regular target, and only do write-rename where rename means something.
|
|
380
|
+
if (!isRegularFileTarget(this.configPath)) {
|
|
381
|
+
writeFileSync(this.configPath, body);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const dir = path.dirname(this.configPath);
|
|
385
|
+
// same directory as the target, so rename() stays within one filesystem
|
|
386
|
+
const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
|
|
387
|
+
let fd;
|
|
388
|
+
try {
|
|
389
|
+
fd = openSync(tmp, "w");
|
|
390
|
+
writeFileSync(fd, body);
|
|
391
|
+
// fsync BEFORE the rename: without it the rename can land while the data is
|
|
392
|
+
// still in the page cache, which is the same data-loss window on a crash
|
|
393
|
+
fsyncSync(fd);
|
|
394
|
+
closeSync(fd);
|
|
395
|
+
fd = undefined;
|
|
396
|
+
renameSync(tmp, this.configPath);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
if (fd !== undefined) {
|
|
400
|
+
try {
|
|
401
|
+
closeSync(fd);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* already closed */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
unlinkSync(tmp);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
/* nothing to clean up */
|
|
412
|
+
}
|
|
413
|
+
// Losing the new state is survivable; losing the file is not, so rethrow
|
|
414
|
+
// rather than pretending the save succeeded
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
303
417
|
}
|
|
304
418
|
raw = loadedRaw();
|
|
305
419
|
/** Apply partial config edits from the web UI and persist them. */
|
|
@@ -334,6 +448,27 @@ export class Master {
|
|
|
334
448
|
this.config.onError = patch.onError;
|
|
335
449
|
if (patch.retryDelayMs !== undefined)
|
|
336
450
|
this.config.retryDelayMs = patch.retryDelayMs;
|
|
451
|
+
// #PUT-config: an explicit null clears a saved window pin → back to
|
|
452
|
+
// per-model inference; a positive number pins it (same contract as
|
|
453
|
+
// contextTokenBudget above)
|
|
454
|
+
if (patch.contextWindowTokens !== undefined) {
|
|
455
|
+
this.config.contextWindowTokens =
|
|
456
|
+
(patch.contextWindowTokens ?? 0) > 0 ? patch.contextWindowTokens : undefined;
|
|
457
|
+
}
|
|
458
|
+
// persist the scalar edits: saveConfig only mirrors agents/providers/etc.
|
|
459
|
+
// into raw, so these would otherwise pass validation, live for one
|
|
460
|
+
// process, and silently vanish from the file (a restart resurrected the
|
|
461
|
+
// old values). undefined DELETES the key so a null-clear also persists.
|
|
462
|
+
for (const k of [
|
|
463
|
+
"contextTokenBudget", "contextWindowTokens", "maxSpawnDepth",
|
|
464
|
+
"maxTurnsPerRound", "onError", "retryDelayMs",
|
|
465
|
+
]) {
|
|
466
|
+
const v = this.config[k];
|
|
467
|
+
if (v === undefined)
|
|
468
|
+
delete this.raw[k];
|
|
469
|
+
else
|
|
470
|
+
this.raw[k] = v;
|
|
471
|
+
}
|
|
337
472
|
if (patch.tasks) {
|
|
338
473
|
this.config.tasks = patch.tasks;
|
|
339
474
|
// rebuild schedule table live
|
|
@@ -347,28 +482,48 @@ export class Master {
|
|
|
347
482
|
}
|
|
348
483
|
async start() {
|
|
349
484
|
mkdirSync(this.config.dataDir, { recursive: true });
|
|
350
|
-
for
|
|
351
|
-
|
|
352
|
-
|
|
485
|
+
// #144: ONE config write for the whole boot instead of one per agent.
|
|
486
|
+
//
|
|
487
|
+
// Every agent whose recorded session is gone mints a new one, and minting
|
|
488
|
+
// calls recordSession → saveConfig. With ~30 configured agents the user's log
|
|
489
|
+
// showed ~30 "session … is gone" lines, and therefore ~30 full rewrites of a
|
|
490
|
+
// 148KB config.json — all before the server ever listened. That is the window
|
|
491
|
+
// in which Ctrl-C emptied the file.
|
|
492
|
+
//
|
|
493
|
+
// `finally` so a throw mid-boot still flushes what it already changed: a
|
|
494
|
+
// deferred write that never flushes would leave config.json describing the PREVIOUS
|
|
495
|
+
// boot, which is its own silent data loss.
|
|
496
|
+
this.deferConfigWrites = true;
|
|
497
|
+
this.configDirty = false;
|
|
498
|
+
try {
|
|
499
|
+
for (const ac of this.config.agents) {
|
|
500
|
+
try {
|
|
501
|
+
await this.addAgent(ac);
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
// one bad entry (duplicate id, unknown provider, …) must not take the
|
|
505
|
+
// whole master down — skip it loudly and keep serving the rest
|
|
506
|
+
console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
|
|
507
|
+
}
|
|
353
508
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
509
|
+
for (const t of this.config.tasks ?? []) {
|
|
510
|
+
try {
|
|
511
|
+
this.tasks.push({
|
|
512
|
+
task: t,
|
|
513
|
+
schedule: parseSchedule(t.schedule),
|
|
514
|
+
lastRunMin: t.lastRunMin ?? -1,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
// a hand-edited schedule typo must not stop the server from listening
|
|
519
|
+
console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
|
|
520
|
+
}
|
|
358
521
|
}
|
|
359
522
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
schedule: parseSchedule(t.schedule),
|
|
365
|
-
lastRunMin: t.lastRunMin ?? -1,
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
catch (err) {
|
|
369
|
-
// a hand-edited schedule typo must not stop the server from listening
|
|
370
|
-
console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
|
|
371
|
-
}
|
|
523
|
+
finally {
|
|
524
|
+
this.deferConfigWrites = false;
|
|
525
|
+
if (this.configDirty)
|
|
526
|
+
this.flushConfig();
|
|
372
527
|
}
|
|
373
528
|
// single low-frequency tick for everything periodic (idle cost ≈ 0)
|
|
374
529
|
setInterval(() => void this.tick(), 15_000).unref();
|
|
@@ -825,10 +980,13 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
825
980
|
: this.childrenOf(parentId).map((c) => c.id)).filter((id) => this.agents.has(id));
|
|
826
981
|
if (targets.length === 0)
|
|
827
982
|
return Promise.resolve({ note: "no live sub-agents to wait for" });
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
983
|
+
// isBusy(), not the raw status: parkForTool overwrites a parked child's
|
|
984
|
+
// status to "idle" (spinner suppression) while its loop is very much
|
|
985
|
+
// alive — the raw check counted that child as settled and let the parent
|
|
986
|
+
// resume over unfinished work. isBusy() sees parked tools, compactions
|
|
987
|
+
// and an open ask_user; queued prompts are DORMANT (isLive()'s "stop vs
|
|
988
|
+
// start" extra) and must not hold the wait. A stop request reads settled.
|
|
989
|
+
const active = () => targets.filter((id) => this.agents.get(id)?.isBusy());
|
|
832
990
|
const first = active();
|
|
833
991
|
if (first.length === 0)
|
|
834
992
|
return Promise.resolve({ note: `all sub-agents already settled: ${targets.join(", ")}` });
|
package/dist/scheduler/cron.js
CHANGED
|
@@ -88,12 +88,14 @@ export function matches(schedule, d) {
|
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
90
|
* Next fire time after `from`, scanning minute by minute (cheap: a handful of
|
|
91
|
-
* integer compares per minute). Returns null when nothing fires within
|
|
92
|
-
*
|
|
91
|
+
* integer compares per minute). Returns null when nothing fires within a year
|
|
92
|
+
* — annual crons (a December date seen in August) used to fall outside the old
|
|
93
|
+
* 31-day horizon and showed `next: null` in the UI even though they fire.
|
|
94
|
+
* A Feb-29-only cron can still legitimately report null most of the year.
|
|
93
95
|
*/
|
|
94
96
|
export function nextFireAt(schedule, from = new Date()) {
|
|
95
97
|
const startMinute = Math.floor(from.getTime() / 60_000) + 1; // next whole minute
|
|
96
|
-
const horizon = 60 * 24 *
|
|
98
|
+
const horizon = 60 * 24 * 366;
|
|
97
99
|
for (let i = 0; i < horizon; i++) {
|
|
98
100
|
const d = new Date((startMinute + i) * 60_000);
|
|
99
101
|
if (matches(schedule, d))
|
package/dist/server/api.js
CHANGED
|
@@ -103,7 +103,14 @@ export function buildApp(master) {
|
|
|
103
103
|
// Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
|
|
104
104
|
// the config's `password` field. Static files stay public; only /api/* is
|
|
105
105
|
// gated.
|
|
106
|
-
|
|
106
|
+
// Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
|
|
107
|
+
// the config's `password` field. Static files stay public; only /api/* is
|
|
108
|
+
// gated.
|
|
109
|
+
//
|
|
110
|
+
// Read DYNAMICALLY: the first-run wizard sets `config.password` through
|
|
111
|
+
// /api/setup AFTER buildApp ran — a const captured here once meant a
|
|
112
|
+
// freshly-set-up server stayed wide open until the next process restart.
|
|
113
|
+
const apiToken = () => process.env.TEAPOT_API_TOKEN || master.config.password || "";
|
|
107
114
|
/**
|
|
108
115
|
* Routes where `?token=` is accepted (#79).
|
|
109
116
|
*
|
|
@@ -119,19 +126,21 @@ export function buildApp(master) {
|
|
|
119
126
|
* also accept the query form.
|
|
120
127
|
*/
|
|
121
128
|
const tokenInQueryOk = (path) => path === "/api/ws" || /^\/api\/agents\/[^/]+\/term$/.test(path);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
app.use("/api/*", async (c, next) => {
|
|
130
|
+
const want = apiToken();
|
|
131
|
+
if (!want)
|
|
132
|
+
return next(); // nothing configured yet — the API stays open
|
|
133
|
+
const h = c.req.header("authorization");
|
|
134
|
+
const provided = h?.startsWith("Bearer ") ? h.slice(7) : undefined;
|
|
135
|
+
if (provided && provided === want)
|
|
136
|
+
return next();
|
|
137
|
+
if (tokenInQueryOk(c.req.path)) {
|
|
138
|
+
const q = c.req.query("token");
|
|
139
|
+
if (q && q === want)
|
|
127
140
|
return next();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return next();
|
|
132
|
-
}
|
|
133
|
-
return c.json({ error: "unauthorized" }, 401);
|
|
134
|
-
});
|
|
141
|
+
}
|
|
142
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
143
|
+
});
|
|
135
144
|
// per-agent terminal spawn guard
|
|
136
145
|
const termCounts = new Map();
|
|
137
146
|
/**
|
|
@@ -748,6 +757,12 @@ export function buildApp(master) {
|
|
|
748
757
|
progressIntervalMs: patch.progressIntervalMs,
|
|
749
758
|
progressMinChars: patch.progressMinChars,
|
|
750
759
|
contextTokenBudget: patch.contextTokenBudget,
|
|
760
|
+
// these four were validated (200 ok) and then DROPPED — the settings
|
|
761
|
+
// modal's context window never reached the config at all
|
|
762
|
+
contextWindowTokens: patch.contextWindowTokens,
|
|
763
|
+
maxTurnsPerRound: patch.maxTurnsPerRound,
|
|
764
|
+
onError: patch.onError,
|
|
765
|
+
retryDelayMs: patch.retryDelayMs,
|
|
751
766
|
maxSpawnDepth: patch.maxSpawnDepth,
|
|
752
767
|
tasks: patch.tasks,
|
|
753
768
|
});
|
|
@@ -1036,14 +1051,20 @@ export function buildApp(master) {
|
|
|
1036
1051
|
if (!body)
|
|
1037
1052
|
return c.json({ error: "invalid JSON" }, 400);
|
|
1038
1053
|
await a.setTodo(body.text ?? "");
|
|
1039
|
-
|
|
1054
|
+
// #123: a notification-bearing save must actually WAKE the idle agent (the
|
|
1055
|
+
// queued prompt used to sit forever).
|
|
1056
|
+
//
|
|
1057
|
+
// #123 residual: but the START follows the NOTIFICATION, not the save. The
|
|
1058
|
+
// original fix left `a.start(...)` outside the gate — a brace-less if only
|
|
1059
|
+
// gates the enqueue — so `notify:false` (whose entire meaning is "save
|
|
1060
|
+
// silently") booted a stopped agent, and so did an EMPTY list. A todo save
|
|
1061
|
+
// is note-taking; booting an incarnation is a side effect.
|
|
1062
|
+
const shouldNotify = body.notify !== false && !!body.text?.trim();
|
|
1063
|
+
if (shouldNotify) {
|
|
1040
1064
|
a.enqueuePrompt(`[harness] The operator updated the task list:\n\n${body.text}\n\nWork through it (get_todo() always has the latest).`, "harness");
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
// it had been ignored.
|
|
1045
|
-
if (a.status !== "running")
|
|
1046
|
-
a.start("todo set");
|
|
1065
|
+
if (a.status !== "running")
|
|
1066
|
+
a.start("todo set");
|
|
1067
|
+
}
|
|
1047
1068
|
return c.json({ ok: true });
|
|
1048
1069
|
});
|
|
1049
1070
|
// edit a previously-sent prompt: forks there, optionally summarizes the tail
|
package/dist/server/term-size.js
CHANGED
|
@@ -20,7 +20,7 @@ export class TermSizeTracker {
|
|
|
20
20
|
if (st.rows === rows && st.cols === cols)
|
|
21
21
|
return;
|
|
22
22
|
const wait = Math.max(0, st.busyUntil - Date.now());
|
|
23
|
-
|
|
23
|
+
const apply = () => {
|
|
24
24
|
st.timer = null;
|
|
25
25
|
st.rows = rows;
|
|
26
26
|
st.cols = cols;
|
|
@@ -30,6 +30,18 @@ export class TermSizeTracker {
|
|
|
30
30
|
catch {
|
|
31
31
|
/* child exited mid-debounce */
|
|
32
32
|
}
|
|
33
|
+
};
|
|
34
|
+
st.timer = setTimeout(() => {
|
|
35
|
+
// output may have CONTINUED past the deadline this timer was computed
|
|
36
|
+
// from (markBusy only extends busyUntil — it cannot reach an already
|
|
37
|
+
// pending timer). Writing now would type stty into a running program,
|
|
38
|
+
// so defer to whatever busy window remains.
|
|
39
|
+
const remain = st.busyUntil - Date.now();
|
|
40
|
+
if (remain > 0) {
|
|
41
|
+
st.timer = setTimeout(apply, remain);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
apply();
|
|
33
45
|
}, wait > 0 ? wait : TERM_RESIZE_DEBOUNCE_MS);
|
|
34
46
|
}
|
|
35
47
|
/** a child produced output — defer resizes so we never type into it */
|