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.
@@ -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
- * The web UI gets a WebSocket push; ACP has no such channel here, so the turn
8
- * is streamed by polling the agent's own log. 150ms is well under the ~60ms
9
- * render tick a human notices, and the read is a tail of a file this process
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, not memory):
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
- * the event has no ACP equivalent (and must therefore NOT be invented into
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
- * Pure and exported so the mapping is testable without a transport, which is
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. They are
98
- // marked precisely so the web feed can skip them; an editor must not be
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. Terminal/fs are deliberately
249
- // absent: a client calls what we advertise, and a partial implementation
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
- // Remember it HERE rather than asking the Agent whether it was stopped:
282
- // the flag that says so is private, and the adapter already knows — it is
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
- // Stream the turn as it happens. The editor shows the reply and each tool
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
- /** Stream a session's events to the client as session/update notifications. */
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. Staying
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 we read, including the ones we did not emit, so
343
- // an un-emitted event is not reconsidered on the next poll.
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
- * Polling the JSONL rather than subscribing to the bus is deliberate: the log
350
- * is the agent's own record, so a listener attached at the wrong moment can
351
- * miss the start of a reply, and a crash mid-turn leaves the file as the one
352
- * complete account of what happened. It also means a resumed session replays
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
- // Start from the CURRENT tail: everything before this prompt belongs to
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
- // NOTE the drain happens BEFORE the abort check, not after it. A short
360
- // turn — a cached model, a tiny task, a failing endpoint that gives up
361
- // quickly — can finish inside one poll interval, so a loop that slept and
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)
@@ -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` must mean "the outbound request CARRYING this
1806
- // prompt has started", not "the prompt moved from the queue into
1807
- // `this.messages`".
1808
- //
1809
- // It used to be logged in drainPendingPrompts, which runs BEFORE
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
- // Emitted ONCE per request, not per attempt: a retry sends the same payload,
1823
- // so re-emitting would move the marker after a later event on each try.
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
- * We deliberately delegate retries/timeouts/response-shape handling to the
6
- * SDK instead of hand-rolling them.
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 any
141
- * existing matching (and the retry notes operators have learned to read)
142
- * still works; the diagnosis follows after a colon.
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 and used to be thrown away
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 already reads as a full sentence and names
275
- // the response it saw. It carries no HTTP status (the request SUCCEEDED —
276
- // that is the whole problem), so the generic wrapper below would have
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
- // throw carries a marker so the handler below can tell this apart from a
370
- // request the provider actively rejected: this one is a broken/flaky
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
- // clean fallback, but ONLY when the streaming attempt produced nothing for
383
- // a reason that could plausibly be the stream itself.
384
- //
385
- // The old condition was `!gotChunk && !signal?.aborted`, which also matched
386
- // a request that FAILED — a gateway answering 200 with an SSE `error`
387
- // frame, or a context-length rejection. In those cases the fallback
388
- // re-issued the same oversized request in a different shape, and the
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 that means the REQUEST ITSELF was rejected. Only then is
398
- // re-issuing it without `stream` a different request rather than the
399
- // same failing one. `400`/`404`/`405`/`422` are the shapes a provider
400
- // uses to say "no streaming here" — deliberately NOT the whole 4xx
401
- // range, because 408/425/429 are transient: replaying those against an
402
- // already-saturated provider just doubles the request count.
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
- // So the fallback runs when the request was rejected outright, OR when it
410
- // completed without error and without a single chunk. Everything else —
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;
@@ -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
- * a project can override shared defaults. Everything stays human-readable and
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
- * The fingerprint covers each root's own mtime/size AND the mtime/size of
30
- * every SKILL.md one level down, because writing SKILL.md does not bump the
31
- * parent directory's mtime. Helper scripts edited in place are covered by
32
- * the SKILL.md stat only in aggregate; a rewritten SKILL.md always lands
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
- // 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
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 and containment rules */
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 that are common in REAL third-party
92
- * skills (#28), because without them those files are unreadable:
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 whose value is empty (e.g. a nested `metadata:` block) is left out rather
100
- * than recorded as an empty string.
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, though — holding a SKILL.md is — so the
209
- * bound only limits how far we look, never what counts.
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 wins over a nested namesake rather than
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 WITHOUT its own SKILL.md is only a container to descend into — we
227
- * never treat one as a skill, which is what keeps bundled helper directories
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
- // Recurse into every subdirectory: nesting means a skill can sit at ANY depth
281
- // (skills/stage1/template/SKILL.md), and "stage1" itself has no SKILL.md.
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
  }
@@ -1589,7 +1589,9 @@ export const TOOLS = [
1589
1589
  },
1590
1590
  {
1591
1591
  name: "list_children",
1592
- description: "List your live sub-agents: id, status, current goal.",
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. Costs zero tokens while parked: prefer this over bash sleep when " +
1634
- "waiting on spawned work. The settling child's report is delivered to you afterwards.",
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: {
@@ -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. Used to gate web-UI edits (PUT /api/config) and
4
- * any programmatic writes, with human-readable error messages.
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
- * NOTE: validation gates, it does not become the storage format — the master
7
- * persists the raw user JSON so unknown fields survive round-trips.
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
- * #110: `process.env.HOME` is a POSIX convention and is normally UNSET on
5
- * Windows. Code that expanded `~` with `process.env.HOME ?? "~"` therefore got
6
- * the LITERAL string "~" back on Windows — and since the result was then passed
7
- * to `path.resolve(base, …)` and `mkdirSync`, teapot created a directory named
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
- * `os.homedir()` is the answer: on Windows Node resolves it from `USERPROFILE`,
11
- * on POSIX from `HOME`, so it is never the literal `~`. The env lookups are kept
12
- * only so a test can drive the Windows shape deterministically — `os.homedir()`
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 also reports a home as HOMEDRIVE + HOMEPATH. Concatenate
24
- // defensively: `drive.replace(...) + path` yields the STRING "undefined"
25
- // rather than undefined when either part is missing, which is exactly the
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
- // #110: on Windows a path can be `~\foo`, hence the backslash in the lookahead.
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
  }