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/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"));
@@ -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 = path.resolve(body.workspace.replace(/^~/, process.env.HOME ?? "~"));
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.resolve(ac.workspace),
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
- /** Current application version (injected at build time via package.json). */
1093
- static VERSION = "__APP_VERSION__";
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: true,
1133
- stdio: "ignore",
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
- child.unref(); // Let the parent exit when ready
1137
- // Give the new process time to start and bind the port
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
  }
@@ -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
- const res = await fetch(`${root}/models`, {
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
- catch {
53
- return []; // unreachable provider — caller treats as "unknown"
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();