teapot-coding-agent 0.22.10 → 0.22.12

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.
@@ -10,7 +10,7 @@ import { promises as fs, existsSync, readFileSync, readdirSync, statSync } from
10
10
  import path from "node:path";
11
11
  import { EventLog, readEvents } from "../log/events.js";
12
12
  import { chat, chatStream } from "./llm.js";
13
- import { executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow } from "./tools.js";
13
+ import { executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow, hasRunningBgShells } from "./tools.js";
14
14
  import { bus } from "../bus.js";
15
15
  /**
16
16
  * SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
@@ -884,6 +884,13 @@ export class Agent {
884
884
  if (this.bgExits.length > 32)
885
885
  this.bgExits.shift(); // cap runaway spam
886
886
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
887
+ // the round ended BECAUSE this job was running (auto-continue is
888
+ // suppressed while bg jobs are alive) — its exit IS the wake-up call.
889
+ // Without this the report would sit unread until the operator poked
890
+ // the agent. Stopped agents stay stopped (a deliberate human stop).
891
+ if (this.status === "idle" && !this.stopRequested) {
892
+ this.start(`background job ${info.id} finished`);
893
+ }
887
894
  }
888
895
  /** fold queued background-exit reports into the next LLM call (once each) */
889
896
  drainBackgroundExits() {
@@ -994,6 +1001,19 @@ export class Agent {
994
1001
  // fresh user input arrived while we were finishing up — another round now
995
1002
  if (this.pendingPrompts.length)
996
1003
  continue;
1004
+ // A round that parked its work on a background bash (dev server,
1005
+ // watcher, long build) ENDED ON PURPOSE — the job's exit notification
1006
+ // is the wake-up. Nagging with "continue working" here just made the
1007
+ // agent spin up duplicate work while it meant to wait. Checked BEFORE
1008
+ // the finished/goal gates: a finish(goalComplete=false) round that
1009
+ // leaves a bg job running is exactly the "wait for it" pattern.
1010
+ if (hasRunningBgShells(this.toolCtx.cwd)) {
1011
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1012
+ event: "auto-continue-suppressed",
1013
+ reason: "background job still running — the agent resumes when it exits",
1014
+ });
1015
+ break;
1016
+ }
997
1017
  // auto-continue only makes sense with an active goal to continue toward
998
1018
  if (finished ||
999
1019
  !this.opts.autoContinue ||
@@ -109,6 +109,26 @@ export function isContextOverflow(err) {
109
109
  const msg = String(err?.message ?? "").toLowerCase();
110
110
  return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) || /\b(400|413)\b.*token/.test(msg));
111
111
  }
112
+ /** Any background shell still running for this workspace? The auto-continue
113
+ * loop consults this before nudging a finished round: an agent that parked
114
+ * its work on `bash(background=true)` — dev server, watcher, long build —
115
+ * must NOT be nagged with "Continue working toward the current goal". The
116
+ * job's exit notification will wake the agent on its own. */
117
+ export function hasRunningBgShells(cwd) {
118
+ const m = bgShellsByWs.get(cwd);
119
+ if (!m)
120
+ return false;
121
+ for (const sh of m.values()) {
122
+ // opportunistic cleanup mirrors startBackgroundShell's
123
+ if (sh.exited && Date.now() - sh.startedAt > 30 * 60_000) {
124
+ m.delete(sh.id);
125
+ continue;
126
+ }
127
+ if (!sh.exited)
128
+ return true;
129
+ }
130
+ return false;
131
+ }
112
132
  /** Run a command in its own process group; kill the whole group on timeout. */
113
133
  function runShell(cmd, ctx, timeoutMs) {
114
134
  return new Promise((resolve) => {
package/dist/index.js CHANGED
@@ -31,6 +31,33 @@ async function main() {
31
31
  console.log(" env: TEAPOT_PORT, TEAPOT_HOST, TEAPOT_CONFIG_DIR, TEAPOT_DATA_DIR, TEAPOT_API_TOKEN");
32
32
  return;
33
33
  }
34
+ // Handle restart-from-old-process: if we're the new process spawned by a
35
+ // live update, wait for the old process to release the port and exit.
36
+ const restartFromPid = process.env.TEAPOT_RESTART_FROM
37
+ ? parseInt(process.env.TEAPOT_RESTART_FROM, 10)
38
+ : null;
39
+ if (restartFromPid && !Number.isNaN(restartFromPid)) {
40
+ // Give the old process time to gracefully shut down and release the port
41
+ console.log(`[teapot] restart: waiting for old process ${restartFromPid} to exit...`);
42
+ try {
43
+ // Send SIGTERM to old process (it handles graceful shutdown)
44
+ process.kill(restartFromPid, "SIGTERM");
45
+ // Wait for it to exit (max 5 seconds)
46
+ for (let i = 0; i < 50; i++) {
47
+ try {
48
+ process.kill(restartFromPid, 0); // check if alive
49
+ await new Promise((r) => setTimeout(r, 100));
50
+ }
51
+ catch {
52
+ // Process no longer exists
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ catch {
58
+ // Old process might already be gone
59
+ }
60
+ }
34
61
  const configPath = resolveConfigPath(cfgArg);
35
62
  const configExisted = existsSync(configPath);
36
63
  const config = loadConfig(configPath);
@@ -57,12 +84,16 @@ async function main() {
57
84
  process.on("unhandledRejection", (err) => {
58
85
  console.error("[teapot] unhandled rejection (master survived):", err);
59
86
  });
60
- const shutdown = async () => {
61
- console.log("\n[teapot] shutting down...");
87
+ let shuttingDown = false;
88
+ const shutdown = async (signal) => {
89
+ if (shuttingDown)
90
+ return;
91
+ shuttingDown = true;
92
+ console.log(`\n[teapot] shutting down${signal ? ` (${signal})` : ""}...`);
62
93
  await Promise.allSettled([...master.agents.values()].map((a) => a.dispose()));
63
94
  process.exit(0);
64
95
  };
65
- process.on("SIGINT", () => void shutdown());
66
- process.on("SIGTERM", () => void shutdown());
96
+ process.on("SIGINT", () => void shutdown("SIGINT"));
97
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
67
98
  }
68
99
  void main();
package/dist/master.js CHANGED
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { spawn } from "node:child_process";
11
12
  import { Agent } from "./agent/agent.js";
12
13
  import { parseSchedule, matches, nextFireAt } from "./scheduler/cron.js";
13
14
  import { bus } from "./bus.js";
@@ -966,6 +967,11 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
966
967
  if (!llm.baseUrl)
967
968
  throw new Error("no baseUrl resolved");
968
969
  agent.setLlmConfig(llm);
970
+ // keep the snapshot's provider in sync — the UI's model switcher reads
971
+ // sel().provider, and the old code only updated the config entry (ac)
972
+ // so the live snapshot kept the stale provider (the "right panel model
973
+ // switching doesn't work" backend half).
974
+ agent.opts.provider = effectiveProvider;
969
975
  const ac = this.config.agents.find((a) => a.id === id);
970
976
  if (ac && providerName)
971
977
  ac.provider = providerName;
@@ -1069,4 +1075,53 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1069
1075
  })),
1070
1076
  };
1071
1077
  }
1078
+ /** Current application version (injected at build time via package.json). */
1079
+ static VERSION = "__APP_VERSION__";
1080
+ /** Return the current server version. */
1081
+ getVersion() {
1082
+ return Master.VERSION;
1083
+ }
1084
+ /** Gracefully stop all agents with a timeout. Returns when all are stopped or timeout. */
1085
+ async stopAllAgents(timeoutMs = 30_000) {
1086
+ console.log(`[teapot] stopping all ${this.agents.size} agents...`);
1087
+ const ids = [...this.agents.keys()];
1088
+ const stopPromises = ids.map(async (id) => {
1089
+ const a = this.agents.get(id);
1090
+ if (!a)
1091
+ return;
1092
+ try {
1093
+ await a.dispose();
1094
+ }
1095
+ catch (err) {
1096
+ console.error("[teapot] dispose error:", err);
1097
+ }
1098
+ // remove from map so a re-spawn after restart doesn't carry state
1099
+ this.agents.delete(id);
1100
+ });
1101
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs));
1102
+ await Promise.race([Promise.all(stopPromises), timeout]);
1103
+ console.log("[teapot] all agents stopped");
1104
+ }
1105
+ /** Restart the server process (spawns a new master, keeps this one alive until ready). */
1106
+ async restartServer() {
1107
+ console.log("[teapot] restarting server...");
1108
+ // Stop all agents first
1109
+ await this.stopAllAgents(30_000);
1110
+ // Spawn a new process that will become the new master
1111
+ // We keep THIS process alive until the new one is ready to accept connections
1112
+ const execPath = process.execPath;
1113
+ const args = process.argv.slice(1);
1114
+ const env = { ...process.env };
1115
+ // Tell the new process it's a restart (it will read the version from the old one)
1116
+ env.TEAPOT_RESTART_FROM = String(process.pid);
1117
+ const child = spawn(execPath, args, {
1118
+ detached: true,
1119
+ stdio: "ignore",
1120
+ env,
1121
+ });
1122
+ child.unref(); // Let the parent exit when ready
1123
+ // Give the new process time to start and bind the port
1124
+ // This process will exit when the new one takes over (SIGTERM handled in index.ts)
1125
+ await new Promise((r) => setTimeout(r, 1_000));
1126
+ }
1072
1127
  }
@@ -556,6 +556,7 @@ export function buildApp(master) {
556
556
  maxSpawnDepth: master.config.maxSpawnDepth,
557
557
  tasks: master.config.tasks,
558
558
  agents: master.config.agents.map((a) => ({ id: a.id, workspace: a.workspace, provider: a.provider, model: a.model })),
559
+ version: master.getVersion(),
559
560
  });
560
561
  });
561
562
  app.put("/api/config", async (c) => {
@@ -871,6 +872,23 @@ export function buildApp(master) {
871
872
  return c.json({ ok: true });
872
873
  });
873
874
  // edit a previously-sent prompt: forks there, optionally summarizes the tail
875
+ app.post("/api/agents/:id/edit-prompt", async (c) => {
876
+ const a = master.agents.get(c.req.param("id"));
877
+ if (!a)
878
+ return c.json({ error: "not found" }, 404);
879
+ const body = await c.req
880
+ .json()
881
+ .catch(() => null);
882
+ if (!body?.eventId || !body.text?.trim())
883
+ return c.json({ error: "eventId and text required" }, 400);
884
+ try {
885
+ const r = await a.editPromptAt(body.eventId, body.text, body.tail === "summarize" ? "summarize" : "discard");
886
+ return c.json({ ok: true, ...r });
887
+ }
888
+ catch (err) {
889
+ return c.json({ error: err.message }, 409);
890
+ }
891
+ });
874
892
  app.post("/api/agents/:id/fork", async (c) => {
875
893
  const a = master.agents.get(c.req.param("id"));
876
894
  if (!a)
@@ -964,6 +982,18 @@ export function buildApp(master) {
964
982
  });
965
983
  // ---- metrics / SSE ----
966
984
  app.get("/api/metrics", (c) => c.json(master.metrics()));
985
+ // current server version (for live-update polling)
986
+ app.get("/api/version", (c) => c.json({ version: master.getVersion() }));
987
+ // trigger a full server restart (agents stopped gracefully, new process spawned)
988
+ app.post("/api/update/restart", async (c) => {
989
+ try {
990
+ await master.restartServer();
991
+ return c.json({ ok: true });
992
+ }
993
+ catch (err) {
994
+ return c.json({ error: err.message }, 500);
995
+ }
996
+ });
967
997
  // scheduled tasks with computed next-fire times (cron visibility for the UI)
968
998
  app.get("/api/tasks", (c) => c.json({ tasks: master.tasksView() }));
969
999
  app.get("/api/events", (c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.22.10",
3
+ "version": "0.22.12",
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",