teapot-coding-agent 0.22.13 → 0.24.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 +29 -0
- package/dist/acp/adapter.js +198 -0
- package/dist/agent/agent.js +282 -15
- package/dist/agent/llm.js +33 -0
- package/dist/agent/skills.js +212 -32
- package/dist/agent/tools.js +776 -39
- package/dist/index.js +74 -27
- package/dist/log/events.js +14 -0
- package/dist/master.js +146 -14
- package/dist/model-meta.js +96 -36
- package/dist/server/api.js +44 -8
- package/dist/server/term-size.js +62 -0
- package/package.json +1 -1
- package/public/assets/index-B_XaPTQR.css +1 -0
- package/public/assets/index-Y_KYu5nW.js +30 -0
- package/public/assets/index-Y_KYu5nW.js.map +1 -0
- package/public/assets/{shiki-B-DhHuic.js → shiki-CG8vVdKn.js} +2 -2
- package/public/assets/{shiki-B-DhHuic.js.map → shiki-CG8vVdKn.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-B-RJz1_I.css +0 -1
- package/public/assets/index-Bg8Y4MGZ.js +0 -30
- package/public/assets/index-Bg8Y4MGZ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -6,8 +6,35 @@
|
|
|
6
6
|
* First run (no config file): boot anyway and finish setup in the web UI.
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
+
import { createServer } from "node:net";
|
|
9
10
|
import { loadConfig, resolveConfigPath, Master } from "./master.js";
|
|
10
11
|
import { buildApp, serveApp } from "./server/api.js";
|
|
12
|
+
/**
|
|
13
|
+
* Resolve once nothing is listening on port/host, i.e. the previous server has
|
|
14
|
+
* released it. Used only by the live-update handover: binding while the old
|
|
15
|
+
* process still holds the socket fails with EADDRINUSE (#35).
|
|
16
|
+
*
|
|
17
|
+
* Returns false on timeout so the caller can warn and try anyway, rather than
|
|
18
|
+
* hanging a boot forever because of a wedged predecessor.
|
|
19
|
+
*/
|
|
20
|
+
async function waitForPortFree(port, host, timeoutMs) {
|
|
21
|
+
const deadline = Date.now() + timeoutMs;
|
|
22
|
+
// 0.0.0.0 / :: means "all interfaces" — probe the loopback the old process
|
|
23
|
+
// was actually reachable on.
|
|
24
|
+
const probeHost = !host || host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
25
|
+
while (Date.now() < deadline) {
|
|
26
|
+
const free = await new Promise((resolve) => {
|
|
27
|
+
const s = createServer();
|
|
28
|
+
s.once("error", () => resolve(false)); // still in use
|
|
29
|
+
s.once("listening", () => s.close(() => resolve(true)));
|
|
30
|
+
s.listen(port, probeHost);
|
|
31
|
+
});
|
|
32
|
+
if (free)
|
|
33
|
+
return true;
|
|
34
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
11
38
|
async function main() {
|
|
12
39
|
// CLI: teapot [--port N] [-p N] [--config file] [-c file] [config.json]
|
|
13
40
|
let cfgArg;
|
|
@@ -28,36 +55,10 @@ async function main() {
|
|
|
28
55
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
29
56
|
console.log("teapot [--port N] [--host addr] [--config file.json] [config.json]");
|
|
30
57
|
console.log(" --host 127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network");
|
|
58
|
+
console.log(" --acp speak the Agent Client Protocol on stdio instead of serving HTTP (#15)");
|
|
31
59
|
console.log(" env: TEAPOT_PORT, TEAPOT_HOST, TEAPOT_CONFIG_DIR, TEAPOT_DATA_DIR, TEAPOT_API_TOKEN");
|
|
32
60
|
return;
|
|
33
61
|
}
|
|
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
|
-
}
|
|
61
62
|
const configPath = resolveConfigPath(cfgArg);
|
|
62
63
|
const configExisted = existsSync(configPath);
|
|
63
64
|
const config = loadConfig(configPath);
|
|
@@ -66,6 +67,33 @@ async function main() {
|
|
|
66
67
|
if (hostOverride)
|
|
67
68
|
config.host = hostOverride;
|
|
68
69
|
console.log(`[teapot] config: ${configPath}${configExisted ? "" : " (not found — first run)"}`);
|
|
70
|
+
// Live update: the process we replace still holds the port. Ask it to release
|
|
71
|
+
// it, then WAIT until the port is genuinely free before binding. Binding
|
|
72
|
+
// into a live socket died with EADDRINUSE, and because index.ts swallows
|
|
73
|
+
// uncaught exceptions ("the master survived") that left two processes running
|
|
74
|
+
// while neither served anything — the port then answered nothing at all
|
|
75
|
+
// (measured: 1150/1237 probes refused during handover) (#35).
|
|
76
|
+
const fromPid = process.env.TEAPOT_RESTART_FROM
|
|
77
|
+
? parseInt(process.env.TEAPOT_RESTART_FROM, 10)
|
|
78
|
+
: NaN;
|
|
79
|
+
if (!Number.isNaN(fromPid) && fromPid !== process.pid) {
|
|
80
|
+
console.log(`[teapot] restart: releasing port from old process ${fromPid}...`);
|
|
81
|
+
try {
|
|
82
|
+
// Flush the old process's agents and let it release the socket. It does
|
|
83
|
+
// NOT wait for us (that would deadlock against our bind below) — it
|
|
84
|
+
// lingers only long enough for its own HTTP response to flush, then exits.
|
|
85
|
+
process.kill(fromPid, "SIGTERM");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* already gone */
|
|
89
|
+
}
|
|
90
|
+
// Wait until the socket is genuinely free. Binding while the old process
|
|
91
|
+
// still holds it fails with EADDRINUSE, and because index.ts swallows
|
|
92
|
+
// uncaught exceptions that left two processes running while neither served
|
|
93
|
+
// anything (#35).
|
|
94
|
+
if (!(await waitForPortFree(config.port, config.host, 30_000)))
|
|
95
|
+
console.warn(`[teapot] warning: could not confirm ${config.host}:${config.port} is free — binding anyway`);
|
|
96
|
+
}
|
|
69
97
|
const hasProviders = Object.keys(config.providers ?? {}).length > 0;
|
|
70
98
|
if (!config.llm.apiKey && !hasProviders)
|
|
71
99
|
console.warn("[teapot] warning: no API key configured — finish setup in the web UI");
|
|
@@ -74,6 +102,17 @@ async function main() {
|
|
|
74
102
|
const master = new Master(config, configPath);
|
|
75
103
|
master.configFileExists = configExisted;
|
|
76
104
|
await master.start();
|
|
105
|
+
// ACP mode (#15): JSON-RPC over stdio, no HTTP server. Deliberately placed
|
|
106
|
+
// AFTER the master is up (so config is resolved and agents work) but BEFORE
|
|
107
|
+
// serveApp, so an editor launching `teapot --acp` never binds a port.
|
|
108
|
+
if (process.argv.includes("--acp")) {
|
|
109
|
+
const { AcpAdapter } = await import("./acp/adapter.js");
|
|
110
|
+
const acp = new AcpAdapter({ master });
|
|
111
|
+
// nothing else may write to stdout — it carries the protocol
|
|
112
|
+
console.error("[teapot] ACP mode: JSON-RPC over stdio");
|
|
113
|
+
await acp.listen();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
77
116
|
const app = buildApp(master);
|
|
78
117
|
serveApp(app, config.port, config.host);
|
|
79
118
|
// agent crash isolation: an agent error never escapes its own loop; here we
|
|
@@ -91,6 +130,14 @@ async function main() {
|
|
|
91
130
|
shuttingDown = true;
|
|
92
131
|
console.log(`\n[teapot] shutting down${signal ? ` (${signal})` : ""}...`);
|
|
93
132
|
await Promise.allSettled([...master.agents.values()].map((a) => a.dispose()));
|
|
133
|
+
// During a live update the replacement SIGTERMs us to take the port. Linger
|
|
134
|
+
// so our own in-flight HTTP responses flush first — notably
|
|
135
|
+
// /api/update/restart, whose answer the browser is still waiting on; the
|
|
136
|
+
// socket is released the moment we exit, which is what lets the replacement
|
|
137
|
+
// bind. We must NOT wait for the replacement to come up: it is itself
|
|
138
|
+
// waiting for this port to free, so that would deadlock the handover (#35).
|
|
139
|
+
if (process.env.TEAPOT_RESTART_FROM)
|
|
140
|
+
await new Promise((r) => setTimeout(r, 1_500));
|
|
94
141
|
process.exit(0);
|
|
95
142
|
};
|
|
96
143
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
package/dist/log/events.js
CHANGED
|
@@ -112,6 +112,20 @@ export class EventLog {
|
|
|
112
112
|
lastEventId(branch) {
|
|
113
113
|
return this.lastByBranch.get(branch) ?? null;
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Seed the parent link for a brand-new branch so its first event chains to
|
|
117
|
+
* the event that created it.
|
|
118
|
+
*
|
|
119
|
+
* `lastByBranch` is per branch, so without this a new branch's first event
|
|
120
|
+
* got `parent: null` — severing the chain. lineageOf() walks parents from the
|
|
121
|
+
* newest event, so the entire pre-fork history became unreachable and a
|
|
122
|
+
* restart rebuilt the conversation with none of it (#38).
|
|
123
|
+
*/
|
|
124
|
+
seedBranch(branch, parentEventId) {
|
|
125
|
+
if (!branch)
|
|
126
|
+
return;
|
|
127
|
+
this.lastByBranch.set(branch, parentEventId ?? "");
|
|
128
|
+
}
|
|
115
129
|
async close() {
|
|
116
130
|
await this.chain.catch(() => { });
|
|
117
131
|
if (!this.stream)
|
package/dist/master.js
CHANGED
|
@@ -12,7 +12,7 @@ import { spawn } from "node:child_process";
|
|
|
12
12
|
import { Agent } from "./agent/agent.js";
|
|
13
13
|
import { parseSchedule, matches, nextFireAt } from "./scheduler/cron.js";
|
|
14
14
|
import { bus } from "./bus.js";
|
|
15
|
-
import { fetchModelList, contextLengthFor } from "./model-meta.js";
|
|
15
|
+
import { fetchModelList, contextLengthFor, isReasoningEffort } from "./model-meta.js";
|
|
16
16
|
/** how deep sub-agent spawning may nest (parent=0, its subs=1, …) */
|
|
17
17
|
const MAX_SPAWN_DEPTH = 3;
|
|
18
18
|
/**
|
|
@@ -21,6 +21,54 @@ const MAX_SPAWN_DEPTH = 3;
|
|
|
21
21
|
* checkout alike. Lowest-priority skill root (workspace > global > bundled).
|
|
22
22
|
*/
|
|
23
23
|
const BUNDLED_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../skills");
|
|
24
|
+
/**
|
|
25
|
+
* Application version, read from package.json next to this module.
|
|
26
|
+
*
|
|
27
|
+
* Walks up from `dist/master.js` / `src/master.ts` to the package root so it
|
|
28
|
+
* resolves in every layout (repo checkout, global install, npx cache). Falls
|
|
29
|
+
* back to `"0.0.0"` rather than throwing: a missing/unreadable package.json
|
|
30
|
+
* must never stop the server from booting.
|
|
31
|
+
*/
|
|
32
|
+
function readAppVersion() {
|
|
33
|
+
try {
|
|
34
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
for (let i = 0; i < 6; i++) {
|
|
36
|
+
const pkg = path.join(dir, "package.json");
|
|
37
|
+
if (existsSync(pkg)) {
|
|
38
|
+
const v = JSON.parse(readFileSync(pkg, "utf8")).version;
|
|
39
|
+
if (typeof v === "string" && v)
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir)
|
|
44
|
+
break;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* fall through to the sentinel below */
|
|
50
|
+
}
|
|
51
|
+
return "0.0.0";
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Turn a user-supplied workspace path into an absolute one.
|
|
55
|
+
*
|
|
56
|
+
* A relative path used to be resolved against `process.cwd()`, so a config
|
|
57
|
+
* entry like `"workspace": "workspaces/alpha"` (the form shipped in
|
|
58
|
+
* teapot.config.example.json) silently pointed at whatever directory the
|
|
59
|
+
* daemon happened to be started from — and the agent's bash tool then ran in
|
|
60
|
+
* the wrong directory (#34). Relative workspaces are now anchored to the
|
|
61
|
+
* config file's own directory, which is what "the workspace" means inside a
|
|
62
|
+
* config file.
|
|
63
|
+
*
|
|
64
|
+
* `~` is expanded for both forms; absolute paths pass through untouched.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveWorkspace(input, base) {
|
|
67
|
+
const expanded = input.trim().replace(/^~(?=$|\/|\\)/, process.env.HOME ?? "~");
|
|
68
|
+
if (path.isAbsolute(expanded))
|
|
69
|
+
return path.normalize(expanded);
|
|
70
|
+
return path.resolve(base, expanded);
|
|
71
|
+
}
|
|
24
72
|
/** default sub-agent personas — mentionable from the composer (@name) and
|
|
25
73
|
* usable by agents via spawn_agent({persona}) */
|
|
26
74
|
export const SUB_PERSONAS = {
|
|
@@ -324,7 +372,7 @@ export class Master {
|
|
|
324
372
|
}
|
|
325
373
|
let agentId;
|
|
326
374
|
if (body.workspace?.trim()) {
|
|
327
|
-
const ws =
|
|
375
|
+
const ws = resolveWorkspace(body.workspace, path.dirname(this.configPath));
|
|
328
376
|
await mkdirSync(ws, { recursive: true });
|
|
329
377
|
const name = (body.agentName?.trim() || path.basename(ws))
|
|
330
378
|
.replace(/[^\w.-]/g, "-")
|
|
@@ -379,6 +427,11 @@ export class Master {
|
|
|
379
427
|
]);
|
|
380
428
|
void listPromise.catch(() => []);
|
|
381
429
|
}
|
|
430
|
+
// catalogue entry for this model — gates reasoning_effort support (#47)
|
|
431
|
+
const modelMeta = (await Promise.race([
|
|
432
|
+
fetchModelList(llm.baseUrl, llm.apiKey),
|
|
433
|
+
new Promise((r) => setTimeout(() => r(undefined), 1_500)),
|
|
434
|
+
]))?.find((m) => m.id === llm.model);
|
|
382
435
|
const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
|
|
383
436
|
await mkdirSync(sessionDir, { recursive: true });
|
|
384
437
|
// every session dir this master hands out carries an ownership manifest —
|
|
@@ -386,7 +439,7 @@ export class Master {
|
|
|
386
439
|
this.writeSessionManifest(sessionDir, ac.id);
|
|
387
440
|
const agent = new Agent({
|
|
388
441
|
id: ac.id,
|
|
389
|
-
workspace: path.
|
|
442
|
+
workspace: resolveWorkspace(ac.workspace, path.dirname(this.configPath)),
|
|
390
443
|
llm,
|
|
391
444
|
sessionDir,
|
|
392
445
|
progressIntervalMs: this.config.progressIntervalMs,
|
|
@@ -410,6 +463,12 @@ export class Master {
|
|
|
410
463
|
...(ac.readOnly ? { readOnlyTools: true } : {}),
|
|
411
464
|
...(ac.parent ? { parent: ac.parent } : {}),
|
|
412
465
|
...(ac.chatFn ? { chatFn: ac.chatFn } : {}),
|
|
466
|
+
// #47: reasoning effort is a per-agent choice; the catalogue decides
|
|
467
|
+
// whether it can actually be sent (see effortForRequest)
|
|
468
|
+
...(ac.reasoningEffort ? { reasoningEffort: ac.reasoningEffort } : {}),
|
|
469
|
+
...(modelMeta?.supportedParameters
|
|
470
|
+
? { supportedParameters: modelMeta.supportedParameters }
|
|
471
|
+
: {}),
|
|
413
472
|
});
|
|
414
473
|
// pricing metadata (USD/token) for the runtime cost estimate — best
|
|
415
474
|
// effort: providers without pricing in /models simply show no cost
|
|
@@ -451,8 +510,24 @@ export class Master {
|
|
|
451
510
|
this.config.agents.push(ac);
|
|
452
511
|
this.saveConfig();
|
|
453
512
|
}
|
|
513
|
+
// Remember which session this agent is bound to, so a restart reattaches
|
|
514
|
+
// deterministically instead of re-guessing from directory mtimes (#39).
|
|
515
|
+
// Done for EVERY agent (persisted or spawned): a spawned child is re-created
|
|
516
|
+
// on boot too, and guessing there is the same resurrection bug.
|
|
517
|
+
this.recordSession(ac, path.basename(sessionDir));
|
|
454
518
|
return agent;
|
|
455
519
|
}
|
|
520
|
+
/** Persist an agent's current session id into the config, if it changed. */
|
|
521
|
+
recordSession(ac, sessionId) {
|
|
522
|
+
if (!sessionId || ac.session === sessionId)
|
|
523
|
+
return;
|
|
524
|
+
ac.session = sessionId;
|
|
525
|
+
const entry = this.config.agents.find((a) => a.id === ac.id);
|
|
526
|
+
if (!entry)
|
|
527
|
+
return; // not a configured agent — nothing to persist yet
|
|
528
|
+
entry.session = sessionId;
|
|
529
|
+
this.saveConfig();
|
|
530
|
+
}
|
|
456
531
|
/** spawn-tree depth of an agent (0 = top level); unknown → 0 */
|
|
457
532
|
depthOf(id, seen = new Set()) {
|
|
458
533
|
const cap = this.maxSpawnDepth();
|
|
@@ -651,8 +726,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
651
726
|
if (owned)
|
|
652
727
|
targetId = owned.agentId;
|
|
653
728
|
const cfg = this.config.agents.find((a) => a.id === targetId);
|
|
654
|
-
if (!cfg || cfg.parent !== parentId)
|
|
655
|
-
|
|
729
|
+
if (!cfg || cfg.parent !== parentId) {
|
|
730
|
+
// Scoping is correct — only the DIRECT parent may message a child, so a
|
|
731
|
+
// grandchild is rightly refused. But "not your sub-agent: <id>" reads
|
|
732
|
+
// like a bug when the agent simply guessed the wrong level of the tree.
|
|
733
|
+
// Name the real owner so the model can re-address the message (#11).
|
|
734
|
+
const actual = cfg?.parent;
|
|
735
|
+
const live = actual ? this.agents.get(actual) : undefined;
|
|
736
|
+
const detail = actual
|
|
737
|
+
? ` its parent is @${actual}${live ? ` (message @${actual} instead, or spawn your own sub-agent for it)` : ""}.`
|
|
738
|
+
: " no such sub-agent exists.";
|
|
739
|
+
throw new Error(`not your sub-agent: ${childId}.${detail}`);
|
|
740
|
+
}
|
|
656
741
|
const child = this.agents.get(targetId);
|
|
657
742
|
if (!child)
|
|
658
743
|
throw new Error(`sub-agent not running: ${targetId}`);
|
|
@@ -827,6 +912,21 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
827
912
|
}
|
|
828
913
|
}
|
|
829
914
|
}
|
|
915
|
+
// Prefer the recorded session: the config says exactly which timeline this
|
|
916
|
+
// agent was on. Guessing by mtime let a stale-or-deleted dir whose
|
|
917
|
+
// chat.jsonl happened to be newer win, handing the agent a timeline the
|
|
918
|
+
// operator had moved off — the "deleted session comes back" report (#39).
|
|
919
|
+
const recorded = this.config.agents.find((a) => a.id === agentId)?.session;
|
|
920
|
+
if (!fresh && recorded) {
|
|
921
|
+
const dir = path.join(root, recorded);
|
|
922
|
+
// only trust it when it is really THIS agent's dir (ownership comes from
|
|
923
|
+
// the manifest; a renamed/reused dir must not resurrect a ghost)
|
|
924
|
+
const owner = this.readSessionOwner(recorded);
|
|
925
|
+
if (existsSync(dir) && (owner === null || owner === agentId))
|
|
926
|
+
return dir;
|
|
927
|
+
// the recorded dir is gone (deleted) — fall through and mint a new one
|
|
928
|
+
console.log(`[teapot] session ${recorded} for ${agentId} is gone; starting a new one`);
|
|
929
|
+
}
|
|
830
930
|
// Prefer the LATEST session directory that actually carries history.
|
|
831
931
|
// This handles the case where a bare-name directory (e.g. from legacy
|
|
832
932
|
// migration) would otherwise shadow a newer UUID-suffixed incarnation
|
|
@@ -942,7 +1042,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
942
1042
|
* Without: keep the current endpoint, swap only the model name.
|
|
943
1043
|
* The choice is persisted on the agent config so it survives restarts.
|
|
944
1044
|
*/
|
|
945
|
-
async setAgentModel(id, providerName, model, contextWindowTokens) {
|
|
1045
|
+
async setAgentModel(id, providerName, model, contextWindowTokens, reasoningEffort) {
|
|
946
1046
|
const agent = this.agents.get(id);
|
|
947
1047
|
if (!agent)
|
|
948
1048
|
throw new Error(`no such agent: ${id}`);
|
|
@@ -966,6 +1066,20 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
966
1066
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
967
1067
|
effectiveProvider = ac?.provider ?? this.config.defaultProvider ?? "";
|
|
968
1068
|
}
|
|
1069
|
+
// #47: re-resolve the catalogue for the (possibly new) model so the
|
|
1070
|
+
// effort gate reflects THIS model's advertised capabilities
|
|
1071
|
+
const effortList = await fetchModelList(llm.baseUrl, llm.apiKey).catch(() => []);
|
|
1072
|
+
const meta = effortList.find((m) => m.id === (model || llm.model));
|
|
1073
|
+
const opts = agent;
|
|
1074
|
+
if (reasoningEffort !== undefined) {
|
|
1075
|
+
// validated here so an unsupported value never reaches the request body;
|
|
1076
|
+
// "" or an unrecognised level clears it (provider default)
|
|
1077
|
+
opts.opts.reasoningEffort = isReasoningEffort(reasoningEffort) ? reasoningEffort : "";
|
|
1078
|
+
const cfgEntry = this.config.agents.find((a) => a.id === id);
|
|
1079
|
+
if (cfgEntry)
|
|
1080
|
+
cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
|
|
1081
|
+
}
|
|
1082
|
+
opts.opts.supportedParameters = meta?.supportedParameters ?? [];
|
|
969
1083
|
if (!llm.model)
|
|
970
1084
|
throw new Error("no model resolved (pass model or set one on the provider)");
|
|
971
1085
|
if (!llm.baseUrl)
|
|
@@ -1079,8 +1193,16 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1079
1193
|
})),
|
|
1080
1194
|
};
|
|
1081
1195
|
}
|
|
1082
|
-
/**
|
|
1083
|
-
|
|
1196
|
+
/**
|
|
1197
|
+
* Current application version.
|
|
1198
|
+
*
|
|
1199
|
+
* Read from package.json at runtime rather than via a `"__APP_VERSION__"`
|
|
1200
|
+
* literal: `__APP_VERSION__` is substituted by *vite* only, so the tsc-built
|
|
1201
|
+
* backend used to ship the raw placeholder and the settings panel rendered
|
|
1202
|
+
* "current version: __APP_VERSION__" (#33). A runtime read is correct in the
|
|
1203
|
+
* built app, in `pnpm dev` and under `node --test`, which never run vite.
|
|
1204
|
+
*/
|
|
1205
|
+
static VERSION = readAppVersion();
|
|
1084
1206
|
/** Return the current server version. */
|
|
1085
1207
|
getVersion() {
|
|
1086
1208
|
return Master.VERSION;
|
|
@@ -1118,14 +1240,24 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1118
1240
|
const env = { ...process.env };
|
|
1119
1241
|
// Tell the new process it's a restart (it will read the version from the old one)
|
|
1120
1242
|
env.TEAPOT_RESTART_FROM = String(process.pid);
|
|
1243
|
+
// Mark OURSELVES as a live-update predecessor too. Our own SIGTERM handler
|
|
1244
|
+
// (src/index.ts) lingers when this var is set, so /api/update/restart's
|
|
1245
|
+
// response still reaches the browser before we exit — otherwise the new
|
|
1246
|
+
// process SIGTERMs us the instant it binds the port and the browser sees a
|
|
1247
|
+
// dropped connection instead of {ok:true} (#35).
|
|
1248
|
+
process.env.TEAPOT_RESTART_FROM = String(process.pid);
|
|
1121
1249
|
const child = spawn(execPath, args, {
|
|
1122
|
-
detached:
|
|
1123
|
-
|
|
1250
|
+
// NOT detached, and NOT stdio:"ignore" (#35). Both made the live update
|
|
1251
|
+
// silently daemonize: the replacement inherited nothing from this
|
|
1252
|
+
// process, so its console.log output vanished — you could not see what it
|
|
1253
|
+
// was doing — and it outlived the terminal, so Ctrl-C and a closing
|
|
1254
|
+
// shell could no longer stop it. Inheriting stdio keeps the logs on the
|
|
1255
|
+
// same console and, without `detached`, the child stays in this process
|
|
1256
|
+
// group so it receives the same Ctrl-C.
|
|
1257
|
+
stdio: "inherit",
|
|
1124
1258
|
env,
|
|
1125
1259
|
});
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
// This process will exit when the new one takes over (SIGTERM handled in index.ts)
|
|
1129
|
-
await new Promise((r) => setTimeout(r, 1_000));
|
|
1260
|
+
await new Promise((r) => setTimeout(r, 1_500));
|
|
1261
|
+
void child; // deliberately not unref()'d — see above
|
|
1130
1262
|
}
|
|
1131
1263
|
}
|
package/dist/model-meta.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import { providerHeaders } from "./agent/llm.js";
|
|
8
8
|
const cache = new Map();
|
|
9
|
+
const inFlight = new Map();
|
|
9
10
|
const TTL = 10 * 60_000;
|
|
10
11
|
/** Fetch GET /models from an OpenAI-compatible endpoint (cached ~10 min). */
|
|
11
12
|
export async function fetchModelList(baseUrl, apiKey) {
|
|
@@ -13,49 +14,108 @@ export async function fetchModelList(baseUrl, apiKey) {
|
|
|
13
14
|
const hit = cache.get(root);
|
|
14
15
|
if (hit && Date.now() - hit.at < TTL)
|
|
15
16
|
return hit.list;
|
|
17
|
+
// An in-flight lookup is SHARED: without this, two callers racing for the
|
|
18
|
+
// same endpoint each paid the full timeout, and `newSession` in ACP mode
|
|
19
|
+
// blocked on it before it could even reply to the editor (#15).
|
|
20
|
+
const inflight = inFlight.get(root);
|
|
21
|
+
if (inflight)
|
|
22
|
+
return inflight;
|
|
23
|
+
const p = (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${root}/models`, {
|
|
26
|
+
headers: {
|
|
27
|
+
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
28
|
+
...providerHeaders(baseUrl), // OpenRouter app attribution
|
|
29
|
+
},
|
|
30
|
+
// boot-time calls happen per agent; a slow/unroutable endpoint used to
|
|
31
|
+
// stall startup for up to 15s PER AGENT before this was tightened
|
|
32
|
+
signal: AbortSignal.timeout(3_000),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
throw new Error(`HTTP ${res.status}`);
|
|
36
|
+
const j = (await res.json());
|
|
37
|
+
const list = (j.data ?? [])
|
|
38
|
+
.map((m) => ({
|
|
39
|
+
id: typeof m.id === "string" ? m.id : "",
|
|
40
|
+
contextLength: typeof m.context_length === "number" ? m.context_length : undefined,
|
|
41
|
+
// OpenRouter-style USD per token (e.g. "0.000003") — powers the
|
|
42
|
+
// runtime cost estimate. Providers without pricing stay undefined.
|
|
43
|
+
pricing: m.pricing && (m.pricing.prompt !== undefined || m.pricing.completion !== undefined)
|
|
44
|
+
? {
|
|
45
|
+
prompt: Number(m.pricing.prompt ?? 0),
|
|
46
|
+
completion: Number(m.pricing.completion ?? 0),
|
|
47
|
+
}
|
|
48
|
+
: undefined,
|
|
49
|
+
modalities: Array.isArray(m.architecture?.input_modalities) && m.architecture.input_modalities.length
|
|
50
|
+
? {
|
|
51
|
+
input: m.architecture.input_modalities,
|
|
52
|
+
output: m.architecture.output_modalities ?? ["text"],
|
|
53
|
+
}
|
|
54
|
+
: undefined,
|
|
55
|
+
// OpenRouter advertises per-model capabilities; other endpoints omit
|
|
56
|
+
// the field entirely, which leaves support "unknown" rather than false.
|
|
57
|
+
supportedParameters: Array.isArray(m.supported_parameters)
|
|
58
|
+
? m.supported_parameters.filter((p) => typeof p === "string")
|
|
59
|
+
: undefined,
|
|
60
|
+
}))
|
|
61
|
+
.filter((m) => m.id);
|
|
62
|
+
cache.set(root, { at: Date.now(), list });
|
|
63
|
+
return list;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return []; // unreachable provider — caller treats as "unknown"
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
inFlight.set(root, p);
|
|
16
70
|
try {
|
|
17
|
-
|
|
18
|
-
headers: {
|
|
19
|
-
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
20
|
-
...providerHeaders(baseUrl), // OpenRouter app attribution
|
|
21
|
-
},
|
|
22
|
-
// boot-time calls happen per agent; a slow/unroutable endpoint used to
|
|
23
|
-
// stall startup for up to 15s PER AGENT before this was tightened
|
|
24
|
-
signal: AbortSignal.timeout(3_000),
|
|
25
|
-
});
|
|
26
|
-
if (!res.ok)
|
|
27
|
-
throw new Error(`HTTP ${res.status}`);
|
|
28
|
-
const j = (await res.json());
|
|
29
|
-
const list = (j.data ?? [])
|
|
30
|
-
.map((m) => ({
|
|
31
|
-
id: typeof m.id === "string" ? m.id : "",
|
|
32
|
-
contextLength: typeof m.context_length === "number" ? m.context_length : undefined,
|
|
33
|
-
// OpenRouter-style USD per token (e.g. "0.000003") — powers the
|
|
34
|
-
// runtime cost estimate. Providers without pricing stay undefined.
|
|
35
|
-
pricing: m.pricing && (m.pricing.prompt !== undefined || m.pricing.completion !== undefined)
|
|
36
|
-
? {
|
|
37
|
-
prompt: Number(m.pricing.prompt ?? 0),
|
|
38
|
-
completion: Number(m.pricing.completion ?? 0),
|
|
39
|
-
}
|
|
40
|
-
: undefined,
|
|
41
|
-
modalities: Array.isArray(m.architecture?.input_modalities) && m.architecture.input_modalities.length
|
|
42
|
-
? {
|
|
43
|
-
input: m.architecture.input_modalities,
|
|
44
|
-
output: m.architecture.output_modalities ?? ["text"],
|
|
45
|
-
}
|
|
46
|
-
: undefined,
|
|
47
|
-
}))
|
|
48
|
-
.filter((m) => m.id);
|
|
49
|
-
cache.set(root, { at: Date.now(), list });
|
|
50
|
-
return list;
|
|
71
|
+
return await p;
|
|
51
72
|
}
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
finally {
|
|
74
|
+
inFlight.delete(root);
|
|
54
75
|
}
|
|
55
76
|
}
|
|
56
77
|
export function contextLengthFor(list, model) {
|
|
57
78
|
return list.find((m) => m.id === model)?.contextLength;
|
|
58
79
|
}
|
|
80
|
+
/** Reasoning-effort levels OpenRouter accepts (`reasoning_effort`). */
|
|
81
|
+
export const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
|
|
82
|
+
export function isReasoningEffort(v) {
|
|
83
|
+
return typeof v === "string" && REASONING_EFFORTS.includes(v);
|
|
84
|
+
}
|
|
85
|
+
/** True when the endpoint is OpenRouter (the only one that documents effort). */
|
|
86
|
+
export function isOpenRouter(baseUrl) {
|
|
87
|
+
try {
|
|
88
|
+
return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The `reasoning_effort` value to actually SEND, or undefined to omit it.
|
|
96
|
+
*
|
|
97
|
+
* Verified against the live OpenRouter /models endpoint (462 models): 196
|
|
98
|
+
* advertise `reasoning_effort`, 331 the broader `reasoning`, and NONE a bare
|
|
99
|
+
* top-level `effort`. Support is therefore far from universal, and sending the
|
|
100
|
+
* parameter to a model that does not advertise it is how a request gets
|
|
101
|
+
* rejected or a setting silently ignored (#47) — so it is sent only when the
|
|
102
|
+
* endpoint is OpenRouter AND the model advertises it. Off by default: an absent
|
|
103
|
+
* setting means "let the provider apply its own default".
|
|
104
|
+
*/
|
|
105
|
+
export function resolveReasoningEffort(opts) {
|
|
106
|
+
if (!isReasoningEffort(opts.effort))
|
|
107
|
+
return undefined;
|
|
108
|
+
if (!isOpenRouter(opts.baseUrl))
|
|
109
|
+
return undefined;
|
|
110
|
+
const meta = opts.list?.find((m) => m.id === opts.model);
|
|
111
|
+
// No catalogue for this endpoint: we cannot verify support, so do not send.
|
|
112
|
+
// A wrong body field is worse than an unset one.
|
|
113
|
+
if (!meta?.supportedParameters)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (!meta.supportedParameters.includes("reasoning_effort"))
|
|
116
|
+
return undefined;
|
|
117
|
+
return opts.effort;
|
|
118
|
+
}
|
|
59
119
|
/** test hook */
|
|
60
120
|
export function clearModelListCache() {
|
|
61
121
|
cache.clear();
|
package/dist/server/api.js
CHANGED
|
@@ -10,7 +10,8 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { promises as fs } from "node:fs";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { parseSchedule } from "../scheduler/cron.js";
|
|
13
|
-
import {
|
|
13
|
+
import { TermSizeTracker } from "./term-size.js";
|
|
14
|
+
import { SUB_PERSONAS, resolveWorkspace } from "../master.js";
|
|
14
15
|
import { ConfigPatchSchema, formatZodError } from "../config-schema.js";
|
|
15
16
|
import { providerHeaders } from "../agent/llm.js";
|
|
16
17
|
import { safeJoin } from "../agent/tools.js";
|
|
@@ -105,6 +106,14 @@ export function buildApp(master) {
|
|
|
105
106
|
});
|
|
106
107
|
// per-agent terminal spawn guard
|
|
107
108
|
const termCounts = new Map();
|
|
109
|
+
// keyed by CHILD, not agent: one agent may have up to 10 concurrent
|
|
110
|
+
// terminals, each with its own size and its own foreground program
|
|
111
|
+
const termSizes = new TermSizeTracker();
|
|
112
|
+
const setTermSize = (child, rows, cols) => termSizes.request(child, rows, cols, (r, c) => {
|
|
113
|
+
if (!child.stdin?.writable)
|
|
114
|
+
return;
|
|
115
|
+
child.stdin.write(`stty rows ${r} cols ${c} >/dev/null 2>&1\n`);
|
|
116
|
+
});
|
|
108
117
|
// ---- realtime events over WebSocket (replaces SSE for the web UI) ----
|
|
109
118
|
app.get("/api/ws", upgradeWebSocket(() => {
|
|
110
119
|
let onUpdate = null;
|
|
@@ -201,8 +210,14 @@ export function buildApp(master) {
|
|
|
201
210
|
? spawn("script", ["-qec", shell, "/dev/null"], { cwd: agent.workspace, env })
|
|
202
211
|
: spawn(shell, [], { cwd: agent.workspace, env: { ...env, TERM: "dumb" } });
|
|
203
212
|
console.log(`[teapot] ⌨ terminal open: ${agentId} @ ${agent.workspace} (${hasScript ? "pty" : "pipe"})`);
|
|
204
|
-
child.stdout?.on("data", (b) =>
|
|
205
|
-
|
|
213
|
+
child.stdout?.on("data", (b) => {
|
|
214
|
+
termSizes.markBusy(child);
|
|
215
|
+
send({ kind: "data", data: b.toString("utf8") });
|
|
216
|
+
});
|
|
217
|
+
child.stderr?.on("data", (b) => {
|
|
218
|
+
termSizes.markBusy(child);
|
|
219
|
+
send({ kind: "data", data: b.toString("utf8") });
|
|
220
|
+
});
|
|
206
221
|
child.on("close", (code) => {
|
|
207
222
|
send({ kind: "exit", code });
|
|
208
223
|
console.log(`[teapot] ⌨ terminal exit: ${agentId} (${code ?? "signal"})`);
|
|
@@ -225,10 +240,15 @@ export function buildApp(master) {
|
|
|
225
240
|
const r = Number(m.rows) | 0;
|
|
226
241
|
const cl = Number(m.cols) | 0;
|
|
227
242
|
if (r > 0 && cl > 0)
|
|
228
|
-
child
|
|
243
|
+
setTermSize(child, r, cl);
|
|
229
244
|
}
|
|
230
245
|
},
|
|
231
246
|
onClose() {
|
|
247
|
+
// capture BEFORE cleanup() nulls the child out
|
|
248
|
+
const dead = child;
|
|
249
|
+
// drop the pending resize timer so we can't write into a dead child
|
|
250
|
+
if (dead)
|
|
251
|
+
termSizes.forget(dead);
|
|
232
252
|
cleanup();
|
|
233
253
|
const n = (termCounts.get(agentId) ?? 1) - 1;
|
|
234
254
|
if (n <= 0)
|
|
@@ -245,7 +265,7 @@ export function buildApp(master) {
|
|
|
245
265
|
const body = await c.req.json();
|
|
246
266
|
if (!body.workspace?.trim())
|
|
247
267
|
return c.json({ error: "workspace required" }, 400);
|
|
248
|
-
const ws =
|
|
268
|
+
const ws = resolveWorkspace(body.workspace, path.dirname(master.configPath));
|
|
249
269
|
try {
|
|
250
270
|
const st = await fs.stat(ws);
|
|
251
271
|
if (!st.isDirectory())
|
|
@@ -702,7 +722,7 @@ export function buildApp(master) {
|
|
|
702
722
|
if (!body)
|
|
703
723
|
return c.json({ error: "invalid JSON" }, 400);
|
|
704
724
|
try {
|
|
705
|
-
const r = await master.setAgentModel(c.req.param("id"), body.provider, body.model, body.contextWindowTokens);
|
|
725
|
+
const r = await master.setAgentModel(c.req.param("id"), body.provider, body.model, body.contextWindowTokens, body.reasoningEffort);
|
|
706
726
|
return c.json({ ok: true, ...r });
|
|
707
727
|
}
|
|
708
728
|
catch (err) {
|
|
@@ -974,9 +994,25 @@ export function buildApp(master) {
|
|
|
974
994
|
for (const e of events) {
|
|
975
995
|
const b = branches.get(e.branch) ?? { branch: e.branch, events: 0 };
|
|
976
996
|
b.events++;
|
|
977
|
-
if (e.type === "fork")
|
|
978
|
-
b.forkedFrom = e.data;
|
|
979
997
|
branches.set(e.branch, b);
|
|
998
|
+
// A fork event is recorded on the branch it was forked FROM (so the
|
|
999
|
+
// parent chain stays linked), but it describes the branch it CREATED.
|
|
1000
|
+
// Attribute it to the new branch, or the fork shows up as belonging to
|
|
1001
|
+
// the old one and the fork picker mislabels it (#38).
|
|
1002
|
+
if (e.type === "fork") {
|
|
1003
|
+
const created = String(e.data?.newBranch ?? "");
|
|
1004
|
+
if (created && created !== e.branch) {
|
|
1005
|
+
const nb = branches.get(created) ?? { branch: created, events: 0 };
|
|
1006
|
+
nb.forkedFrom = e.data;
|
|
1007
|
+
branches.set(created, nb);
|
|
1008
|
+
b.events--; // it is not an event OF the branch it was forked from
|
|
1009
|
+
if (b.events <= 0)
|
|
1010
|
+
branches.delete(e.branch);
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
b.forkedFrom = e.data;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
980
1016
|
}
|
|
981
1017
|
return c.json({ branches: [...branches.values()] });
|
|
982
1018
|
});
|