teapot-coding-agent 0.26.13 → 0.27.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/README.md CHANGED
@@ -6,8 +6,6 @@
6
6
 
7
7
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/akku1139/teapot)
8
8
 
9
- [日本語版はこちら](README.ja.md)
10
-
11
9
  ---
12
10
 
13
11
  ## 🚀 Quick start
@@ -167,7 +165,7 @@ Agents can run `bash` and rewrite files — including `rm` and `npm install`.
167
165
 
168
166
  - Designed to run under a **dedicated Linux user**
169
167
  - `git commit` before handing over a repo you care about
170
- - File operations outside the workspace are rejected (`safeJoin`)
168
+ - File operations outside the workspace are rejected (`safeJoin`), but agents can use `bash` outside of the worktree
171
169
  - teapot listens on localhost only by default. If you expose it, set **both** `--host 0.0.0.0` and an API token (`TEAPOT_API_TOKEN` env or config `password`):
172
170
 
173
171
  ```sh
@@ -1227,23 +1227,48 @@ export class Agent {
1227
1227
  }
1228
1228
  if (p.source === "user") {
1229
1229
  const id = p.id ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
1230
- this.deliveredPrompts.push(id);
1231
- // cap: UI only needs recent confirmations
1232
- if (this.deliveredPrompts.length > 64)
1233
- this.deliveredPrompts.shift();
1234
- void this.log
1235
- .append("system_note", this.currentSession, this.currentBranch, {
1236
- event: "prompt-delivered",
1237
- promptId: id,
1238
- preview: p.text.slice(0, 80),
1239
- })
1240
- .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
1241
- .catch(() => { });
1230
+ // #37: queued here, MARKED at the request boundary instead — see
1231
+ // markDeliveredPrompts(). Text is kept so the marker can carry the same
1232
+ // preview the UI shows.
1233
+ this.undeliveredPrompts.push({ id, preview: p.text.slice(0, 80) });
1242
1234
  }
1243
1235
  }
1244
1236
  }
1245
1237
  /** ids of user prompts that have entered an LLM call payload (recent first-capped) */
1246
1238
  deliveredPrompts = [];
1239
+ /**
1240
+ * User prompts consumed from the queue but not yet MARKED as delivered (#37).
1241
+ *
1242
+ * They are drained at a turn boundary, but the `prompt-delivered` marker must
1243
+ * wait until the request that carries them actually starts — which is later,
1244
+ * and after `maybeCompact()`, which can itself call the LLM.
1245
+ */
1246
+ undeliveredPrompts = [];
1247
+ /**
1248
+ * Log `prompt-delivered` for every prompt whose outbound request is starting.
1249
+ *
1250
+ * #37: the marker's meaning is "the request carrying this prompt has begun".
1251
+ * It used to fire in drainPendingPrompts, i.e. before the request, so the
1252
+ * timeline reordered by a moment the API never saw.
1253
+ */
1254
+ async markDeliveredPrompts() {
1255
+ if (this.undeliveredPrompts.length === 0)
1256
+ return;
1257
+ const pending = this.undeliveredPrompts;
1258
+ this.undeliveredPrompts = [];
1259
+ for (const { id, preview } of pending) {
1260
+ this.deliveredPrompts.push(id);
1261
+ // cap: the UI only needs recent confirmations
1262
+ if (this.deliveredPrompts.length > 64)
1263
+ this.deliveredPrompts.shift();
1264
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1265
+ event: "prompt-delivered",
1266
+ promptId: id,
1267
+ preview,
1268
+ });
1269
+ }
1270
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
1271
+ }
1247
1272
  /**
1248
1273
  * Background jobs that exited since the last LLM call. Their outcomes are
1249
1274
  * folded into ONE harness message at the turn boundary — the agent learns
@@ -1366,9 +1391,25 @@ export class Agent {
1366
1391
  stopSeq = 0;
1367
1392
  /** #95: set when this round already injected a harness progress request */
1368
1393
  progressRequestedThisRound = false;
1394
+ /**
1395
+ * CONTROL PLANE: fired on every status change, immediately, independent of the
1396
+ * log.
1397
+ *
1398
+ * #126: `wait_children` wakeups used to ride `EventLog.onEvent`, so a status
1399
+ * change reached the parent only AFTER the write completed. After the
1400
+ * write-before-notify fix that meant a FAILED or slow write left a parked parent
1401
+ * waiting forever — persistence latency became control flow. Measured with every
1402
+ * write failing: `onEvent fired: 0`, so no wakeup at all.
1403
+ *
1404
+ * Control flow must not depend on durability, so this is separate from the
1405
+ * event pipeline. The log's `onEvent` now means "persisted, tell the world";
1406
+ * this means "my state changed, react now".
1407
+ */
1408
+ onControlStatusChange = null;
1369
1409
  setStatus(s, reason = "") {
1370
- // Update the field FIRST. log.append fires onEvent synchronously, and the
1371
- // master's child-event hook wakes parked wait_children callers — those
1410
+ // Update the field FIRST. The control-plane hook fires synchronously below
1411
+ // (see onControlStatusChange), and it is what wakes parked wait_children
1412
+ // callers — those
1372
1413
  // re-check agent.status at that instant. Updating after the append made
1373
1414
  // every waiter observe the OLD status ("running"), conclude "someone is
1374
1415
  // still working", and stay parked until timeout even though the child had
@@ -1377,6 +1418,15 @@ export class Agent {
1377
1418
  this.status = s;
1378
1419
  this.statusReason = reason;
1379
1420
  if (prev !== s) {
1421
+ // #126: control plane FIRST, and not via the log. A parent's wait_children
1422
+ // must wake on the status change itself, not on the durability of the write
1423
+ // that records it.
1424
+ try {
1425
+ this.onControlStatusChange?.(this.opts.id, s);
1426
+ }
1427
+ catch {
1428
+ /* a control-plane listener must never break a status change */
1429
+ }
1380
1430
  void this.log.append("state", this.currentSession, this.currentBranch, {
1381
1431
  from: prev,
1382
1432
  to: s,
@@ -1591,6 +1641,27 @@ export class Agent {
1591
1641
  text: "",
1592
1642
  reasoning: "",
1593
1643
  });
1644
+ // #37: `prompt-delivered` must mean "the outbound request CARRYING this
1645
+ // prompt has started", not "the prompt moved from the queue into
1646
+ // `this.messages`".
1647
+ //
1648
+ // It used to be logged in drainPendingPrompts, which runs BEFORE
1649
+ // refreshSkills, maybeRequestProgress and — critically — maybeCompact. So
1650
+ // the marker preceded the request, and `maybeCompact()` can itself make an
1651
+ // LLM call, which meant the timeline claimed a delivery order the API never
1652
+ // saw. Measured before the fix:
1653
+ //
1654
+ // LLM CALL #1
1655
+ // seq=5 prompt-delivered("first prompt")
1656
+ // seq=6 llm turn start
1657
+ //
1658
+ // The UI reorders by this marker (resequenceToDelivery), so the semantics
1659
+ // were the bug — not timeline-order.ts.
1660
+ //
1661
+ // Emitted ONCE per request, not per attempt: a retry sends the same payload,
1662
+ // so re-emitting would move the marker after a later event on each try.
1663
+ if (attempt === 1)
1664
+ await this.markDeliveredPrompts();
1594
1665
  try {
1595
1666
  return await this.callLlm(messages, tools, onDelta);
1596
1667
  }
@@ -0,0 +1,70 @@
1
+ /** can this platform kill a whole process group? */
2
+ export function canKillProcessGroup(platform = process.platform) {
3
+ return platform !== "win32";
4
+ }
5
+ /**
6
+ * Kill a spawned child, and its whole group where the platform allows it.
7
+ *
8
+ * Deliberately does NOT throw: callers are timeouts and abort handlers, where a
9
+ * throw would replace the real message with a secondary failure.
10
+ */
11
+ export function killTree(child, platform = process.platform) {
12
+ if (!child?.pid)
13
+ return { killed: false, wholeTree: false, reason: "no-pid" };
14
+ if (!canKillProcessGroup(platform)) {
15
+ // Windows: no group kill. Try the direct child so at least THAT stops, and
16
+ // report that the tree is not covered rather than implying it is.
17
+ try {
18
+ process.kill(child.pid, "SIGKILL");
19
+ return { killed: true, wholeTree: false };
20
+ }
21
+ catch (err) {
22
+ const code = err.code;
23
+ return {
24
+ killed: false,
25
+ wholeTree: false,
26
+ reason: code === "ESRCH" ? "already-gone" : "error",
27
+ };
28
+ }
29
+ }
30
+ try {
31
+ process.kill(-child.pid, "SIGKILL"); // whole group
32
+ return { killed: true, wholeTree: true };
33
+ }
34
+ catch (err) {
35
+ const code = err.code;
36
+ // EPERM on the group can still mean the child is killable directly, and
37
+ // EPERM is what a partially-privileged tree produces.
38
+ if (code === "EPERM") {
39
+ try {
40
+ process.kill(child.pid, "SIGKILL");
41
+ return { killed: true, wholeTree: false };
42
+ }
43
+ catch {
44
+ return { killed: false, wholeTree: false, reason: "error" };
45
+ }
46
+ }
47
+ if (code === "ESRCH")
48
+ return { killed: false, wholeTree: false, reason: "already-gone" };
49
+ return { killed: false, wholeTree: false, reason: "error" };
50
+ }
51
+ }
52
+ /** a sentence for the tool result, so a partial kill is never silent */
53
+ export function describeKill(outcome) {
54
+ if (outcome.killed && outcome.wholeTree)
55
+ return "killed (whole process group)";
56
+ if (outcome.killed) {
57
+ return ("killed the process, but this platform has no process groups — any " +
58
+ "background children it started may still be running");
59
+ }
60
+ switch (outcome.reason) {
61
+ case "no-pid":
62
+ return "no process handle to kill";
63
+ case "already-gone":
64
+ return "already exited";
65
+ case "group-unsupported":
66
+ return "process groups are not supported on this platform";
67
+ default:
68
+ return "could not be killed";
69
+ }
70
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { promises as fs } from "node:fs";
20
20
  import path from "node:path";
21
+ import { homeDir } from "../home.js";
21
22
  export const SKILL_FILE = "SKILL.md";
22
23
  /**
23
24
  * Cheap change-detector for a set of skill roots. Returns a stable string
@@ -148,7 +149,9 @@ function unquote(v) {
148
149
  * directories are skipped silently, and nothing is read unless it exists.
149
150
  */
150
151
  export function foreignSkillRoots(workspace) {
151
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
152
+ // #110: the shared helper also covers HOMEDRIVE/HOMEPATH, which is how
153
+ // Windows reports a home when USERPROFILE is absent.
154
+ const home = homeDir();
152
155
  const out = [];
153
156
  const add = (dir) => {
154
157
  if (dir && !out.some((r) => r.dir === dir))
@@ -7,6 +7,7 @@ import { existsSync, promises as fs, readlinkSync, realpathSync } from "node:fs"
7
7
  import path from "node:path";
8
8
  import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
9
9
  import { isReasoningEffort } from "../model-meta.js";
10
+ import { killTree, describeKill } from "./kill.js";
10
11
  const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
11
12
  const num = (v, fallback) => (typeof v === "number" ? v : fallback);
12
13
  function clip(s, max) {
@@ -322,23 +323,18 @@ function runShell(cmd, ctx, timeoutMs) {
322
323
  };
323
324
  child.stdout.on("data", collect);
324
325
  child.stderr.on("data", collect);
325
- const killGroup = () => {
326
- try {
327
- if (child.pid)
328
- process.kill(-child.pid, "SIGKILL"); // whole group
329
- }
330
- catch {
331
- /* already gone */
332
- }
333
- };
326
+ // #110: the kill REPORTS whether it worked. It used to swallow every failure
327
+ // as "already gone", and on Windows a group kill throws ALWAYS — so a timeout
328
+ // would report `TIMEOUT after …ms` for a process that was still running, and
329
+ // shutdown would leave it alive. A timeout that claims success is worse than
330
+ // a crash, because nothing afterwards looks wrong.
331
+ const killGroup = () => killTree(child);
334
332
  const timer = setTimeout(() => {
335
- killReason = `TIMEOUT after ${timeoutMs}ms`;
336
- killGroup();
333
+ killReason = `TIMEOUT after ${timeoutMs}ms — ${describeKill(killGroup())}`;
337
334
  }, timeoutMs);
338
335
  // harness shutdown must not wait out a long-running command
339
336
  const onAbort = () => {
340
- killReason = "ABORTED (harness shutdown)";
341
- killGroup();
337
+ killReason = `ABORTED (harness shutdown) — ${describeKill(killGroup())}`;
342
338
  };
343
339
  if (ctx.signal) {
344
340
  if (ctx.signal.aborted)
@@ -1196,8 +1192,18 @@ export const TOOLS = [
1196
1192
  return stale;
1197
1193
  let oldText = str(args.old_text);
1198
1194
  const newText = str(args.new_text);
1195
+ // #126: the single most common edit_file failure in the real logs (22
1196
+ // occurrences) said only "old_text is required", while the tool's own
1197
+ // schema already documents the better form. Name it and say what to do —
1198
+ // the pattern master.ts uses for "not your sub-agent" (master.ts:979),
1199
+ // which turned a confusing refusal into a re-addressable one.
1199
1200
  if (!oldText)
1200
- return { ok: false, result: "old_text is required" };
1201
+ return {
1202
+ ok: false,
1203
+ result: "old_text is required — pass the exact text to replace, or read with " +
1204
+ '`read_file({line_ids:"hash"})` and pass that block VERBATIM as old_text. ' +
1205
+ "Do NOT copy read_file's `N| ` line-number prefixes.",
1206
+ };
1201
1207
  const replaceAll = args.replace_all === true;
1202
1208
  // #7/#4: accept a block copied straight out of read_file's hashline
1203
1209
  // output. When the anchors do not verify we FAIL with the specific reason
@@ -1422,14 +1428,9 @@ export const TOOLS = [
1422
1428
  `jobs are per-workspace and forgotten on harness restart`,
1423
1429
  };
1424
1430
  if (str(args.action) === "kill") {
1425
- if (!sh.exited) {
1426
- try {
1427
- if (sh.child.pid)
1428
- process.kill(-sh.child.pid, "SIGKILL");
1429
- }
1430
- catch { /* already gone */ }
1431
- }
1432
- const note = sh.exited ? "(already exited)" : "killed";
1431
+ // #110: same swallowed-kill bug — this reported "killed" and dropped the
1432
+ // job from the map while the process kept running, now untrackable.
1433
+ const note = sh.exited ? "(already exited)" : describeKill(killTree(sh.child));
1433
1434
  map.delete(sh.id);
1434
1435
  return { ok: true, result: `job ${sh.id} ${note}. Output so far:\n${clip(sh.out, 4000)}` };
1435
1436
  }
package/dist/home.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The user's home directory, on every platform.
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.
9
+ *
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.
17
+ */
18
+ export function homeDir(env = process.env) {
19
+ if (env.HOME)
20
+ return env.HOME;
21
+ if (env.USERPROFILE)
22
+ 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.
27
+ const drive = env.HOMEDRIVE;
28
+ const rest = env.HOMEPATH;
29
+ if (drive && rest)
30
+ return drive.replace(/\\+$/, "") + rest;
31
+ if (drive)
32
+ return drive;
33
+ return "";
34
+ }
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
+ */
41
+ export function expandTilde(input, env = process.env) {
42
+ // #110: on Windows a path can be `~\foo`, hence the backslash in the lookahead.
43
+ if (!/^~(?=$|[/\\])/.test(input))
44
+ return input;
45
+ 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
+ if (!home)
49
+ return input;
50
+ return input.replace(/^~(?=$|[/\\])/, home);
51
+ }
@@ -89,12 +89,25 @@ export class EventLog {
89
89
  data,
90
90
  };
91
91
  this.lastByBranch.set(branch, evt.id);
92
- try {
93
- this.onEvent?.(evt);
94
- }
95
- catch {
96
- /* observer must never break the log */
97
- }
92
+ // #54: WRITE FIRST, then notify.
93
+ //
94
+ // The observer used to fire BEFORE the write. The frontend receives a
95
+ // `kind:"event"` frame and — by design — treats it as "something changed"
96
+ // and re-reads `/events`. So the reload could read the log BEFORE this
97
+ // event reached disk, and the reload would simply not contain it.
98
+ //
99
+ // Measured against a real EventLog, reloading on every notification:
100
+ //
101
+ // appends: 30 WS-triggered reloads that MISSED the event: 30
102
+ //
103
+ // The 120ms debounce usually hides this, which is exactly why #54 felt
104
+ // random: a slow write misses, a fast one lands. Notifying after the write
105
+ // makes the event visible to any reader the observer triggers.
106
+ // `written` distinguishes a REAL write from the post-close drop path, which
107
+ // also resolves. #54: announcing a dropped event would tell the frontend to
108
+ // reload for something that will never appear — the row would flicker in and
109
+ // straight back out.
110
+ let written = false;
98
111
  const p = new Promise((resolve, reject) => {
99
112
  this.chain = this.chain.then(() => {
100
113
  if (!this.stream) {
@@ -103,10 +116,31 @@ export class EventLog {
103
116
  console.error(`[teapot] event dropped after log close (${this.agentId}): ${type}`);
104
117
  return resolve(evt);
105
118
  }
106
- this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => err ? reject(err) : resolve(evt));
119
+ this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => {
120
+ if (err)
121
+ return reject(err);
122
+ written = true;
123
+ resolve(evt);
124
+ });
107
125
  }, () => resolve(evt));
108
126
  });
109
127
  this.chain = this.chain.then(() => { }, () => { });
128
+ // notify once the write is handed to the OS, so a reload the observer
129
+ // triggers can actually see this event
130
+ void p
131
+ .then(() => {
132
+ if (!written)
133
+ return; // dropped after close: nothing on disk to announce
134
+ try {
135
+ this.onEvent?.(evt);
136
+ }
137
+ catch {
138
+ /* observer must never break the log */
139
+ }
140
+ })
141
+ .catch(() => {
142
+ /* a failed write must not notify — the event is not on disk */
143
+ });
110
144
  return p;
111
145
  }
112
146
  lastEventId(branch) {
package/dist/master.js CHANGED
@@ -7,6 +7,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSy
7
7
  import { randomUUID } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
+ import { expandTilde } from "./home.js";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { spawn } from "node:child_process";
12
13
  import { Agent } from "./agent/agent.js";
@@ -64,7 +65,9 @@ function readAppVersion() {
64
65
  * `~` is expanded for both forms; absolute paths pass through untouched.
65
66
  */
66
67
  export function resolveWorkspace(input, base) {
67
- const expanded = input.trim().replace(/^~(?=$|\/|\\)/, process.env.HOME ?? "~");
68
+ // #110: `~` must not expand to the LITERAL "~" — that is how a directory named
69
+ // `~` got created on Windows, where process.env.HOME is unset. See src/home.ts.
70
+ const expanded = expandTilde(input.trim());
68
71
  if (path.isAbsolute(expanded))
69
72
  return path.normalize(expanded);
70
73
  return path.resolve(base, expanded);
@@ -521,6 +524,28 @@ export class Master {
521
524
  bus.emit("update", { kind: "event", agentId: e.agent, event: e });
522
525
  this.onChildEvent(ac, e);
523
526
  };
527
+ // #126: the CONTROL PLANE, separate from the event pipeline above.
528
+ //
529
+ // `onChildEvent` used to be what woke a parent's `wait_children`, and it runs
530
+ // after the event is persisted. So a failed or slow write left the parent
531
+ // parked forever — measured with every write failing, the wake never fired.
532
+ //
533
+ // A status change is CONTROL FLOW, not an observation, so it gets its own
534
+ // immediate path. `onChildEvent` keeps its other job (mirroring the child's
535
+ // activity into the parent's timeline, forwarding its final report), which
536
+ // legitimately belongs to the event stream.
537
+ agent.onControlStatusChange = (agentId, status) => {
538
+ if (!ac.parent)
539
+ return;
540
+ const parent = this.agents.get(ac.parent);
541
+ if (!parent)
542
+ return;
543
+ // the waiter re-checks whether ITS targets are all settled, so waking on
544
+ // every movement is correct and cheap
545
+ this.wakeParentWaiters(ac.parent);
546
+ if (status === "idle" || status === "stopped" || status === "waiting" || status === "error")
547
+ parent.enqueuePrompt(`[harness] @${agentId} is now ${status}. Continue your task, or finish() if the work is done.`, "harness");
548
+ };
524
549
  // sub-agent management hooks — only when this agent can legally spawn
525
550
  if (myDepth < this.maxSpawnDepth()) {
526
551
  const self = agent;
@@ -839,9 +864,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
839
864
  // silently dropped exactly the details the operator spawned the sub
840
865
  // to gather. Context management is compaction's job, not the pipe's.
841
866
  `[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
867
+ // #123: a parent's report is delivered INTO a live agent — queueing it is
868
+ // not enough. If the parent is idle (finished, or parked) the report sat in
869
+ // the queue until something else started the loop, so the operator saw a
870
+ // finished sub-agent and a parent that never reacted.
871
+ if (parent.status !== "running")
872
+ parent.start(`sub-agent ${ac.id} finished`);
842
873
  }
843
874
  else if (e.type === "error") {
844
875
  parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
876
+ // #123: same for an error report
877
+ if (parent.status !== "running")
878
+ parent.start(`sub-agent ${ac.id} error`);
845
879
  }
846
880
  // #63: a child's activity is NOT mirrored into the parent's log.
847
881
  //