teapot-coding-agent 0.26.12 → 0.26.14

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.
@@ -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)
@@ -1422,14 +1418,9 @@ export const TOOLS = [
1422
1418
  `jobs are per-workspace and forgotten on harness restart`,
1423
1419
  };
1424
1420
  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";
1421
+ // #110: same swallowed-kill bug — this reported "killed" and dropped the
1422
+ // job from the map while the process kept running, now untrackable.
1423
+ const note = sh.exited ? "(already exited)" : describeKill(killTree(sh.child));
1433
1424
  map.delete(sh.id);
1434
1425
  return { ok: true, result: `job ${sh.id} ${note}. Output so far:\n${clip(sh.out, 4000)}` };
1435
1426
  }
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
+ }
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);
@@ -11,6 +11,7 @@ import { promises as fs } from "node:fs";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { parseSchedule } from "../scheduler/cron.js";
13
13
  import { TermSizeTracker } from "./term-size.js";
14
+ import { expandTilde, homeDir } from "../home.js";
14
15
  import { SUB_PERSONAS, resolveWorkspace } from "../master.js";
15
16
  import { ConfigPatchSchema, formatZodError } from "../config-schema.js";
16
17
  import { providerHeaders } from "../agent/llm.js";
@@ -440,8 +441,11 @@ export function buildApp(master) {
440
441
  });
441
442
  // ---- filesystem browsing (read-only, for the workspace picker) ----
442
443
  app.get("/api/fs", async (c) => {
443
- let p = c.req.query("path") || process.env.HOME || "/";
444
- p = path.resolve(p.replace(/^~/, process.env.HOME ?? "~"));
444
+ // #110: read the home directory through the shared helper. `process.env.HOME`
445
+ // is unset on Windows, which made the old fallback yield the literal "~" —
446
+ // and /api/fs would then browse a directory named ~.
447
+ let p = c.req.query("path") || homeDir() || path.parse(process.cwd()).root;
448
+ p = path.resolve(expandTilde(p));
445
449
  try {
446
450
  const entries = await fs.readdir(p, { withFileTypes: true });
447
451
  return c.json({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.26.12",
3
+ "version": "0.26.14",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -39,7 +39,8 @@
39
39
  "start": "node dist/index.js",
40
40
  "dev": "node src/index.ts",
41
41
  "dev-web": "vite",
42
- "test": "node --test test/*.test.ts"
42
+ "test": "node --test test/*.test.ts",
43
+ "test:portable": "node scripts/run-portable-tests.mjs"
43
44
  },
44
45
  "dependencies": {
45
46
  "@hono/node-server": "^2.1.1",