teapot-coding-agent 0.29.7 → 0.29.9
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/acp/adapter.js +26 -49
- package/dist/agent/agent.js +17 -18
- package/dist/agent/llm.js +29 -58
- package/dist/agent/skills.js +20 -31
- package/dist/agent/tools.js +9 -4
- package/dist/config-schema.js +4 -4
- package/dist/home.js +15 -27
- package/dist/index.js +31 -68
- package/dist/master.js +100 -67
- package/dist/model-meta.js +0 -6
- package/dist/scheduler/cron.js +5 -6
- package/dist/server/term-size.js +2 -3
- package/package.json +1 -1
- package/public/assets/{index-DPfeY2n-.js → index-zmavtYYT.js} +5 -5
- package/public/assets/index-zmavtYYT.js.map +1 -0
- package/public/assets/{shiki-BQcHFGv4.js → shiki-DhnjYQM7.js} +2 -2
- package/public/assets/{shiki-BQcHFGv4.js.map → shiki-DhnjYQM7.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DPfeY2n-.js.map +0 -1
package/dist/acp/adapter.js
CHANGED
|
@@ -4,10 +4,9 @@ export const ACP_PROTOCOL_VERSION = 1;
|
|
|
4
4
|
/**
|
|
5
5
|
* How often a running turn is drained for new events.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* is
|
|
9
|
-
*
|
|
10
|
-
* is already appending to — cheap enough to be invisible next to an LLM call.
|
|
7
|
+
* ACP has no push channel, so the turn is streamed by polling the agent's own
|
|
8
|
+
* log. 150ms is well under the ~60ms render tick a human notices, and the read is
|
|
9
|
+
* a tail of a file this process is already appending to.
|
|
11
10
|
*/
|
|
12
11
|
const STREAM_POLL_MS = 150;
|
|
13
12
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -16,18 +15,11 @@ const PARSE_ERROR = -32700;
|
|
|
16
15
|
const METHOD_NOT_FOUND = -32601;
|
|
17
16
|
const INVALID_PARAMS = -32602;
|
|
18
17
|
const INTERNAL_ERROR = -32603;
|
|
19
|
-
/**
|
|
20
|
-
* Run the ACP adapter until stdin closes.
|
|
21
|
-
*
|
|
22
|
-
* Kept as a class rather than free functions so the session map and the pending
|
|
23
|
-
* request bookkeeping have somewhere to live, and so it is directly testable
|
|
24
|
-
* with an in-memory stream pair.
|
|
25
|
-
*/
|
|
26
18
|
/* ---------- streaming: log events → session/update notifications ---------- */
|
|
27
19
|
/**
|
|
28
20
|
* ACP `ToolCall.kind` for a teapot tool name.
|
|
29
21
|
*
|
|
30
|
-
* The schema's own enum (verified against schema/v1/schema.json
|
|
22
|
+
* The schema's own enum (verified against schema/v1/schema.json):
|
|
31
23
|
* read, edit, delete, move, search, execute, fetch, think, switch_mode, other,
|
|
32
24
|
* description. Editors use this to pick an icon and to decide whether the call
|
|
33
25
|
* mutates anything, so mapping our tools onto it is worth doing properly.
|
|
@@ -81,22 +73,18 @@ function toolLocation(name, args, workspace) {
|
|
|
81
73
|
return [{ path: abs, ...(Number.isFinite(line) && line > 0 ? { line } : {}) }];
|
|
82
74
|
}
|
|
83
75
|
/**
|
|
84
|
-
* Turn one logged event into the session/update payload for it, or null when
|
|
85
|
-
*
|
|
86
|
-
* one).
|
|
76
|
+
* Turn one logged event into the session/update payload for it, or null when the
|
|
77
|
+
* event has no ACP equivalent (and must therefore NOT be invented into one).
|
|
87
78
|
*
|
|
88
|
-
*
|
|
89
|
-
* how the previous scaffold went unverified: `drainOnce` existed, was never
|
|
90
|
-
* called, and nothing noticed.
|
|
79
|
+
* Exported so the mapping is testable without a transport.
|
|
91
80
|
*/
|
|
92
81
|
export function updateForEvent(e, opts) {
|
|
93
82
|
const d = (e.data ?? {});
|
|
94
83
|
switch (e.type) {
|
|
95
84
|
case "message": {
|
|
96
85
|
// The completion AUDIT's verdict and the operator-facing summary are
|
|
97
|
-
// timeline content, not something the agent said to the model
|
|
98
|
-
//
|
|
99
|
-
// shown an agent speaking a harness message.
|
|
86
|
+
// timeline content, not something the agent said to the model; an editor
|
|
87
|
+
// must not be shown an agent speaking a harness message.
|
|
100
88
|
if (d.final === true || d.operatorFacing === true)
|
|
101
89
|
return null;
|
|
102
90
|
const text = String(d.content ?? "");
|
|
@@ -245,9 +233,8 @@ export class AcpAdapter {
|
|
|
245
233
|
this.initialized = true;
|
|
246
234
|
return {
|
|
247
235
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
248
|
-
// Only capabilities we actually implement
|
|
249
|
-
//
|
|
250
|
-
// would be worse than an honest omission.
|
|
236
|
+
// Only capabilities we actually implement: a client calls what we
|
|
237
|
+
// advertise, so a partial implementation would be worse than omission.
|
|
251
238
|
agentCapabilities: { loadSession: true, promptCapabilities: { image: false, audio: false } },
|
|
252
239
|
authMethods: [],
|
|
253
240
|
};
|
|
@@ -278,9 +265,8 @@ export class AcpAdapter {
|
|
|
278
265
|
const s = p.sessionId ? this.sessions.get(String(p.sessionId)) : undefined;
|
|
279
266
|
if (!s)
|
|
280
267
|
return;
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// the component that answered the cancel notification.
|
|
268
|
+
// The Agent's own "stopped" flag is private, and this adapter is the
|
|
269
|
+
// component that answered the cancel notification anyway.
|
|
284
270
|
this.cancelled.add(s.id);
|
|
285
271
|
s.agent.stop("cancelled via ACP");
|
|
286
272
|
}
|
|
@@ -298,10 +284,7 @@ export class AcpAdapter {
|
|
|
298
284
|
return this.fail(id, INVALID_PARAMS, "prompt must contain text");
|
|
299
285
|
this.turnUpdates.set(id, { sessionId: s.id });
|
|
300
286
|
this.cancelled.delete(s.id); // a cancel is per-turn, not per-session
|
|
301
|
-
//
|
|
302
|
-
// call appear as they happen; without this the client sees NOTHING until
|
|
303
|
-
// the whole turn finishes, which is the difference between a usable agent
|
|
304
|
-
// and a black box that eventually prints an answer.
|
|
287
|
+
// Without streaming, the client sees NOTHING until the whole turn finishes.
|
|
305
288
|
const abort = new AbortController();
|
|
306
289
|
const stream = this.streamTurn(s.id, abort.signal).catch(() => { });
|
|
307
290
|
try {
|
|
@@ -324,7 +307,7 @@ export class AcpAdapter {
|
|
|
324
307
|
this.turnUpdates.delete(id);
|
|
325
308
|
}
|
|
326
309
|
}
|
|
327
|
-
/**
|
|
310
|
+
/** drain a session's log tail into session/update notifications */
|
|
328
311
|
async drainOnce(sessionId, afterSeq = 0) {
|
|
329
312
|
const s = this.sessions.get(sessionId);
|
|
330
313
|
if (!s)
|
|
@@ -334,34 +317,28 @@ export class AcpAdapter {
|
|
|
334
317
|
if (e.seq <= afterSeq)
|
|
335
318
|
continue;
|
|
336
319
|
const update = updateForEvent(e, { workspace: s.cwd });
|
|
337
|
-
// null means "no ACP equivalent" — state flips, goals, notes
|
|
338
|
-
// silent is correct; the old code invented a tool_call for every one.
|
|
320
|
+
// null means "no ACP equivalent" — state flips, goals, notes
|
|
339
321
|
if (update)
|
|
340
322
|
this.notify("session/update", { sessionId, update });
|
|
341
323
|
}
|
|
342
|
-
// Advance past EVERY event
|
|
343
|
-
//
|
|
324
|
+
// Advance past EVERY event read, emitted or not, so nothing is reconsidered
|
|
325
|
+
// on the next poll.
|
|
344
326
|
return events.reduce((m, e) => Math.max(m, e.seq), afterSeq);
|
|
345
327
|
}
|
|
346
328
|
/**
|
|
347
329
|
* Poll one session until it settles, streaming each batch as it appears.
|
|
348
330
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* exactly the same updates.
|
|
331
|
+
* The JSONL log rather than the bus is deliberate: a bus listener attached at
|
|
332
|
+
* the wrong moment can miss the start of a reply, and after a crash mid-turn
|
|
333
|
+
* the file is the one complete account of what happened. It also means a
|
|
334
|
+
* resumed session replays exactly the same updates.
|
|
354
335
|
*/
|
|
355
336
|
async streamTurn(sessionId, signal) {
|
|
356
|
-
//
|
|
357
|
-
// earlier turns, and a resumed session has already been replayed.
|
|
337
|
+
// Everything before this prompt belongs to earlier turns.
|
|
358
338
|
let cursor = await this.logTail(sessionId);
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// then bailed on `signal.aborted` would exit having drained NOTHING. That
|
|
363
|
-
// is exactly what happened: every turn raced the first tick and the
|
|
364
|
-
// editor received silence.
|
|
339
|
+
// The drain happens BEFORE the abort check: a turn can finish inside one poll
|
|
340
|
+
// interval, so a loop that slept and then bailed on `signal.aborted` would
|
|
341
|
+
// exit having drained NOTHING and the editor would receive silence.
|
|
365
342
|
for (;;) {
|
|
366
343
|
cursor = await this.drainOnce(sessionId, cursor);
|
|
367
344
|
if (signal.aborted)
|
package/dist/agent/agent.js
CHANGED
|
@@ -538,6 +538,16 @@ export class Agent {
|
|
|
538
538
|
* that wait would hang. isLive() answers "stop vs start" (#59/#133); this
|
|
539
539
|
* answers "is work in flight".
|
|
540
540
|
*/
|
|
541
|
+
/**
|
|
542
|
+
* The text of every prompt still waiting for a turn boundary.
|
|
543
|
+
*
|
|
544
|
+
* Read-only view for callers that need to know WHAT is queued, not just how
|
|
545
|
+
* many — a delivery assertion has to see the text, or "the message arrived"
|
|
546
|
+
* degrades into "something is queued".
|
|
547
|
+
*/
|
|
548
|
+
pendingPromptTexts() {
|
|
549
|
+
return this.pendingPrompts.map((p) => p.text);
|
|
550
|
+
}
|
|
541
551
|
isBusy() {
|
|
542
552
|
if (this.stopRequested)
|
|
543
553
|
return false;
|
|
@@ -1802,25 +1812,14 @@ export class Agent {
|
|
|
1802
1812
|
text: "",
|
|
1803
1813
|
reasoning: "",
|
|
1804
1814
|
});
|
|
1805
|
-
// #37: `prompt-delivered`
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
// refreshSkills, maybeRequestProgress and — critically — maybeCompact. So
|
|
1811
|
-
// the marker preceded the request, and `maybeCompact()` can itself make an
|
|
1812
|
-
// LLM call, which meant the timeline claimed a delivery order the API never
|
|
1813
|
-
// saw. Measured before the fix:
|
|
1814
|
-
//
|
|
1815
|
-
// LLM CALL #1
|
|
1816
|
-
// seq=5 prompt-delivered("first prompt")
|
|
1817
|
-
// seq=6 llm turn start
|
|
1818
|
-
//
|
|
1819
|
-
// The UI reorders by this marker (resequenceToDelivery), so the semantics
|
|
1820
|
-
// were the bug — not timeline-order.ts.
|
|
1815
|
+
// #37: `prompt-delivered` means "the outbound request CARRYING this prompt
|
|
1816
|
+
// has started" — which is why it is emitted here and not in
|
|
1817
|
+
// drainPendingPrompts. That runs BEFORE maybeCompact, which can itself make
|
|
1818
|
+
// an LLM call, so the marker preceded the request and the timeline claimed a
|
|
1819
|
+
// delivery order the API never saw.
|
|
1821
1820
|
//
|
|
1822
|
-
//
|
|
1823
|
-
//
|
|
1821
|
+
// Once per request, not per attempt: a retry sends the same payload, so
|
|
1822
|
+
// re-emitting would move the marker past later events on each try.
|
|
1824
1823
|
if (attempt === 1)
|
|
1825
1824
|
await this.markDeliveredPrompts();
|
|
1826
1825
|
try {
|
package/dist/agent/llm.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* LLM access via the official `openai` npm client (OpenAI-compatible APIs:
|
|
3
3
|
* OpenAI, OpenRouter, local vLLM/Ollama, ...).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Retries, timeouts and response-shape handling are delegated to the SDK rather
|
|
6
|
+
* than hand-rolled.
|
|
7
7
|
*/
|
|
8
8
|
import OpenAI from "openai";
|
|
9
9
|
const clients = new WeakMap();
|
|
@@ -73,11 +73,6 @@ function client(cfg) {
|
|
|
73
73
|
}
|
|
74
74
|
return c;
|
|
75
75
|
}
|
|
76
|
-
/**
|
|
77
|
-
* Providers are picky in different ways; the common denominator is that
|
|
78
|
-
* empty text content in user/assistant messages makes many of them 400.
|
|
79
|
-
* Fill blanks with a harmless placeholder before sending.
|
|
80
|
-
*/
|
|
81
76
|
/**
|
|
82
77
|
* Providers occasionally glue MULTIPLE tool calls into one entry, or leak
|
|
83
78
|
* their internal framing into the name field (seen live: a name of
|
|
@@ -119,6 +114,7 @@ function sanitize(messages) {
|
|
|
119
114
|
: { type: "text", text: p.text ?? "" });
|
|
120
115
|
return { role: m.role, content: parts };
|
|
121
116
|
}
|
|
117
|
+
// empty text content makes many providers 400 — send a harmless placeholder
|
|
122
118
|
if ((m.role === "user" || m.role === "assistant") && !m.content) {
|
|
123
119
|
return { ...m, content: m.tool_calls?.length ? "(tool call)" : "(no content)" };
|
|
124
120
|
}
|
|
@@ -129,22 +125,16 @@ function sanitize(messages) {
|
|
|
129
125
|
});
|
|
130
126
|
}
|
|
131
127
|
/**
|
|
132
|
-
* Why did the response carry no usable message?
|
|
133
|
-
*
|
|
134
|
-
* #50 — this used to be a bare "LLM API returned no choices", which is what
|
|
135
|
-
* made the report undiagnosable: an empty `choices` array, a 200 carrying an
|
|
136
|
-
* `error` object, a `delta`-shaped body, and an SSE stream that the SDK
|
|
137
|
-
* handed back as a plain String all produce the identical line, and none of
|
|
138
|
-
* them is a useful thing to read in a log.
|
|
128
|
+
* Why did the response carry no usable message? (#50)
|
|
139
129
|
*
|
|
140
|
-
* The leading text is kept stable at "LLM API returned no choices" so
|
|
141
|
-
*
|
|
142
|
-
*
|
|
130
|
+
* The leading text is kept stable at "LLM API returned no choices" so existing
|
|
131
|
+
* matching (and the retry notes operators have learned to read) still works; the
|
|
132
|
+
* diagnosis follows after a colon.
|
|
143
133
|
*/
|
|
144
134
|
function noChoicesDetail(res) {
|
|
145
135
|
const base = "LLM API returned no choices";
|
|
146
136
|
// a gateway that answers 200 with an error object instead of failing: the
|
|
147
|
-
// actionable message is right there
|
|
137
|
+
// actionable message is right there
|
|
148
138
|
const err = res?.error;
|
|
149
139
|
if (err && typeof err === "object") {
|
|
150
140
|
const msg = typeof err.message === "string" ? err.message : JSON.stringify(err);
|
|
@@ -271,11 +261,9 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
271
261
|
}
|
|
272
262
|
catch (err) {
|
|
273
263
|
const e = err;
|
|
274
|
-
// #50: our own shape-diagnosis
|
|
275
|
-
// the
|
|
276
|
-
//
|
|
277
|
-
// relabelled it "LLM API error ?: …", hiding both the diagnosis and the
|
|
278
|
-
// fact that no status was ever involved.
|
|
264
|
+
// #50: our own shape-diagnosis reads as a full sentence but carries no HTTP
|
|
265
|
+
// status (the request SUCCEEDED — that is the whole problem), so the generic
|
|
266
|
+
// wrapper below would relabel it "LLM API error ?: …".
|
|
279
267
|
if (String(err.message ?? "").startsWith("LLM API returned no choices"))
|
|
280
268
|
throw err;
|
|
281
269
|
if (e.status === undefined && !String(err.message).includes("provider returned"))
|
|
@@ -366,11 +354,8 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
366
354
|
});
|
|
367
355
|
if (!message.content && !message.tool_calls) {
|
|
368
356
|
// #50: a stream that opened, produced NOTHING, and closed cleanly. The
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// proxy, and re-issuing it without `stream` is a genuinely different
|
|
372
|
-
// request that usually succeeds. Throwing a bare Error would make the two
|
|
373
|
-
// indistinguishable and cost us that safety net.
|
|
357
|
+
// marker lets the handler below tell this apart from a request the provider
|
|
358
|
+
// actively rejected — re-issuing THIS one without `stream` usually succeeds.
|
|
374
359
|
const e = new Error("LLM API error: empty completion");
|
|
375
360
|
e.emptyStream = true;
|
|
376
361
|
throw e;
|
|
@@ -378,38 +363,24 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
378
363
|
return { message, reasoning: reasoning || undefined, usage };
|
|
379
364
|
}
|
|
380
365
|
catch (err) {
|
|
381
|
-
// #50 — a provider that doesn't support streaming at all still gets ONE
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
// resulting "LLM API returned no choices" replaced the real diagnosis.
|
|
390
|
-
// That is why a restored 1500-message session reported "no choices" three
|
|
391
|
-
// times and then errored: the genuine "context length exceeded" was
|
|
392
|
-
// discarded on the first attempt and could never be recognised by
|
|
393
|
-
// isContextOverflow(), so the compact-and-retry recovery never ran.
|
|
394
|
-
//
|
|
395
|
-
// Two exclusions, and the second one matters as much as the first:
|
|
366
|
+
// #50 — a provider that doesn't support streaming at all still gets ONE clean
|
|
367
|
+
// fallback, but ONLY when the failure could plausibly be the stream itself.
|
|
368
|
+
// The condition must NOT be `!gotChunk && !signal?.aborted`: that also matches
|
|
369
|
+
// a request that FAILED (a 200 carrying an SSE `error` frame, a context-length
|
|
370
|
+
// rejection), and the fallback then re-issues the same oversized request,
|
|
371
|
+
// replacing the real diagnosis — a restored 1500-message session reports "no
|
|
372
|
+
// choices" instead of "context length exceeded", so isContextOverflow() never
|
|
373
|
+
// runs compact-and-retry.
|
|
396
374
|
//
|
|
397
|
-
// 1. A status
|
|
398
|
-
// re-
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// 2. A THROWN error, as opposed to a request that simply produced no
|
|
404
|
-
// chunks. A 200 that streams zero chunks and closes cleanly is a
|
|
405
|
-
// broken/flaky proxy, and that is precisely the case the non-streaming
|
|
406
|
-
// retry rescues. Requiring a 4xx here would have deleted that safety
|
|
407
|
-
// net — a regression this file's own tests caught.
|
|
375
|
+
// 1. A status in STREAM_REJECTED_STATUS: only a request rejected outright is a
|
|
376
|
+
// genuinely DIFFERENT request when re-issued without `stream`.
|
|
377
|
+
// 2. `emptyStream`: a 200 that streams zero chunks and closes cleanly is a
|
|
378
|
+
// broken/flaky proxy — precisely the case the retry rescues. Requiring a
|
|
379
|
+
// 4xx here would delete that safety net, a regression this file's own
|
|
380
|
+
// tests caught.
|
|
408
381
|
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// 5xx, an error frame, a mid-stream failure — is the ANSWER, rethrown
|
|
412
|
-
// untouched so overflow recovery can see it.
|
|
382
|
+
// Everything else — 5xx, an error frame, a mid-stream failure — is the ANSWER,
|
|
383
|
+
// rethrown untouched so overflow recovery can see it.
|
|
413
384
|
const status = err?.status;
|
|
414
385
|
const rejectedByProvider = typeof status === "number" && STREAM_REJECTED_STATUS.has(status);
|
|
415
386
|
const emptyStream = err?.emptyStream === true;
|
package/dist/agent/skills.js
CHANGED
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* (free-form instructions shown to the agent when it loads the skill)
|
|
13
13
|
*
|
|
14
14
|
* Roots are scanned in priority order; the first skill with a given name wins.
|
|
15
|
-
* Workspace skills (<workspace>/skills) beat global ones (~config/skills), so
|
|
16
|
-
*
|
|
17
|
-
* git-friendly Markdown — no database, no lock-in.
|
|
15
|
+
* Workspace skills (<workspace>/skills) beat global ones (~config/skills), so a
|
|
16
|
+
* project can override shared defaults.
|
|
18
17
|
*/
|
|
19
18
|
import { promises as fs } from "node:fs";
|
|
20
19
|
import path from "node:path";
|
|
@@ -26,12 +25,10 @@ export const SKILL_FILE = "SKILL.md";
|
|
|
26
25
|
* can skip a full rescan (a readdir per root plus a read of every SKILL.md)
|
|
27
26
|
* on the overwhelmingly common "nothing changed" path.
|
|
28
27
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* here too (saveSkill rewrites it), so bundled-file edits ride along with
|
|
34
|
-
* the next skill write rather than triggering their own rescan.
|
|
28
|
+
* It must walk every SKILL.md the discovery walk could adopt, not just the
|
|
29
|
+
* roots: writing SKILL.md does not bump the parent directory's mtime. Helper
|
|
30
|
+
* scripts edited in place ride along with the next skill write (saveSkill
|
|
31
|
+
* rewrites SKILL.md), rather than triggering their own rescan.
|
|
35
32
|
*/
|
|
36
33
|
export async function skillRootsFingerprint(roots) {
|
|
37
34
|
const parts = [];
|
|
@@ -46,15 +43,13 @@ export async function skillRootsFingerprint(roots) {
|
|
|
46
43
|
continue;
|
|
47
44
|
}
|
|
48
45
|
parts.push(`${st.mtimeMs}:${st.size}`);
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// stamp never changed, refreshSkills early-returned, and the skill stayed
|
|
52
|
-
// invisible/stale in list_skills and the system-prompt catalogue
|
|
46
|
+
// same depth bound as the discovery walk, or a NESTED skill's add/edit leaves
|
|
47
|
+
// this stamp unchanged and the skill stays stale in list_skills
|
|
53
48
|
await stampSkillFiles(root.dir, 0, parts);
|
|
54
49
|
}
|
|
55
50
|
return parts.join("|");
|
|
56
51
|
}
|
|
57
|
-
/** recursive fingerprint walk — mirrors walkSkills' bound
|
|
52
|
+
/** recursive fingerprint walk — mirrors walkSkills' depth bound */
|
|
58
53
|
async function stampSkillFiles(dir, depth, parts) {
|
|
59
54
|
if (depth > MAX_SKILL_DEPTH)
|
|
60
55
|
return;
|
|
@@ -88,16 +83,16 @@ export function isValidSkillName(name) {
|
|
|
88
83
|
* Minimal frontmatter parser for the subset we need: a leading `---` block of
|
|
89
84
|
* `key: value` lines. No YAML dependency by design.
|
|
90
85
|
*
|
|
91
|
-
* It does understand the two YAML shapes
|
|
92
|
-
*
|
|
86
|
+
* It does understand the two YAML shapes common in REAL third-party skills
|
|
87
|
+
* (#28), because without them those files are unreadable:
|
|
93
88
|
* - FOLDED/LITERAL BLOCK SCALARS (`description: >-` … or `|` …), which is how
|
|
94
89
|
* most skill authors write a long description. Reading them naively yields
|
|
95
90
|
* the literal ">-" as the description, which silently destroys the one field
|
|
96
91
|
* the model matches its task against.
|
|
97
92
|
* - QUOTED values, whose surrounding quotes are stripped so a description
|
|
98
93
|
* containing a colon survives.
|
|
99
|
-
* A key
|
|
100
|
-
* than recorded as
|
|
94
|
+
* A key with an empty value (e.g. a nested `metadata:` block) is left out rather
|
|
95
|
+
* than recorded as "".
|
|
101
96
|
*/
|
|
102
97
|
export function parseSkillMd(text) {
|
|
103
98
|
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
@@ -149,7 +144,6 @@ function unquote(v) {
|
|
|
149
144
|
}
|
|
150
145
|
return s;
|
|
151
146
|
}
|
|
152
|
-
/** Root dirs in priority order: [0] overrides later ones on name clash. */
|
|
153
147
|
/**
|
|
154
148
|
* Skill directories owned by OTHER coding harnesses (#28).
|
|
155
149
|
*
|
|
@@ -205,15 +199,14 @@ export function foreignSkillRoots(workspace) {
|
|
|
205
199
|
*
|
|
206
200
|
* Bounded because a skill directory also holds BUNDLED FILES (helper scripts)
|
|
207
201
|
* and arbitrary payload, so an unbounded walk is not safe. Depth is not the
|
|
208
|
-
* thing that makes a directory a skill
|
|
209
|
-
*
|
|
202
|
+
* thing that makes a directory a skill — holding a SKILL.md is — so the bound
|
|
203
|
+
* only limits how far we look, never what counts.
|
|
210
204
|
*/
|
|
211
205
|
const MAX_SKILL_DEPTH = 4;
|
|
212
206
|
/** Root dirs in priority order: [0] overrides later ones on name clash. */
|
|
213
207
|
export async function discoverSkills(roots) {
|
|
214
208
|
const byName = new Map();
|
|
215
|
-
// name -> depth, so a SHALLOWER skill
|
|
216
|
-
// a deep one silently shadowing the obvious skill (#27)
|
|
209
|
+
// name -> depth, so a SHALLOWER skill beats a nested namesake (#27)
|
|
217
210
|
const depthByName = new Map();
|
|
218
211
|
for (const root of roots) {
|
|
219
212
|
await walkSkills(root.dir, root.source, 0, byName, depthByName);
|
|
@@ -223,9 +216,8 @@ export async function discoverSkills(roots) {
|
|
|
223
216
|
/**
|
|
224
217
|
* Depth-first walk collecting every directory that holds a SKILL.md (#27).
|
|
225
218
|
*
|
|
226
|
-
* A directory
|
|
227
|
-
*
|
|
228
|
-
* from being reported as skills themselves.
|
|
219
|
+
* A directory without its own SKILL.md is only a container to descend into, which
|
|
220
|
+
* is what keeps bundled helper directories from being reported as skills.
|
|
229
221
|
*/
|
|
230
222
|
async function walkSkills(dir, source, depth, byName, depthByName) {
|
|
231
223
|
if (depth > MAX_SKILL_DEPTH)
|
|
@@ -250,7 +242,6 @@ async function walkSkills(dir, source, depth, byName, depthByName) {
|
|
|
250
242
|
const parsed = parseSkillMd(hereText);
|
|
251
243
|
const name = parsed.meta.name || path.basename(dir);
|
|
252
244
|
const prevDepth = depthByName.get(name);
|
|
253
|
-
// first root wins on a name clash, and a shallower skill beats a deeper one
|
|
254
245
|
const wins = prevDepth === undefined ||
|
|
255
246
|
(prevDepth !== undefined && depth < prevDepth && byName.get(name)?.source === source);
|
|
256
247
|
if (wins) {
|
|
@@ -277,10 +268,8 @@ async function walkSkills(dir, source, depth, byName, depthByName) {
|
|
|
277
268
|
for (const e of entries) {
|
|
278
269
|
if (!e.isDirectory() || e.name.startsWith("."))
|
|
279
270
|
continue;
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
// Bundled helper payloads are not mistaken for skills because a directory
|
|
283
|
-
// only BECOMES a skill when it holds its own SKILL.md (#27).
|
|
271
|
+
// a skill can sit at ANY depth (skills/stage1/template/SKILL.md), and
|
|
272
|
+
// "stage1" itself has no SKILL.md (#27)
|
|
284
273
|
await walkSkills(path.join(dir, e.name), source, depth + 1, byName, depthByName);
|
|
285
274
|
}
|
|
286
275
|
}
|
package/dist/agent/tools.js
CHANGED
|
@@ -1589,7 +1589,9 @@ export const TOOLS = [
|
|
|
1589
1589
|
},
|
|
1590
1590
|
{
|
|
1591
1591
|
name: "list_children",
|
|
1592
|
-
description: "List
|
|
1592
|
+
description: "List the sub-agents working under you: id, status, current goal. " +
|
|
1593
|
+
"Includes NESTED ones your sub-agents spawned (marked (nested) with their own parent), " +
|
|
1594
|
+
"so you can see work you would otherwise be unaware of — wait_children waits for these too.",
|
|
1593
1595
|
parameters: { type: "object", properties: {} },
|
|
1594
1596
|
async run(_args, ctx) {
|
|
1595
1597
|
const sa = ctx.subAgents;
|
|
@@ -1601,7 +1603,8 @@ export const TOOLS = [
|
|
|
1601
1603
|
return {
|
|
1602
1604
|
ok: true,
|
|
1603
1605
|
result: kids
|
|
1604
|
-
.map((k) => `${k.id} · ${k.status} · ${clip(k.goal, 60)}`
|
|
1606
|
+
.map((k) => `${k.id} · ${k.status} · ${clip(k.goal, 60)}` +
|
|
1607
|
+
(k.nested ? ` (nested — spawned by @${k.parent}; address @${k.parent} to steer it)` : ""))
|
|
1605
1608
|
.join("\n"),
|
|
1606
1609
|
};
|
|
1607
1610
|
},
|
|
@@ -1630,8 +1633,10 @@ export const TOOLS = [
|
|
|
1630
1633
|
{
|
|
1631
1634
|
name: "wait_children",
|
|
1632
1635
|
description: "Park until at least one sub-agent settles (finished, errored, stopped, or asks you a question) — " +
|
|
1633
|
-
"or until the timeout lapses.
|
|
1634
|
-
"
|
|
1636
|
+
"or until the timeout lapses. Waits for your whole SUBTREE: a sub-agent that spawned its own " +
|
|
1637
|
+
"sub-agents is not settled while those are still working, so this never returns over live nested " +
|
|
1638
|
+
"work. Costs zero tokens while parked: prefer this over bash sleep when waiting on spawned work. " +
|
|
1639
|
+
"The settling child's report is delivered to you afterwards.",
|
|
1635
1640
|
parameters: {
|
|
1636
1641
|
type: "object",
|
|
1637
1642
|
properties: {
|
package/dist/config-schema.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zod schema for teapot's config — the single source of truth for what a
|
|
3
|
-
* valid config looks like
|
|
4
|
-
*
|
|
3
|
+
* valid config looks like, used to gate web-UI edits (PUT /api/config) and any
|
|
4
|
+
* programmatic writes with human-readable error messages.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Validation gates; it is not the storage format — the master persists the raw
|
|
7
|
+
* user JSON so unknown fields survive round-trips.
|
|
8
8
|
*/
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
export const ProviderSchema = z.object({
|
package/dist/home.js
CHANGED
|
@@ -1,29 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The user's home directory, on every platform.
|
|
2
|
+
* The user's home directory, on every platform (#110).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `~`. That is the reported symptom.
|
|
4
|
+
* `process.env.HOME` is a POSIX convention and is normally UNSET on Windows, so
|
|
5
|
+
* expanding `~` with `process.env.HOME ?? "~"` produced the LITERAL "~" there —
|
|
6
|
+
* and since the result reached `path.resolve` + `mkdirSync`, teapot created a
|
|
7
|
+
* directory named `~`.
|
|
9
8
|
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* on a Linux CI box would report `/root` and hide the very condition under test.
|
|
14
|
-
*
|
|
15
|
-
* Returns "" only when nothing at all is known, which callers treat as "leave the
|
|
16
|
-
* `~` alone" rather than inventing a path.
|
|
9
|
+
* The `env` parameters exist so a test can drive the Windows shape
|
|
10
|
+
* deterministically; `os.homedir()` on a Linux CI box would report `/root` and
|
|
11
|
+
* hide the condition under test.
|
|
17
12
|
*/
|
|
13
|
+
/** @returns "" when no home is known — callers leave `~` alone rather than invent a path */
|
|
18
14
|
export function homeDir(env = process.env) {
|
|
19
15
|
if (env.HOME)
|
|
20
16
|
return env.HOME;
|
|
21
17
|
if (env.USERPROFILE)
|
|
22
18
|
return env.USERPROFILE;
|
|
23
|
-
// Windows
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// class of bug this helper exists to remove.
|
|
19
|
+
// Windows can report HOMEDRIVE + HOMEPATH. Both must be present:
|
|
20
|
+
// `undefined + path` yields the STRING "undefined", which is exactly the class
|
|
21
|
+
// of bug this helper exists to remove.
|
|
27
22
|
const drive = env.HOMEDRIVE;
|
|
28
23
|
const rest = env.HOMEPATH;
|
|
29
24
|
if (drive && rest)
|
|
@@ -32,20 +27,13 @@ export function homeDir(env = process.env) {
|
|
|
32
27
|
return drive;
|
|
33
28
|
return "";
|
|
34
29
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Expand a leading `~` to the home directory.
|
|
37
|
-
*
|
|
38
|
-
* Kept in one place so the Windows case cannot be got wrong a second time — the
|
|
39
|
-
* bug existed in three separate places before this helper (#110).
|
|
40
|
-
*/
|
|
30
|
+
/** Expand a leading `~`. One place, so the Windows case cannot be got wrong twice (#110). */
|
|
41
31
|
export function expandTilde(input, env = process.env) {
|
|
42
|
-
//
|
|
32
|
+
// a Windows path can be `~\foo`, hence the backslash in the lookahead
|
|
43
33
|
if (!/^~(?=$|[/\\])/.test(input))
|
|
44
34
|
return input;
|
|
45
35
|
const home = homeDir(env);
|
|
46
|
-
// With no home known, returning the input untouched is more honest than
|
|
47
|
-
// substituting a literal that would become a real directory.
|
|
48
36
|
if (!home)
|
|
49
|
-
return input;
|
|
37
|
+
return input; // no home known: leaving `~` is more honest than inventing one
|
|
50
38
|
return input.replace(/^~(?=$|[/\\])/, home);
|
|
51
39
|
}
|