teapot-coding-agent 0.28.2 → 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 +37 -7
- 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-DCpmqeFE.js → shiki-F2TFV45_.js} +2 -2
- package/public/assets/{shiki-DCpmqeFE.js.map → shiki-F2TFV45_.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-j-byVujL.js +0 -30
- package/public/assets/index-j-byVujL.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, unlinkSync, openSync, closeSync, fsyncSync, } 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";
|
|
@@ -178,8 +178,14 @@ export function loadConfig(configPath) {
|
|
|
178
178
|
*/
|
|
179
179
|
function isRegularFileTarget(p) {
|
|
180
180
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
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();
|
|
183
189
|
}
|
|
184
190
|
catch {
|
|
185
191
|
// does not exist yet — the parent decides; a real config path will be created
|
|
@@ -442,6 +448,27 @@ export class Master {
|
|
|
442
448
|
this.config.onError = patch.onError;
|
|
443
449
|
if (patch.retryDelayMs !== undefined)
|
|
444
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
|
+
}
|
|
445
472
|
if (patch.tasks) {
|
|
446
473
|
this.config.tasks = patch.tasks;
|
|
447
474
|
// rebuild schedule table live
|
|
@@ -953,10 +980,13 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
953
980
|
: this.childrenOf(parentId).map((c) => c.id)).filter((id) => this.agents.has(id));
|
|
954
981
|
if (targets.length === 0)
|
|
955
982
|
return Promise.resolve({ note: "no live sub-agents to wait for" });
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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());
|
|
960
990
|
const first = active();
|
|
961
991
|
if (first.length === 0)
|
|
962
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 */
|