teapot-coding-agent 0.23.0 → 0.24.1
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/dist/acp/adapter.js +198 -0
- package/dist/agent/agent.js +305 -20
- package/dist/agent/llm.js +33 -0
- package/dist/agent/skills.js +165 -32
- package/dist/agent/tools.js +632 -27
- package/dist/index.js +74 -27
- package/dist/log/events.js +96 -0
- package/dist/master.js +134 -12
- package/dist/model-meta.js +96 -36
- package/dist/server/api.js +42 -9
- package/package.json +1 -1
- package/public/assets/index-ClyCarLt.css +1 -0
- package/public/assets/{index-CNmd6GUS.js → index-sfdzqjpM.js} +20 -20
- package/public/assets/index-sfdzqjpM.js.map +1 -0
- package/public/assets/{shiki-NOpdigkY.js → shiki-D1TWrY4w.js} +2 -2
- package/public/assets/{shiki-NOpdigkY.js.map → shiki-D1TWrY4w.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-CNmd6GUS.js.map +0 -1
- package/public/assets/index-QCwmIYsM.css +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)
|
|
@@ -142,3 +156,85 @@ export async function readEvents(filePath) {
|
|
|
142
156
|
return [];
|
|
143
157
|
}
|
|
144
158
|
}
|
|
159
|
+
/** Default floor for a tail read, so a small `limit` cannot cause a tiny read. */
|
|
160
|
+
const TAIL_MIN_BYTES = 64 * 1024;
|
|
161
|
+
/**
|
|
162
|
+
* Read only the LAST `limit` events from an append-only JSONL log.
|
|
163
|
+
*
|
|
164
|
+
* #59: the web UI polls this endpoint every ~120 ms for the selected agent, and
|
|
165
|
+
* the full re-parse cost 348 ms on a 57 MB / 200 000-event log. The mtime cache
|
|
166
|
+
* could not help — a RUNNING agent appends constantly, so `size` and `mtimeMs`
|
|
167
|
+
* change on every single request and the cache only ever helped stopped agents.
|
|
168
|
+
*
|
|
169
|
+
* The log is strictly append-only: `EventLog.append` is the only writer and
|
|
170
|
+
* always writes a whole `JSON.stringify(evt) + "\n"` under a serialized chain,
|
|
171
|
+
* so an append never rewrites earlier bytes. That makes a tail read safe, and
|
|
172
|
+
* makes byte-offset caching legitimate.
|
|
173
|
+
*
|
|
174
|
+
* Anchored to the tail deliberately. The read starts at a byte offset chosen for
|
|
175
|
+
* the file's current size, so the FIRST line it sees is usually a fragment —
|
|
176
|
+
* dropped. `bytesScanned` is reported so callers (and tests) can assert the read
|
|
177
|
+
* really was partial rather than trusting a wall-clock number.
|
|
178
|
+
*/
|
|
179
|
+
export async function readEventsTail(filePath, limit) {
|
|
180
|
+
const empty = { events: [], total: 0, bytesScanned: 0, truncated: false };
|
|
181
|
+
if (limit <= 0)
|
|
182
|
+
return empty;
|
|
183
|
+
let fh = null;
|
|
184
|
+
try {
|
|
185
|
+
const fsp = await import("node:fs/promises");
|
|
186
|
+
fh = await fsp.open(filePath, "r");
|
|
187
|
+
const st = await fh.stat();
|
|
188
|
+
if (st.size === 0)
|
|
189
|
+
return empty;
|
|
190
|
+
// grow the window until it holds `limit` whole lines. Start at a floor so a
|
|
191
|
+
// small limit still reads a sensible block, then double — bounded by the
|
|
192
|
+
// file size, because a pathological file must not make us read forever.
|
|
193
|
+
let want = Math.min(st.size, Math.max(TAIL_MIN_BYTES, limit * 512));
|
|
194
|
+
for (;;) {
|
|
195
|
+
const r = await readWindow(fh, st.size, want);
|
|
196
|
+
if (r.events.length >= limit || want >= st.size) {
|
|
197
|
+
return {
|
|
198
|
+
events: r.events.slice(-limit),
|
|
199
|
+
// `total` is an APPROXIMATE count: only the window was parsed, so this
|
|
200
|
+
// is "at least this many". /events uses it for the "load older" hint,
|
|
201
|
+
// where approximate is fine — an exact count would mean reading the
|
|
202
|
+
// entire file, which is the cost this function exists to avoid.
|
|
203
|
+
total: r.events.length,
|
|
204
|
+
bytesScanned: r.bytes,
|
|
205
|
+
truncated: r.bytes < st.size,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
want = Math.min(st.size, want * 4);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return empty;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await fh?.close().catch(() => { });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** read the last `bytes` of the file, dropping the leading partial line */
|
|
219
|
+
async function readWindow(fh, size, bytes) {
|
|
220
|
+
const start = Math.max(0, size - bytes);
|
|
221
|
+
const buf = Buffer.alloc(size - start);
|
|
222
|
+
if (buf.length)
|
|
223
|
+
await fh.read(buf, 0, buf.length, start);
|
|
224
|
+
const text = buf.toString("utf8");
|
|
225
|
+
const nl = text.indexOf("\n");
|
|
226
|
+
// the first line is a fragment unless we started at 0
|
|
227
|
+
const body = start > 0 ? (nl === -1 ? "" : text.slice(nl + 1)) : text;
|
|
228
|
+
const events = [];
|
|
229
|
+
for (const line of body.split("\n")) {
|
|
230
|
+
if (!line.trim())
|
|
231
|
+
continue;
|
|
232
|
+
try {
|
|
233
|
+
events.push(JSON.parse(line));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* torn or corrupt line: skip it, never return a fragment */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { events, bytes: buf.length };
|
|
240
|
+
}
|
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();
|
|
@@ -837,6 +912,21 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
837
912
|
}
|
|
838
913
|
}
|
|
839
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
|
+
}
|
|
840
930
|
// Prefer the LATEST session directory that actually carries history.
|
|
841
931
|
// This handles the case where a bare-name directory (e.g. from legacy
|
|
842
932
|
// migration) would otherwise shadow a newer UUID-suffixed incarnation
|
|
@@ -952,7 +1042,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
952
1042
|
* Without: keep the current endpoint, swap only the model name.
|
|
953
1043
|
* The choice is persisted on the agent config so it survives restarts.
|
|
954
1044
|
*/
|
|
955
|
-
async setAgentModel(id, providerName, model, contextWindowTokens) {
|
|
1045
|
+
async setAgentModel(id, providerName, model, contextWindowTokens, reasoningEffort) {
|
|
956
1046
|
const agent = this.agents.get(id);
|
|
957
1047
|
if (!agent)
|
|
958
1048
|
throw new Error(`no such agent: ${id}`);
|
|
@@ -976,6 +1066,20 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
976
1066
|
const ac = this.config.agents.find((a) => a.id === id);
|
|
977
1067
|
effectiveProvider = ac?.provider ?? this.config.defaultProvider ?? "";
|
|
978
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 ?? [];
|
|
979
1083
|
if (!llm.model)
|
|
980
1084
|
throw new Error("no model resolved (pass model or set one on the provider)");
|
|
981
1085
|
if (!llm.baseUrl)
|
|
@@ -1089,8 +1193,16 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1089
1193
|
})),
|
|
1090
1194
|
};
|
|
1091
1195
|
}
|
|
1092
|
-
/**
|
|
1093
|
-
|
|
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();
|
|
1094
1206
|
/** Return the current server version. */
|
|
1095
1207
|
getVersion() {
|
|
1096
1208
|
return Master.VERSION;
|
|
@@ -1128,14 +1240,24 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1128
1240
|
const env = { ...process.env };
|
|
1129
1241
|
// Tell the new process it's a restart (it will read the version from the old one)
|
|
1130
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);
|
|
1131
1249
|
const child = spawn(execPath, args, {
|
|
1132
|
-
detached:
|
|
1133
|
-
|
|
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",
|
|
1134
1258
|
env,
|
|
1135
1259
|
});
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
// This process will exit when the new one takes over (SIGTERM handled in index.ts)
|
|
1139
|
-
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
|
|
1140
1262
|
}
|
|
1141
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();
|