@electric-ax/agents 0.2.4 → 0.4.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.
@@ -1,30 +1,32 @@
1
1
  #!/usr/bin/env node
2
- import { createServer } from "node:http";
3
2
  import path from "node:path";
4
- import fs, { promises, watch } from "node:fs";
3
+ import fs from "node:fs";
5
4
  import pino from "pino";
6
5
  import { fileURLToPath } from "node:url";
7
- import { CODING_SESSION_CURSOR_COLLECTION_TYPE, CODING_SESSION_EVENT_COLLECTION_TYPE, CODING_SESSION_META_COLLECTION_TYPE, createEntityRegistry, createRuntimeHandler, db } from "@electric-ax/agents-runtime";
8
- import { spawn } from "node:child_process";
9
- import { homedir } from "node:os";
6
+ import { appendPathToUrl, completeWithLowCostModel, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, db, detectAvailableProviders, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
7
+ import { eq, not, queryOnce } from "@durable-streams/state";
10
8
  import { z } from "zod";
11
- import { deserializeCursor, discoverSessions, importLocalSession, loadSession, resolveSession, serializeCursor, tailSession } from "agent-session-protocol";
12
- import Anthropic from "@anthropic-ai/sdk";
13
9
  import { createHash } from "node:crypto";
14
10
  import fs$1 from "node:fs/promises";
15
11
  import Database from "better-sqlite3";
16
12
  import { Type } from "@sinclair/typebox";
17
13
  import { load } from "sqlite-vec";
18
14
  import { nanoid } from "nanoid";
19
- import { braveSearchTool, createBashTool, createEditTool, createReadFileTool, createWriteTool, fetchUrlTool } from "@electric-ax/agents-runtime/tools";
15
+ import { getModels } from "@mariozechner/pi-ai";
16
+ import { braveSearchTool, createBashTool, createEditTool, createFetchUrlTool, createReadFileTool, createWriteTool, fetchUrlTool } from "@electric-ax/agents-runtime/tools";
17
+ import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, keychainPersistence, loadConfig, mcp, watchConfig } from "@electric-ax/agents-mcp";
20
18
 
21
19
  //#region src/log.ts
22
20
  const LOG_DIR = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
23
21
  fs.mkdirSync(LOG_DIR, { recursive: true });
24
22
  const LOG_FILE = path.join(LOG_DIR, `builtin-agents-${Date.now()}.jsonl`);
25
23
  const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
26
- const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST;
27
- const streams = [{ stream: pino.destination(LOG_FILE) }];
24
+ const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
25
+ const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
26
+ const streams = [{ stream: pino.destination({
27
+ dest: LOG_FILE,
28
+ sync: IS_ELECTRON_MAIN
29
+ }) }];
28
30
  if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
29
31
  target: `pino-pretty`,
30
32
  options: {
@@ -71,516 +73,6 @@ const serverLog = {
71
73
  }
72
74
  };
73
75
 
74
- //#endregion
75
- //#region src/agents/coding-session.ts
76
- const defaultCliRunner = { async run(opts) {
77
- return new Promise((resolve, reject) => {
78
- const isClaude = opts.agent === `claude`;
79
- const bin = isClaude ? `claude` : `codex`;
80
- const args = isClaude ? opts.sessionId ? [
81
- `-r`,
82
- opts.sessionId,
83
- `--dangerously-skip-permissions`,
84
- `-p`
85
- ] : [`--dangerously-skip-permissions`, `-p`] : opts.sessionId ? [
86
- `exec`,
87
- `--skip-git-repo-check`,
88
- `resume`,
89
- opts.sessionId,
90
- opts.prompt
91
- ] : [
92
- `exec`,
93
- `--skip-git-repo-check`,
94
- opts.prompt
95
- ];
96
- const child = spawn(bin, args, {
97
- cwd: opts.cwd,
98
- stdio: [
99
- isClaude ? `pipe` : `ignore`,
100
- `pipe`,
101
- `pipe`
102
- ]
103
- });
104
- const MAX_BUF_CHARS = 4096;
105
- let stdout = ``;
106
- let stderr = ``;
107
- child.stdout?.on(`data`, (d) => {
108
- if (stdout.length < MAX_BUF_CHARS) stdout += d.toString().slice(0, MAX_BUF_CHARS - stdout.length);
109
- });
110
- child.stderr?.on(`data`, (d) => {
111
- if (stderr.length < MAX_BUF_CHARS) stderr += d.toString().slice(0, MAX_BUF_CHARS - stderr.length);
112
- });
113
- child.on(`error`, reject);
114
- child.on(`exit`, (code) => {
115
- resolve({
116
- exitCode: code ?? -1,
117
- stdout,
118
- stderr
119
- });
120
- });
121
- if (isClaude && child.stdin) {
122
- child.stdin.write(opts.prompt);
123
- child.stdin.end();
124
- }
125
- });
126
- } };
127
- async function discoverNewestSession(agent, cwd, excludeIds) {
128
- const all = await discoverSessions(agent);
129
- const candidates = all.filter((s) => !excludeIds.has(s.sessionId) && (!s.cwd || s.cwd === cwd));
130
- if (candidates.length === 0) return null;
131
- return candidates[0].sessionId;
132
- }
133
- /**
134
- * Compute the candidate directories where Claude Code stores per-cwd
135
- * session JSONL files. Claude resolves the cwd to its realpath when
136
- * choosing the directory name (so /tmp/foo on macOS lands under
137
- * `-private-tmp-foo`), but the entity may have been spawned with the
138
- * non-realpath form. Return both candidates so the caller can union
139
- * their contents.
140
- */
141
- async function getClaudeProjectDirs(cwd) {
142
- const home = homedir();
143
- const make = (c) => path.join(home, `.claude`, `projects`, c.replace(/\//g, `-`));
144
- const dirs = [make(cwd)];
145
- try {
146
- const real = await promises.realpath(cwd);
147
- if (real !== cwd) dirs.push(make(real));
148
- } catch {}
149
- return dirs;
150
- }
151
- async function listClaudeJsonlIdsByCwd(cwd) {
152
- const ids = new Set();
153
- for (const dir of await getClaudeProjectDirs(cwd)) try {
154
- const files = await promises.readdir(dir);
155
- for (const f of files) if (f.endsWith(`.jsonl`)) ids.add(f.slice(0, -`.jsonl`.length));
156
- } catch {}
157
- return ids;
158
- }
159
- /**
160
- * Deterministic-path discovery for a freshly created session. After the
161
- * Claude CLI runs in `-p` mode it writes the new JSONL straight into
162
- * `~/.claude/projects/<sanitize(cwd)>/<id>.jsonl` *without* leaving a
163
- * `~/.claude/sessions/<pid>.json` lock file (those are interactive-only),
164
- * so `discoverSessions` can miss it. Compute the expected dir directly
165
- * and diff its contents against a pre-run snapshot. Returns the newest
166
- * fresh sessionId or null. Codex falls back to discoverNewestSession.
167
- */
168
- async function findNewSessionAfterRun(agent, cwd, preDirectIds, preDiscoveredIds) {
169
- if (agent === `claude`) {
170
- const dirs = await getClaudeProjectDirs(cwd);
171
- let best = null;
172
- for (const dir of dirs) try {
173
- const files = await promises.readdir(dir);
174
- for (const f of files) {
175
- if (!f.endsWith(`.jsonl`)) continue;
176
- const id = f.slice(0, -`.jsonl`.length);
177
- if (preDirectIds.has(id)) continue;
178
- const st = await promises.stat(path.join(dir, f)).catch(() => null);
179
- if (!st) continue;
180
- if (!best || st.mtimeMs > best.mtime) best = {
181
- id,
182
- mtime: st.mtimeMs
183
- };
184
- }
185
- } catch {}
186
- if (best) return best.id;
187
- }
188
- return discoverNewestSession(agent, cwd, preDiscoveredIds);
189
- }
190
- const sessionMetaRowSchema = z.object({
191
- key: z.literal(`current`),
192
- electricSessionId: z.string(),
193
- nativeSessionId: z.string().optional(),
194
- agent: z.enum([`claude`, `codex`]),
195
- cwd: z.string(),
196
- status: z.enum([
197
- `initializing`,
198
- `idle`,
199
- `running`,
200
- `error`
201
- ]),
202
- error: z.string().optional(),
203
- currentPromptInboxKey: z.string().optional()
204
- });
205
- const cursorStateRowSchema = z.object({
206
- key: z.literal(`current`),
207
- cursor: z.string(),
208
- lastProcessedInboxKey: z.string().optional()
209
- });
210
- const eventRowSchema = z.object({
211
- key: z.string(),
212
- ts: z.number(),
213
- type: z.string(),
214
- callId: z.string().optional(),
215
- payload: z.looseObject({})
216
- });
217
- const creationArgsSchema = z.object({
218
- agent: z.enum([`claude`, `codex`]),
219
- cwd: z.string().optional(),
220
- nativeSessionId: z.string().optional(),
221
- importFrom: z.object({
222
- agent: z.enum([`claude`, `codex`]),
223
- sessionId: z.string()
224
- }).optional()
225
- });
226
- const promptMessageSchema = z.object({ text: z.string() });
227
- /**
228
- * Stable key for an events-collection row, derived from the event's content.
229
- * Lets us re-insert the same event without producing duplicates — the caller
230
- * (or the collection's uniqueness guard) uses this to de-dup across retries,
231
- * replays, and crash recovery. Sorts chronologically by ts, then by type.
232
- */
233
- function eventKey(event) {
234
- const tsPart = String(event.ts).padStart(16, `0`);
235
- return `${tsPart}_${event.type}_${contentHashHex(event)}`;
236
- }
237
- function contentHashHex(event) {
238
- const json = JSON.stringify(event);
239
- let h = 5381;
240
- for (let i = 0; i < json.length; i++) h = (h * 33 ^ json.charCodeAt(i)) >>> 0;
241
- return h.toString(16).padStart(8, `0`);
242
- }
243
- function buildEventRow(event) {
244
- const callId = `callId` in event && typeof event.callId === `string` ? event.callId : void 0;
245
- return {
246
- key: eventKey(event),
247
- ts: event.ts,
248
- type: event.type,
249
- ...callId !== void 0 ? { callId } : {},
250
- payload: event
251
- };
252
- }
253
- function appendIfNew(ctx, event) {
254
- const row = buildEventRow(event);
255
- if (ctx.events.get(row.key) !== void 0) return;
256
- ctx.actions.events_insert({ row });
257
- }
258
- /**
259
- * Mirror every event that lands in the JSONL file while `runWork` is
260
- * executing (i.e. while the CLI is running). Returns the advanced cursor
261
- * and the `runWork` result once everything has settled and every append
262
- * has been persisted to the entity's durable stream.
263
- *
264
- * If setup fails (e.g. the session file can't be resolved), `runWork`
265
- * still runs — but nothing is mirrored and `setupError` is populated so
266
- * the caller can surface the condition. If `runWork` throws, the error
267
- * propagates after the watcher has been cleaned up.
268
- */
269
- async function runWithLiveMirror(opts) {
270
- let cursor = null;
271
- let setupError = void 0;
272
- try {
273
- const session = await resolveSession(opts.nativeSessionId, opts.agent);
274
- if (opts.serializedCursor) cursor = deserializeCursor({
275
- ...opts.serializedCursor,
276
- path: session.path
277
- });
278
- else {
279
- const initial = await loadSession({
280
- sessionId: opts.nativeSessionId,
281
- agent: opts.agent
282
- });
283
- for (const ev of initial.events) appendIfNew(opts.ctx, ev);
284
- cursor = initial.cursor;
285
- }
286
- } catch (e) {
287
- setupError = e;
288
- }
289
- if (!cursor) {
290
- const result$1 = await opts.runWork();
291
- return {
292
- cursor: opts.serializedCursor,
293
- setupError,
294
- result: result$1
295
- };
296
- }
297
- let activeCursor = cursor;
298
- let busy = false;
299
- let pending = false;
300
- let stopped = false;
301
- const drainOnce = async () => {
302
- if (stopped && busy) return;
303
- if (busy) {
304
- pending = true;
305
- return;
306
- }
307
- busy = true;
308
- try {
309
- const res = await tailSession({ cursor: activeCursor });
310
- activeCursor = res.cursor;
311
- for (const ev of res.newEvents) appendIfNew(opts.ctx, ev);
312
- } catch {} finally {
313
- busy = false;
314
- if (pending && !stopped) {
315
- pending = false;
316
- drainOnce();
317
- }
318
- }
319
- };
320
- const fileWatcher = watch(activeCursor.path, () => {
321
- drainOnce();
322
- });
323
- const pollHandle = setInterval(() => {
324
- drainOnce();
325
- }, 1500);
326
- let result;
327
- try {
328
- result = await opts.runWork();
329
- } finally {
330
- stopped = true;
331
- clearInterval(pollHandle);
332
- fileWatcher.close();
333
- while (busy) await new Promise((r) => setTimeout(r, 10));
334
- try {
335
- const final = await tailSession({ cursor: activeCursor });
336
- activeCursor = final.cursor;
337
- for (const ev of final.newEvents) appendIfNew(opts.ctx, ev);
338
- } catch {}
339
- }
340
- return {
341
- cursor: serializeCursor(activeCursor),
342
- setupError,
343
- result
344
- };
345
- }
346
- function registerCodingSession(registry, options = {}) {
347
- const runner = options.cliRunner ?? defaultCliRunner;
348
- const defaultCwd = options.defaultWorkingDirectory ?? process.cwd();
349
- registry.define(`coder`, {
350
- description: `Runs a Claude Code / Codex CLI session and mirrors its normalized event stream into a durable store. Prompts arrive via message_received (type: "prompt") and are executed serially.`,
351
- creationSchema: creationArgsSchema,
352
- inboxSchemas: { prompt: promptMessageSchema },
353
- state: {
354
- sessionMeta: {
355
- schema: sessionMetaRowSchema,
356
- type: CODING_SESSION_META_COLLECTION_TYPE,
357
- primaryKey: `key`
358
- },
359
- cursorState: {
360
- schema: cursorStateRowSchema,
361
- type: CODING_SESSION_CURSOR_COLLECTION_TYPE,
362
- primaryKey: `key`
363
- },
364
- events: {
365
- schema: eventRowSchema,
366
- type: CODING_SESSION_EVENT_COLLECTION_TYPE,
367
- primaryKey: `key`
368
- }
369
- },
370
- async handler(ctx, _wake) {
371
- const existingMeta = ctx.db.collections.sessionMeta.get(`current`);
372
- if (!existingMeta) {
373
- const args = creationArgsSchema.parse(ctx.args);
374
- const cwd = args.cwd ?? defaultCwd;
375
- const electricSessionId = ctx.entityUrl.split(`/`).pop() ?? ctx.entityUrl;
376
- let resolvedNativeId = args.nativeSessionId;
377
- if (args.importFrom) {
378
- const result = await importLocalSession({
379
- source: {
380
- sessionId: args.importFrom.sessionId,
381
- agent: args.importFrom.agent
382
- },
383
- target: {
384
- agent: args.agent,
385
- cwd
386
- }
387
- });
388
- resolvedNativeId = result.sessionId;
389
- }
390
- const hasNative = resolvedNativeId !== void 0;
391
- ctx.db.actions.sessionMeta_insert({ row: {
392
- key: `current`,
393
- electricSessionId,
394
- ...hasNative ? { nativeSessionId: resolvedNativeId } : {},
395
- agent: args.agent,
396
- cwd,
397
- status: hasNative ? `idle` : `initializing`
398
- } });
399
- }
400
- if (!ctx.db.collections.cursorState.get(`current`)) ctx.db.actions.cursorState_insert({ row: {
401
- key: `current`,
402
- cursor: ``
403
- } });
404
- const metaRow = ctx.db.collections.sessionMeta.get(`current`);
405
- const cursorRow = ctx.db.collections.cursorState.get(`current`);
406
- if (!metaRow || !cursorRow) throw new Error(`[coding-session] expected sessionMeta and cursorState rows to exist after init`);
407
- if (metaRow.nativeSessionId && !cursorRow.cursor) {
408
- const mirrorCtx = {
409
- events: { get: (k) => ctx.db.collections.events.get(k) },
410
- actions: { events_insert: ctx.db.actions.events_insert }
411
- };
412
- try {
413
- const initial = await loadSession({
414
- sessionId: metaRow.nativeSessionId,
415
- agent: metaRow.agent
416
- });
417
- for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
418
- const serialized = serializeCursor(initial.cursor);
419
- ctx.db.actions.cursorState_update({
420
- key: `current`,
421
- updater: (d) => {
422
- d.cursor = JSON.stringify(serialized);
423
- }
424
- });
425
- } catch (e) {
426
- const message = e instanceof Error ? e.message : String(e);
427
- ctx.db.actions.sessionMeta_update({
428
- key: `current`,
429
- updater: (d) => {
430
- d.error = `initial mirror failed: ${message}`;
431
- }
432
- });
433
- }
434
- }
435
- const inboxRows = ctx.db.collections.inbox.toArray.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
436
- const lastKey = cursorRow.lastProcessedInboxKey ?? ``;
437
- const pending = inboxRows.filter((m) => m.key > lastKey);
438
- if (pending.length === 0) {
439
- if (metaRow.status === `running` || metaRow.status === `error`) ctx.db.actions.sessionMeta_update({
440
- key: `current`,
441
- updater: (d) => {
442
- d.status = `idle`;
443
- delete d.currentPromptInboxKey;
444
- delete d.error;
445
- }
446
- });
447
- return;
448
- }
449
- let runningMeta = metaRow;
450
- let runningCursor = cursorRow;
451
- for (const inboxMsg of pending) {
452
- const parsed = promptMessageSchema.safeParse(inboxMsg.payload);
453
- if (!parsed.success) {
454
- ctx.db.actions.cursorState_update({
455
- key: `current`,
456
- updater: (d) => {
457
- d.lastProcessedInboxKey = inboxMsg.key;
458
- }
459
- });
460
- runningCursor = {
461
- ...runningCursor,
462
- lastProcessedInboxKey: inboxMsg.key
463
- };
464
- continue;
465
- }
466
- const prompt = parsed.data.text;
467
- const existingTitle = ctx.tags.title;
468
- if (typeof existingTitle !== `string` || existingTitle.length === 0) ctx.setTag(`title`, prompt.slice(0, 80));
469
- ctx.db.actions.sessionMeta_update({
470
- key: `current`,
471
- updater: (d) => {
472
- d.status = `running`;
473
- d.currentPromptInboxKey = inboxMsg.key;
474
- delete d.error;
475
- }
476
- });
477
- const recordedRun = ctx.recordRun();
478
- const eventKeysBefore = new Set(ctx.db.collections.events.toArray.map((e) => e.key));
479
- try {
480
- const mirrorCtx = {
481
- events: { get: (k) => ctx.db.collections.events.get(k) },
482
- actions: { events_insert: ctx.db.actions.events_insert }
483
- };
484
- let nextCursorJson = runningCursor.cursor;
485
- if (!runningMeta.nativeSessionId) {
486
- const preDirectIds = runningMeta.agent === `claude` ? await listClaudeJsonlIdsByCwd(runningMeta.cwd) : new Set();
487
- const preDiscoveredIds = new Set((await discoverSessions(runningMeta.agent)).map((s) => s.sessionId));
488
- const cliResult = await runner.run({
489
- agent: runningMeta.agent,
490
- cwd: runningMeta.cwd,
491
- prompt
492
- });
493
- if (cliResult.exitCode !== 0) throw new Error(`[coding-session] ${runningMeta.agent} CLI exited ${cliResult.exitCode}. stderr=${cliResult.stderr.slice(0, 800) || `<empty>`} stdout=${cliResult.stdout.slice(0, 800) || `<empty>`}`);
494
- const foundId = await findNewSessionAfterRun(runningMeta.agent, runningMeta.cwd, preDirectIds, preDiscoveredIds);
495
- if (!foundId) throw new Error(`[coding-session] ${runningMeta.agent} CLI succeeded but no new session file was found`);
496
- ctx.db.actions.sessionMeta_update({
497
- key: `current`,
498
- updater: (d) => {
499
- d.nativeSessionId = foundId;
500
- }
501
- });
502
- runningMeta = {
503
- ...runningMeta,
504
- nativeSessionId: foundId
505
- };
506
- const initial = await loadSession({
507
- sessionId: foundId,
508
- agent: runningMeta.agent
509
- });
510
- for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
511
- nextCursorJson = JSON.stringify(serializeCursor(initial.cursor));
512
- } else {
513
- const serializedCursor = runningCursor.cursor ? JSON.parse(runningCursor.cursor) : null;
514
- const { cursor: nextSerialized, setupError, result: cliResult } = await runWithLiveMirror({
515
- agent: runningMeta.agent,
516
- nativeSessionId: runningMeta.nativeSessionId,
517
- serializedCursor,
518
- ctx: mirrorCtx,
519
- runWork: () => runner.run({
520
- agent: runningMeta.agent,
521
- sessionId: runningMeta.nativeSessionId,
522
- cwd: runningMeta.cwd,
523
- prompt
524
- })
525
- });
526
- if (setupError) throw setupError instanceof Error ? setupError : new Error(String(setupError));
527
- if (cliResult.exitCode !== 0) throw new Error(`[coding-session] ${runningMeta.agent} CLI exited ${cliResult.exitCode}. stderr=${cliResult.stderr.slice(0, 800) || `<empty>`} stdout=${cliResult.stdout.slice(0, 800) || `<empty>`}`);
528
- const persistedCursor = nextSerialized ?? serializedCursor;
529
- nextCursorJson = persistedCursor ? JSON.stringify(persistedCursor) : ``;
530
- }
531
- ctx.db.actions.cursorState_update({
532
- key: `current`,
533
- updater: (d) => {
534
- d.cursor = nextCursorJson;
535
- d.lastProcessedInboxKey = inboxMsg.key;
536
- }
537
- });
538
- runningCursor = {
539
- ...runningCursor,
540
- cursor: nextCursorJson,
541
- lastProcessedInboxKey: inboxMsg.key
542
- };
543
- for (const row of ctx.db.collections.events.toArray) {
544
- if (eventKeysBefore.has(row.key)) continue;
545
- if (row.type !== `assistant_message`) continue;
546
- const text = row.payload?.text;
547
- if (typeof text === `string` && text.length > 0) recordedRun.attachResponse(text);
548
- }
549
- recordedRun.end({ status: `completed` });
550
- } catch (e) {
551
- const message = e instanceof Error ? e.message : String(e);
552
- recordedRun.end({
553
- status: `failed`,
554
- finishReason: `error`
555
- });
556
- ctx.db.actions.sessionMeta_update({
557
- key: `current`,
558
- updater: (d) => {
559
- d.status = `error`;
560
- d.error = message;
561
- }
562
- });
563
- ctx.db.actions.cursorState_update({
564
- key: `current`,
565
- updater: (d) => {
566
- d.lastProcessedInboxKey = inboxMsg.key;
567
- }
568
- });
569
- throw e;
570
- }
571
- }
572
- ctx.db.actions.sessionMeta_update({
573
- key: `current`,
574
- updater: (d) => {
575
- d.status = `idle`;
576
- delete d.currentPromptInboxKey;
577
- delete d.error;
578
- }
579
- });
580
- }
581
- });
582
- }
583
-
584
76
  //#endregion
585
77
  //#region src/docs/embed.ts
586
78
  const EMBEDDING_DIMENSIONS = 128;
@@ -1385,11 +877,11 @@ const WORKER_TOOL_NAMES = [
1385
877
  `read`,
1386
878
  `write`,
1387
879
  `edit`,
1388
- `brave_search`,
880
+ `web_search`,
1389
881
  `fetch_url`,
1390
882
  `spawn_worker`
1391
883
  ];
1392
- function createSpawnWorkerTool(ctx) {
884
+ function createSpawnWorkerTool(ctx, modelConfig) {
1393
885
  return {
1394
886
  name: `spawn_worker`,
1395
887
  label: `Spawn Worker`,
@@ -1416,10 +908,16 @@ function createSpawnWorkerTool(ctx) {
1416
908
  details: { spawned: false }
1417
909
  };
1418
910
  const id = nanoid(10);
911
+ const workerModelArgs = modelConfig ? {
912
+ provider: modelConfig.provider,
913
+ model: modelConfig.model,
914
+ ...modelConfig.reasoningEffort && { reasoningEffort: modelConfig.reasoningEffort }
915
+ } : {};
1419
916
  try {
1420
917
  const handle = await ctx.spawn(`worker`, id, {
1421
918
  systemPrompt,
1422
- tools
919
+ tools,
920
+ ...workerModelArgs
1423
921
  }, {
1424
922
  initialMessage,
1425
923
  wake: {
@@ -1453,140 +951,137 @@ function createSpawnWorkerTool(ctx) {
1453
951
  }
1454
952
 
1455
953
  //#endregion
1456
- //#region src/tools/spawn-coder.ts
1457
- const CODER_AGENT_NAMES = [`claude`, `codex`];
1458
- function createSpawnCoderTool(ctx) {
954
+ //#region src/model-catalog.ts
955
+ const REASONING_EFFORT_VALUES = [
956
+ `auto`,
957
+ `minimal`,
958
+ `low`,
959
+ `medium`,
960
+ `high`
961
+ ];
962
+ const DEFAULT_ANTHROPIC_MODEL = `claude-sonnet-4-6`;
963
+ const DEFAULT_OPENAI_MODEL = `gpt-4.1`;
964
+ const DEFAULT_CODEX_MODEL = `gpt-5.4`;
965
+ function modelValue(provider, id) {
966
+ return `${provider}:${id}`;
967
+ }
968
+ function providerLabel(provider) {
969
+ if (provider === `anthropic`) return `Anthropic`;
970
+ if (provider === `openai-codex`) return `OpenAI Codex`;
971
+ return `OpenAI`;
972
+ }
973
+ function configuredProviders() {
974
+ return detectAvailableProviders();
975
+ }
976
+ function mockFallbackCatalog() {
977
+ const fallback = {
978
+ provider: `anthropic`,
979
+ id: DEFAULT_ANTHROPIC_MODEL,
980
+ label: `Anthropic ${DEFAULT_ANTHROPIC_MODEL}`,
981
+ value: modelValue(`anthropic`, DEFAULT_ANTHROPIC_MODEL),
982
+ reasoning: true
983
+ };
1459
984
  return {
1460
- name: `spawn_coder`,
1461
- label: `Spawn Coder`,
1462
- description: `Spawn a coding-session subagent (a coder) that drives a Claude Code or Codex CLI session in a working directory. Use when the user asks for code changes, file edits, debugging, or any task that benefits from a real coding agent with tool access. The coder is long-lived — its URL stays valid across many turns, so you can keep prompting it via prompt_coder without re-spawning. End your turn after spawning; you'll be woken when the coder finishes its first reply.`,
1463
- parameters: Type.Object({
1464
- prompt: Type.String({ description: `First user message sent to the coder. This is what kicks off the run — without it the coder will idle. Be concrete: describe the task, mention the files/paths involved, and what form of answer you want back.` }),
1465
- agent: Type.Optional(Type.Union(CODER_AGENT_NAMES.map((n) => Type.Literal(n)), { description: `Which coding agent to use. Defaults to "claude". Use "codex" only if the user explicitly asks for it.` })),
1466
- cwd: Type.Optional(Type.String({ description: `Working directory the coder runs in. Defaults to the runtime's cwd (the same directory Horton is running in). Set this when the user wants the coder to operate on a different repo.` }))
1467
- }),
1468
- execute: async (_toolCallId, params) => {
1469
- const { prompt, agent, cwd } = params;
1470
- if (typeof prompt !== `string` || prompt.length === 0) return {
1471
- content: [{
1472
- type: `text`,
1473
- text: `Error: prompt is required and must be a non-empty string.`
1474
- }],
1475
- details: { spawned: false }
1476
- };
1477
- const id = nanoid(10);
1478
- const spawnArgs = { agent: agent ?? `claude` };
1479
- if (cwd) spawnArgs.cwd = cwd;
1480
- try {
1481
- const handle = await ctx.spawn(`coder`, id, spawnArgs, {
1482
- initialMessage: { text: prompt },
1483
- wake: {
1484
- on: `runFinished`,
1485
- includeResponse: true
1486
- }
1487
- });
1488
- const coderUrl = handle.entityUrl;
1489
- return {
1490
- content: [{
1491
- type: `text`,
1492
- text: `Coder dispatched at ${coderUrl}. End your turn — when the coder finishes its current reply you'll be woken with the response. To send follow-up prompts to the same coder, call prompt_coder with this URL.`
1493
- }],
1494
- details: {
1495
- spawned: true,
1496
- coderUrl
1497
- }
1498
- };
1499
- } catch (err) {
1500
- serverLog.warn(`[spawn_coder tool] failed to spawn coder ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1501
- return {
1502
- content: [{
1503
- type: `text`,
1504
- text: `Error spawning coder: ${err instanceof Error ? err.message : `Unknown error`}`
1505
- }],
1506
- details: { spawned: false }
1507
- };
1508
- }
1509
- }
985
+ choices: [fallback],
986
+ defaultChoice: fallback
1510
987
  };
1511
988
  }
1512
- function createPromptCoderTool(ctx) {
989
+ async function fetchAvailableModelIds(provider) {
990
+ try {
991
+ const res = provider === `anthropic` ? await fetch(`https://api.anthropic.com/v1/models`, {
992
+ headers: {
993
+ "x-api-key": process.env.ANTHROPIC_API_KEY ?? ``,
994
+ "anthropic-version": `2023-06-01`
995
+ },
996
+ signal: AbortSignal.timeout(3e3)
997
+ }) : await fetch(`https://api.openai.com/v1/models`, {
998
+ headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ``}` },
999
+ signal: AbortSignal.timeout(3e3)
1000
+ });
1001
+ if (res.status === 401 || res.status === 403) return new Set();
1002
+ if (!res.ok) return null;
1003
+ const body = await res.json();
1004
+ const ids = new Set((body.data ?? []).map((model) => model.id).filter((id) => typeof id === `string`));
1005
+ return ids.size > 0 ? ids : null;
1006
+ } catch {
1007
+ return null;
1008
+ }
1009
+ }
1010
+ async function choicesForProvider(provider) {
1011
+ const knownModels = getModels(provider);
1012
+ if (provider === `openai-codex`) return knownModels.map((model) => ({
1013
+ provider,
1014
+ id: model.id,
1015
+ label: `${providerLabel(provider)} ${model.name}`,
1016
+ value: modelValue(provider, model.id),
1017
+ reasoning: model.reasoning
1018
+ }));
1019
+ const availableIds = await fetchAvailableModelIds(provider);
1020
+ const models = availableIds === null ? knownModels : knownModels.filter((model) => availableIds.has(model.id));
1021
+ return models.map((model) => ({
1022
+ provider,
1023
+ id: model.id,
1024
+ label: `${providerLabel(provider)} ${model.name}`,
1025
+ value: modelValue(provider, model.id),
1026
+ reasoning: model.reasoning
1027
+ }));
1028
+ }
1029
+ function withProviderPayloadDefaults(config, choice, reasoningEffort) {
1030
+ if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
1031
+ const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
1032
+ const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
1513
1033
  return {
1514
- name: `prompt_coder`,
1515
- label: `Prompt Coder`,
1516
- description: `Send a follow-up prompt to a coder you previously spawned. The prompt is queued on the coder's inbox and runs as the next CLI turn. End your turn after calling — you'll be woken when the coder's reply lands.`,
1517
- parameters: Type.Object({
1518
- coder_url: Type.String({ description: `Entity URL returned by spawn_coder, e.g. "/coder/abc123". Must be the URL of a coder you previously spawned in this conversation.` }),
1519
- prompt: Type.String({ description: `Follow-up message to send to the coder. Treat this like the next turn in a chat — reference earlier context the coder already saw rather than restating it.` })
1520
- }),
1521
- execute: async (_toolCallId, params) => {
1522
- const { coder_url, prompt } = params;
1523
- if (typeof coder_url !== `string` || !coder_url.startsWith(`/coder/`)) return {
1524
- content: [{
1525
- type: `text`,
1526
- text: `Error: coder_url must be a path like "/coder/<id>".`
1527
- }],
1528
- details: { sent: false }
1529
- };
1530
- if (typeof prompt !== `string` || prompt.length === 0) return {
1531
- content: [{
1532
- type: `text`,
1533
- text: `Error: prompt is required and must be a non-empty string.`
1534
- }],
1535
- details: { sent: false }
1034
+ ...config,
1035
+ onPayload: (payload) => {
1036
+ if (typeof payload !== `object` || payload === null) return void 0;
1037
+ const body = payload;
1038
+ const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1039
+ return {
1040
+ ...body,
1041
+ reasoning: {
1042
+ ...existingReasoning,
1043
+ effort
1044
+ }
1536
1045
  };
1537
- try {
1538
- ctx.send(coder_url, { text: prompt });
1539
- return {
1540
- content: [{
1541
- type: `text`,
1542
- text: `Prompt queued for ${coder_url}. End your turn — you'll be woken when the coder's reply lands.`
1543
- }],
1544
- details: {
1545
- sent: true,
1546
- coderUrl: coder_url
1547
- }
1548
- };
1549
- } catch (err) {
1550
- serverLog.warn(`[prompt_coder tool] failed to send to ${coder_url}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1551
- return {
1552
- content: [{
1553
- type: `text`,
1554
- text: `Error sending prompt to coder: ${err instanceof Error ? err.message : `Unknown error`}`
1555
- }],
1556
- details: { sent: false }
1557
- };
1558
- }
1559
1046
  }
1560
1047
  };
1561
1048
  }
1049
+ function parseReasoningEffort(value) {
1050
+ return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
1051
+ }
1052
+ async function createBuiltinModelCatalog(options = {}) {
1053
+ const providers = configuredProviders();
1054
+ if (providers.length === 0 && options.allowMockFallback) return mockFallbackCatalog();
1055
+ const choices = (await Promise.all(providers.map((provider) => choicesForProvider(provider)))).flat();
1056
+ if (choices.length === 0) return options.allowMockFallback ? mockFallbackCatalog() : null;
1057
+ const defaultChoice = choices.find((choice) => choice.provider === `anthropic` && choice.id === DEFAULT_ANTHROPIC_MODEL) ?? choices.find((choice) => choice.provider === `openai` && choice.id === DEFAULT_OPENAI_MODEL) ?? choices.find((choice) => choice.provider === `openai-codex` && choice.id === DEFAULT_CODEX_MODEL) ?? choices[0];
1058
+ return {
1059
+ choices,
1060
+ defaultChoice
1061
+ };
1062
+ }
1063
+ function resolveBuiltinModelConfig(catalog, args) {
1064
+ const modelArg = args.model;
1065
+ const providerArg = args.provider;
1066
+ const reasoningEffort = parseReasoningEffort(args.reasoningEffort);
1067
+ const selected = typeof modelArg === `string` ? catalog.choices.find((choice$1) => choice$1.value === modelArg || choice$1.id === modelArg && choice$1.provider === providerArg) : void 0;
1068
+ const choice = selected ?? catalog.defaultChoice;
1069
+ const config = {
1070
+ provider: choice.provider,
1071
+ model: choice.id,
1072
+ ...reasoningEffort && { reasoningEffort },
1073
+ ...choice.provider === `openai-codex` && { getApiKey: () => readCodexAccessToken() }
1074
+ };
1075
+ return withProviderPayloadDefaults(config, choice, reasoningEffort);
1076
+ }
1077
+ function modelChoiceValues(catalog) {
1078
+ return catalog.choices.map((choice) => choice.value);
1079
+ }
1562
1080
 
1563
1081
  //#endregion
1564
1082
  //#region src/agents/horton.ts
1565
- const TITLE_MODEL = `claude-haiku-4-5-20251001`;
1566
- const HORTON_MODEL = `claude-sonnet-4-6`;
1567
- let anthropic = null;
1568
- function getClient() {
1569
- if (!anthropic) anthropic = new Anthropic();
1570
- return anthropic;
1571
- }
1572
- async function defaultHaikuCall(prompt) {
1573
- const client = getClient();
1574
- const res = await client.messages.create({
1575
- model: TITLE_MODEL,
1576
- max_tokens: 64,
1577
- messages: [{
1578
- role: `user`,
1579
- content: prompt
1580
- }]
1581
- });
1582
- const block = res.content[0];
1583
- return block?.type === `text` ? block.text : ``;
1584
- }
1585
- const TITLE_PROMPT = (userMessage) => `Summarize the following user request in 3-5 words for use as a chat session title.
1586
- Respond with only the title, no quotes, no punctuation, no preamble.
1587
-
1588
- User request:
1589
- ${userMessage}`;
1083
+ const TITLE_SYSTEM_PROMPT = "You generate concise chat session titles in 3-5 words. Respond with only the title, no quotes, no punctuation, no preamble.";
1084
+ const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1590
1085
  const TITLE_STOP_WORDS = new Set([
1591
1086
  `a`,
1592
1087
  `an`,
@@ -1654,19 +1149,34 @@ function buildFallbackTitle(userMessage) {
1654
1149
  const selected = informativeWords.length >= 2 ? informativeWords.slice(0, 5) : backupWords;
1655
1150
  return selected.join(` `).slice(0, 80).trim() || `Untitled Chat`;
1656
1151
  }
1657
- async function generateTitle(userMessage, llmCall = defaultHaikuCall) {
1152
+ function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
1153
+ return (prompt) => completeWithLowCostModel({
1154
+ catalog,
1155
+ modelConfig,
1156
+ log: (message) => serverLog.info(message),
1157
+ logPrefix,
1158
+ purpose: `title generation`,
1159
+ systemPrompt: TITLE_SYSTEM_PROMPT,
1160
+ prompt,
1161
+ maxTokens: 64
1162
+ });
1163
+ }
1164
+ async function generateTitle(userMessage, llmCall, onFallback) {
1658
1165
  try {
1659
- const raw = await llmCall(TITLE_PROMPT(userMessage));
1166
+ const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
1660
1167
  const title = raw.trim();
1661
- return title.length > 0 ? title : buildFallbackTitle(userMessage);
1662
- } catch {
1168
+ if (title.length > 0) return title;
1169
+ onFallback?.(`empty LLM title response`);
1170
+ return buildFallbackTitle(userMessage);
1171
+ } catch (err) {
1172
+ onFallback?.(err instanceof Error ? err.message : String(err));
1663
1173
  return buildFallbackTitle(userMessage);
1664
1174
  }
1665
1175
  }
1666
1176
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1667
1177
  const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
1668
1178
  const skillsTools = opts.hasSkills ? `\n- use_skill: load a skill (knowledge, instructions, or a tutorial) into your context to help with the user's request\n- remove_skill: unload a skill from context when you're done with it` : ``;
1669
- const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents, Durable Agents, or this framework, ALWAYS use search_durable_agents_docs FIRST. Do not use brave_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
1179
+ const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents, Durable Agents, or this framework, ALWAYS use search_durable_agents_docs FIRST. Do not use web_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
1670
1180
  const skillsGuidance = opts.hasSkills ? `\n# Skills\nYou have access to skills — specialized knowledge and guided workflows you can load on demand. Your context includes a skills catalog listing what's available. When the user's request matches a skill's description or keywords, load it with use_skill.
1671
1181
 
1672
1182
  Some skills are user-invocable — the user can trigger them with a slash command like \`/quickstart\`. When you see a message starting with \`/\` followed by a skill name, load that skill immediately with use_skill. Pass any text after the skill name as args.
@@ -1702,7 +1212,9 @@ Don't force onboarding. If someone just wants to chat or code, let them. When in
1702
1212
  - ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`}
1703
1213
  - The Electric Agents docs site is at ${opts.docsUrl}
1704
1214
  - The docs site covers: Usage (entity definition, handlers, tools, state, spawning, coordination, waking, shared state, client integration, app setup), Reference (handler context, entity definitions, configurations, tools, state proxies, wake events, registries), Entities (Horton, Worker), and Patterns (Manager-Worker, Pipeline, Map-Reduce, Dispatcher, Blackboard, Reactive Observers).
1705
- - For general coding questions unrelated to Electric Agents, use brave_search or your own knowledge.` : ``;
1215
+ - For general coding questions unrelated to Electric Agents, use web_search or your own knowledge.` : ``;
1216
+ const modelGuidance = opts.modelProvider && opts.modelId ? `\n# Runtime model
1217
+ You are currently running via provider "${opts.modelProvider}" with model "${opts.modelId}". If the user asks what model or provider you are using, answer with these exact runtime values. Do not infer your model identity from training data or from the name of another coding tool.` : ``;
1706
1218
  return `You are Horton, a friendly and capable assistant. You can chat, research the web, read and edit code, run shell commands, and dispatch subagents (workers) for isolated subtasks. Be warm and engaging in conversation; be precise and concrete when working with code.
1707
1219
 
1708
1220
  # Greetings
@@ -1713,18 +1225,16 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1713
1225
  - read: read a file
1714
1226
  - write: create or overwrite a file
1715
1227
  - edit: targeted string replacement in an existing file (you must read the file first)
1716
- - brave_search: search the web
1228
+ - web_search: search the web
1717
1229
  - fetch_url: fetch and convert a URL to markdown
1718
1230
  - spawn_worker: dispatch a subagent for an isolated task
1719
- - spawn_coder: spawn a long-lived coding agent (Claude Code or Codex CLI) for code changes, file edits, debugging
1720
- - prompt_coder: send a follow-up prompt to a coder you previously spawned
1721
1231
  ${docsTools}${skillsTools}
1722
1232
 
1723
1233
  # Working with files
1724
1234
  - Prefer edit over write when modifying existing files.
1725
1235
  - You must read a file before you can edit it.
1726
1236
  - Use absolute paths or paths relative to the current working directory.
1727
- ${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
1237
+ ${modelGuidance}${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
1728
1238
 
1729
1239
  # Risky actions
1730
1240
  Pause and confirm with the user before:
@@ -1745,13 +1255,6 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
1745
1255
 
1746
1256
  After spawning, end your turn (optionally with a brief "I've dispatched a worker for X; I'll respond when it finishes"). When the worker finishes, you'll receive a message describing which worker completed and what it returned. Multiple workers may finish at different times — check the message for the worker URL to know which one you're hearing about.
1747
1257
 
1748
- # When to spawn a coder
1749
- Spawn a coder when the user asks for code changes, file edits, debugging, or any task that benefits from a real coding agent with full tool access (bash, file edits, etc.). A coder runs Claude Code or Codex CLI under the hood.
1750
-
1751
- Unlike a worker, a coder is **long-lived**: its URL stays valid across many turns. Spawn once with spawn_coder, then keep prompting it via prompt_coder for follow-ups — don't spawn a new coder for each turn. Treat the coder URL like a chat handle.
1752
-
1753
- After calling spawn_coder or prompt_coder, end your turn. When the coder's reply lands, you'll be woken with the response in the wake message — relay it (or a summary) back to the user, and call prompt_coder again if there's a follow-up.
1754
-
1755
1258
  # Reporting
1756
1259
  Report outcomes faithfully. If a command failed, say so with the relevant output. If you didn't run a verification step, say that rather than implying you did. Don't hedge confirmed results with unnecessary disclaimers.
1757
1260
 
@@ -1765,34 +1268,82 @@ function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
1765
1268
  createWriteTool(workingDirectory, readSet),
1766
1269
  createEditTool(workingDirectory, readSet),
1767
1270
  braveSearchTool,
1768
- fetchUrlTool,
1769
- createSpawnWorkerTool(ctx),
1770
- createSpawnCoderTool(ctx),
1771
- createPromptCoderTool(ctx),
1271
+ ...opts.modelCatalog && opts.modelConfig ? [createFetchUrlTool({
1272
+ catalog: opts.modelCatalog,
1273
+ modelConfig: opts.modelConfig,
1274
+ log: (message) => serverLog.info(message),
1275
+ logPrefix: opts.logPrefix ?? `[horton]`
1276
+ })] : [fetchUrlTool],
1277
+ createSpawnWorkerTool(ctx, opts.modelConfig),
1772
1278
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1773
1279
  ];
1774
1280
  }
1775
- function extractFirstUserMessage(events) {
1776
- for (const event of events) {
1777
- if (event.type !== `message_received`) continue;
1778
- const value = event.value;
1779
- if (!value || value.from === `system`) continue;
1780
- const payload = value.payload;
1781
- if (typeof payload === `string`) return payload;
1782
- if (payload != null) return JSON.stringify(payload);
1281
+ function payloadToTitleText(payload) {
1282
+ if (typeof payload === `string`) return payload;
1283
+ if (payload == null) return ``;
1284
+ if (typeof payload === `object`) {
1285
+ const text = payload.text;
1286
+ return typeof text === `string` ? text : JSON.stringify(payload);
1287
+ }
1288
+ return String(payload);
1289
+ }
1290
+ async function extractFirstUserMessage(ctx) {
1291
+ const firstMessage = await queryOnce((q) => q.from({ inbox: ctx.db.collections.inbox }).where(({ inbox }) => not(eq(inbox.from, `system`))).orderBy(({ inbox }) => inbox._seq, `asc`).findOne());
1292
+ if (!firstMessage) return null;
1293
+ const text = payloadToTitleText(firstMessage.payload);
1294
+ return text.length > 0 ? text : null;
1295
+ }
1296
+ function readAgentsMd(workingDirectory) {
1297
+ const agentsMdPath = path.join(workingDirectory, `AGENTS.md`);
1298
+ try {
1299
+ if (!fs.existsSync(agentsMdPath) || !fs.statSync(agentsMdPath).isFile()) return null;
1300
+ const content = fs.readFileSync(agentsMdPath, `utf8`);
1301
+ return [
1302
+ `<context_file kind="instructions" path="${agentsMdPath}">`,
1303
+ content,
1304
+ `</context_file>`
1305
+ ].join(`\n`);
1306
+ } catch {
1307
+ return null;
1783
1308
  }
1784
- return null;
1785
1309
  }
1786
1310
  function createAssistantHandler(options) {
1787
- const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, docsUrl } = options;
1311
+ const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1788
1312
  const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1789
1313
  return async function assistantHandler(ctx, wake) {
1790
1314
  const readSet = new Set();
1315
+ const effectiveCwd = typeof ctx.args.workingDirectory === `string` && ctx.args.workingDirectory.trim().length > 0 ? ctx.args.workingDirectory : workingDirectory;
1316
+ const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
1317
+ const agentsMd = readAgentsMd(effectiveCwd);
1791
1318
  const tools = [
1792
1319
  ...ctx.electricTools,
1793
- ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool }),
1794
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : []
1320
+ ...createHortonTools(effectiveCwd, ctx, readSet, {
1321
+ docsSearchTool,
1322
+ modelConfig,
1323
+ modelCatalog,
1324
+ logPrefix: `[horton ${ctx.entityUrl}]`
1325
+ }),
1326
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
1327
+ ...mcp.tools()
1795
1328
  ];
1329
+ const titlePromise = ctx.firstWake && !ctx.tags.title ? (async () => {
1330
+ const firstUserMessage = await extractFirstUserMessage(ctx);
1331
+ if (!firstUserMessage) return;
1332
+ let title = null;
1333
+ try {
1334
+ const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
1335
+ serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
1336
+ });
1337
+ if (result.length > 0) title = result;
1338
+ } catch (err) {
1339
+ serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1340
+ }
1341
+ if (title !== null) try {
1342
+ await ctx.setTag(`title`, title);
1343
+ } catch (err) {
1344
+ serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1345
+ }
1346
+ })() : Promise.resolve();
1796
1347
  if (docsSupport) ctx.useContext({
1797
1348
  sourceBudget: 1e5,
1798
1349
  sources: {
@@ -1810,6 +1361,11 @@ function createAssistantHandler(options) {
1810
1361
  content: () => ctx.timelineMessages(),
1811
1362
  cache: `volatile`
1812
1363
  },
1364
+ ...agentsMd ? { agents_md: {
1365
+ content: () => agentsMd,
1366
+ max: 2e4,
1367
+ cache: `stable`
1368
+ } } : {},
1813
1369
  ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1814
1370
  content: () => skillsRegistry.renderCatalog(2e3),
1815
1371
  max: 2e3,
@@ -1828,41 +1384,46 @@ function createAssistantHandler(options) {
1828
1384
  conversation: {
1829
1385
  content: () => ctx.timelineMessages(),
1830
1386
  cache: `volatile`
1387
+ },
1388
+ ...agentsMd ? { agents_md: {
1389
+ content: () => agentsMd,
1390
+ max: 2e4,
1391
+ cache: `stable`
1392
+ } } : {}
1393
+ }
1394
+ });
1395
+ else if (agentsMd) ctx.useContext({
1396
+ sourceBudget: 1e5,
1397
+ sources: {
1398
+ conversation: {
1399
+ content: () => ctx.timelineMessages(),
1400
+ cache: `volatile`
1401
+ },
1402
+ agents_md: {
1403
+ content: () => agentsMd,
1404
+ max: 2e4,
1405
+ cache: `stable`
1831
1406
  }
1832
1407
  }
1833
1408
  });
1834
1409
  ctx.useAgent({
1835
- systemPrompt: buildHortonSystemPrompt(workingDirectory, {
1410
+ systemPrompt: buildHortonSystemPrompt(effectiveCwd, {
1836
1411
  hasDocsSupport: Boolean(docsSupport),
1837
1412
  hasSkills,
1838
- docsUrl
1413
+ docsUrl,
1414
+ modelProvider: modelConfig.provider,
1415
+ modelId: String(modelConfig.model)
1839
1416
  }),
1840
- model: HORTON_MODEL,
1417
+ ...modelConfig,
1841
1418
  tools,
1842
1419
  ...streamFn && { streamFn }
1843
1420
  });
1844
1421
  await ctx.agent.run();
1845
- if (ctx.firstWake && !ctx.tags.title) {
1846
- const firstUserMessage = extractFirstUserMessage(ctx.events);
1847
- if (firstUserMessage) {
1848
- let title = null;
1849
- try {
1850
- const result = await generateTitle(firstUserMessage);
1851
- if (result.length > 0) title = result;
1852
- } catch (err) {
1853
- serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1854
- }
1855
- if (title !== null) try {
1856
- await ctx.setTag(`title`, title);
1857
- } catch (err) {
1858
- serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1859
- }
1860
- }
1861
- }
1422
+ await titlePromise;
1862
1423
  };
1863
1424
  }
1864
1425
  function registerHorton(registry, options) {
1865
- const { workingDirectory, streamFn, skillsRegistry = null } = options;
1426
+ const { workingDirectory, streamFn, skillsRegistry = null, modelCatalog } = options;
1866
1427
  const docsUrl = options.docsUrl ?? process.env.HORTON_DOCS_URL;
1867
1428
  if (process.env.BRAVE_SEARCH_API_KEY) serverLog.info(`[horton] Web search: using Brave Search API`);
1868
1429
  else serverLog.warn(`[horton] BRAVE_SEARCH_API_KEY not set — web search will fall back to Anthropic built-in search (uses your ANTHROPIC_API_KEY)`);
@@ -1877,10 +1438,17 @@ function registerHorton(registry, options) {
1877
1438
  docsSupport,
1878
1439
  docsSearchTool,
1879
1440
  skillsRegistry,
1441
+ modelCatalog,
1880
1442
  docsUrl
1881
1443
  });
1444
+ const hortonCreationSchema = z.object({
1445
+ model: z.enum(modelChoiceValues(modelCatalog)).default(modelCatalog.defaultChoice.value),
1446
+ reasoningEffort: z.enum(REASONING_EFFORT_VALUES).default(`auto`).describe(`Reasoning effort for compatible reasoning models. Auto uses a safe provider default.`),
1447
+ workingDirectory: z.string().optional().describe(`Working directory for file operations. Defaults to the server's configured cwd.`)
1448
+ });
1882
1449
  registry.define(`horton`, {
1883
1450
  description: `Friendly capable assistant — chat, code, research, dispatch`,
1451
+ creationSchema: hortonCreationSchema,
1884
1452
  handler: assistantHandler
1885
1453
  });
1886
1454
  const typeNames = [`horton`];
@@ -1925,6 +1493,9 @@ function parseWorkerArgs(value) {
1925
1493
  };
1926
1494
  }
1927
1495
  if (tools.length === 0 && !args.sharedDb) throw new Error(`[worker] must provide tools and/or sharedDb`);
1496
+ if (typeof value.model === `string`) args.model = value.model;
1497
+ if (typeof value.provider === `string`) args.provider = value.provider;
1498
+ if (typeof value.reasoningEffort === `string` && REASONING_EFFORT_VALUES.includes(value.reasoningEffort)) args.reasoningEffort = value.reasoningEffort;
1928
1499
  return args;
1929
1500
  }
1930
1501
  function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
@@ -1942,7 +1513,7 @@ function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
1942
1513
  case `edit`:
1943
1514
  out.push(createEditTool(workingDirectory, readSet));
1944
1515
  break;
1945
- case `brave_search`:
1516
+ case `web_search`:
1946
1517
  out.push(braveSearchTool);
1947
1518
  break;
1948
1519
  case `fetch_url`:
@@ -2051,13 +1622,14 @@ function buildSharedStateTools(shared, schema, mode) {
2051
1622
  return tools;
2052
1623
  }
2053
1624
  function registerWorker(registry, options) {
2054
- const { workingDirectory, streamFn } = options;
1625
+ const { workingDirectory, streamFn, modelCatalog } = options;
2055
1626
  registry.define(`worker`, {
2056
1627
  description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
2057
1628
  async handler(ctx) {
2058
1629
  const args = parseWorkerArgs(ctx.args);
2059
1630
  const readSet = new Set();
2060
1631
  const builtinTools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
1632
+ const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
2061
1633
  const sharedStateTools = [];
2062
1634
  if (args.sharedDb) {
2063
1635
  const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
@@ -2065,7 +1637,7 @@ function registerWorker(registry, options) {
2065
1637
  }
2066
1638
  ctx.useAgent({
2067
1639
  systemPrompt: `${args.systemPrompt}${WORKER_PROMPT_FOOTER}`,
2068
- model: HORTON_MODEL,
1640
+ ...modelConfig,
2069
1641
  tools: [...builtinTools, ...sharedStateTools],
2070
1642
  ...streamFn && { streamFn }
2071
1643
  });
@@ -2155,7 +1727,6 @@ function stripQuotes(value) {
2155
1727
 
2156
1728
  //#endregion
2157
1729
  //#region src/skills/extract-meta.ts
2158
- const EXTRACT_MODEL = `claude-haiku-4-5-20251001`;
2159
1730
  const DEFAULT_MAX = 1e4;
2160
1731
  async function extractSkillMeta(name, content) {
2161
1732
  const preamble = parsePreamble(content);
@@ -2168,7 +1739,7 @@ async function extractSkillMeta(name, content) {
2168
1739
  ...preamble.userInvocable && { userInvocable: true },
2169
1740
  max: preamble.max ?? DEFAULT_MAX
2170
1741
  };
2171
- if (process.env.ANTHROPIC_API_KEY) try {
1742
+ try {
2172
1743
  return await llmExtract(name, content, preamble);
2173
1744
  } catch (err) {
2174
1745
  serverLog.warn(`[skills] LLM metadata extraction failed for "${name}": ${err instanceof Error ? err.message : String(err)}`);
@@ -2181,7 +1752,6 @@ async function extractSkillMeta(name, content) {
2181
1752
  };
2182
1753
  }
2183
1754
  async function llmExtract(name, content, partial) {
2184
- const client = new Anthropic();
2185
1755
  const truncated = content.slice(0, 8e3);
2186
1756
  const prompt = `Analyze this skill document and extract metadata. The skill is named "${name}".
2187
1757
 
@@ -2195,15 +1765,14 @@ Return ONLY a JSON object with these fields:
2195
1765
  - "keywords": array of 3-8 relevant keywords
2196
1766
 
2197
1767
  Return raw JSON, no markdown fences.`;
2198
- const res = await client.messages.create({
2199
- model: EXTRACT_MODEL,
2200
- max_tokens: 256,
2201
- messages: [{
2202
- role: `user`,
2203
- content: prompt
2204
- }]
1768
+ const text = await completeWithLowCostModel({
1769
+ purpose: `skill metadata extraction`,
1770
+ systemPrompt: `Extract metadata from skill documents. Return only valid JSON that matches the requested schema.`,
1771
+ prompt,
1772
+ maxTokens: 256,
1773
+ log: (message) => serverLog.info(message),
1774
+ logPrefix: `[skills]`
2205
1775
  });
2206
- const text = res.content[0]?.type === `text` ? res.content[0].text : ``;
2207
1776
  const parsed = JSON.parse(text);
2208
1777
  return {
2209
1778
  description: partial.description ?? parsed.description ?? humanize(name),
@@ -2332,11 +1901,11 @@ function truncate(str, max) {
2332
1901
 
2333
1902
  //#endregion
2334
1903
  //#region src/bootstrap.ts
2335
- const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler`;
2336
1904
  async function createBuiltinAgentHandler(options) {
2337
- const { agentServerUrl, serveEndpoint = `${agentServerUrl}${DEFAULT_BUILTIN_AGENT_HANDLER_PATH}`, workingDirectory, streamFn, createElectricTools } = options;
2338
- if (!streamFn && !process.env.ANTHROPIC_API_KEY) {
2339
- serverLog.warn(`[builtin-agents] ANTHROPIC_API_KEY not set — skipping built-in agent registration`);
1905
+ const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, createElectricTools, publicUrl, runtimeName, serverHeaders, defaultDispatchPolicyForType } = options;
1906
+ const modelCatalog = await createBuiltinModelCatalog({ allowMockFallback: Boolean(streamFn) });
1907
+ if (!modelCatalog) {
1908
+ serverLog.warn(`[builtin-agents] no supported model provider API key found — set ANTHROPIC_API_KEY or OPENAI_API_KEY`);
2340
1909
  return null;
2341
1910
  }
2342
1911
  const cwd = workingDirectory ?? process.cwd();
@@ -2357,22 +1926,26 @@ async function createBuiltinAgentHandler(options) {
2357
1926
  const typeNames = registerHorton(registry, {
2358
1927
  workingDirectory: cwd,
2359
1928
  streamFn,
2360
- skillsRegistry
1929
+ skillsRegistry,
1930
+ modelCatalog
2361
1931
  });
2362
1932
  registerWorker(registry, {
2363
1933
  workingDirectory: cwd,
2364
- streamFn
1934
+ streamFn,
1935
+ modelCatalog
2365
1936
  });
2366
1937
  typeNames.push(`worker`);
2367
- registerCodingSession(registry, { defaultWorkingDirectory: cwd });
2368
- typeNames.push(`coder`);
2369
1938
  const runtime = createRuntimeHandler({
2370
1939
  baseUrl: agentServerUrl,
2371
1940
  serveEndpoint,
2372
1941
  registry,
2373
1942
  subscriptionPathForType: (name) => `/${name}/*/main`,
1943
+ defaultDispatchPolicyForType,
1944
+ serverHeaders,
2374
1945
  idleTimeout: 5e3,
2375
- createElectricTools
1946
+ createElectricTools,
1947
+ publicUrl,
1948
+ name: runtimeName ?? `builtin-agents`
2376
1949
  });
2377
1950
  return {
2378
1951
  handler: runtime.onEnter,
@@ -2390,103 +1963,226 @@ async function registerBuiltinAgentTypes(bootstrap) {
2390
1963
  //#endregion
2391
1964
  //#region src/server.ts
2392
1965
  var BuiltinAgentsServer = class {
2393
- server = null;
2394
1966
  bootstrap = null;
2395
- _url = null;
2396
- publicBaseUrl = null;
1967
+ _mcpRegistry = null;
1968
+ mcpWatcherCloser = null;
1969
+ mcpToolProviderName = null;
1970
+ mcpApplyInFlight = new Set();
1971
+ mcpStopping = false;
1972
+ pullWakeRunner = null;
2397
1973
  options;
2398
1974
  constructor(options) {
2399
1975
  this.options = options;
2400
1976
  }
2401
- get url() {
2402
- if (!this._url) throw new Error(`Builtin agents server not started`);
2403
- return this._url;
2404
- }
2405
- get registeredBaseUrl() {
2406
- if (!this.publicBaseUrl) throw new Error(`Builtin agents server not started`);
2407
- return this.publicBaseUrl;
1977
+ /** Embedded MCP registry. `null` until `start()` has run. */
1978
+ get mcpRegistry() {
1979
+ return this._mcpRegistry;
2408
1980
  }
2409
1981
  async start() {
2410
- if (this.server) throw new Error(`Builtin agents server already started`);
2411
- return new Promise((resolve, reject) => {
2412
- this.server = createServer((req, res) => {
2413
- this.handleRequest(req, res).catch((error) => {
2414
- serverLog.error(`[builtin-agents] unhandled request error`, error);
2415
- if (!res.headersSent) {
2416
- res.writeHead(500, { "content-type": `application/json` });
2417
- res.end(JSON.stringify({ error: `Internal server error` }));
2418
- }
2419
- });
1982
+ if (this.bootstrap || this.pullWakeRunner) throw new Error(`Builtin agents runtime already started`);
1983
+ const pullWake = this.options.pullWake;
1984
+ if (!pullWake?.runnerId) throw new Error(`Builtin agents require a pull-wake runner id`);
1985
+ try {
1986
+ const publicUrl = this.options.mcpOAuthRedirectBase ?? this.options.agentServerUrl;
1987
+ const mcpRegistry = createRegistry({
1988
+ publicUrl,
1989
+ openAuthorizeUrl: this.options.openAuthorizeUrl
2420
1990
  });
2421
- this.server.on(`error`, reject);
2422
- const host = this.options.host ?? `127.0.0.1`;
2423
- this.server.listen(this.options.port, host, async () => {
1991
+ this._mcpRegistry = mcpRegistry;
1992
+ const mcpConfigPath = this.options.loadProjectMcpConfig ? path.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
1993
+ const extras = this.options.extraMcpServers ?? [];
1994
+ const wirePersistence = async (cfg) => {
1995
+ const servers = [];
1996
+ for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
1997
+ const persist = await keychainPersistence({ server: s.name });
1998
+ servers.push({
1999
+ ...s,
2000
+ auth: {
2001
+ ...s.auth,
2002
+ ...persist
2003
+ }
2004
+ });
2005
+ } else servers.push(s);
2006
+ return {
2007
+ ...cfg,
2008
+ servers
2009
+ };
2010
+ };
2011
+ const merge = (jsonCfg) => {
2012
+ const jsonServers = jsonCfg?.servers ?? [];
2013
+ const jsonNames = new Set(jsonServers.map((s) => s.name));
2014
+ const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
2015
+ return {
2016
+ servers: [...filteredExtras, ...jsonServers],
2017
+ raw: jsonCfg?.raw
2018
+ };
2019
+ };
2020
+ const onConfigError = this.options.onConfigError;
2021
+ const runApply = async (jsonCfg) => {
2022
+ if (this.mcpStopping) return;
2023
+ try {
2024
+ const wired = await wirePersistence(merge(jsonCfg));
2025
+ if (this.mcpStopping) return;
2026
+ await mcpRegistry.applyConfig(wired);
2027
+ } catch (e) {
2028
+ serverLog.error(`[mcp] applyConfig:`, e);
2029
+ try {
2030
+ onConfigError?.(e);
2031
+ } catch (cbErr) {
2032
+ serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
2033
+ }
2034
+ }
2035
+ };
2036
+ const applyMerged = (jsonCfg) => {
2037
+ const p = runApply(jsonCfg);
2038
+ this.mcpApplyInFlight.add(p);
2039
+ p.finally(() => this.mcpApplyInFlight.delete(p));
2040
+ return p;
2041
+ };
2042
+ if (mcpConfigPath) {
2043
+ try {
2044
+ const cfg = await loadConfig(mcpConfigPath, process.env);
2045
+ applyMerged(cfg);
2046
+ } catch (err) {
2047
+ if (err.code !== `ENOENT`) throw err;
2048
+ if (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
2049
+ else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
2050
+ applyMerged(null);
2051
+ }
2424
2052
  try {
2425
- const addr = this.server.address();
2426
- if (typeof addr === `string`) this._url = addr;
2427
- else if (addr) {
2428
- const resolvedHost = host === `0.0.0.0` ? `127.0.0.1` : host;
2429
- this._url = `http://${resolvedHost}:${addr.port}`;
2430
- } else throw new Error(`Could not determine builtin agents server address`);
2431
- this.publicBaseUrl = this.options.baseUrl ?? this._url;
2432
- const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
2433
- const serveEndpoint = new URL(webhookPath, this.publicBaseUrl.endsWith(`/`) ? this.publicBaseUrl : `${this.publicBaseUrl}/`).toString();
2434
- this.bootstrap = await createBuiltinAgentHandler({
2435
- agentServerUrl: this.options.agentServerUrl,
2436
- serveEndpoint,
2437
- workingDirectory: this.options.workingDirectory,
2438
- streamFn: this.options.mockStreamFn,
2439
- createElectricTools: this.options.createElectricTools
2053
+ this.mcpWatcherCloser = await watchConfig(mcpConfigPath, {
2054
+ onChange: (cfg) => void applyMerged(cfg),
2055
+ onError: (e) => serverLog.error(`[mcp] config error:`, e)
2440
2056
  });
2441
- if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY must be set before starting builtin agents`);
2442
- await registerBuiltinAgentTypes(this.bootstrap);
2443
- serverLog.info(`[builtin-agents] webhook handler listening at ${serveEndpoint}`);
2444
- resolve(this._url);
2445
- } catch (error) {
2446
- await this.stop().catch(() => {});
2447
- reject(error);
2057
+ } catch (e) {
2058
+ serverLog.error(`[mcp] config watcher failed to start:`, e);
2059
+ }
2060
+ } else {
2061
+ if (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
2062
+ applyMerged(null);
2063
+ }
2064
+ this.mcpToolProviderName = `mcp`;
2065
+ registerToolProvider({
2066
+ name: `mcp`,
2067
+ tools: () => {
2068
+ const tools = [];
2069
+ for (const entry of mcpRegistry.list()) {
2070
+ if (entry.status !== `ready`) continue;
2071
+ const live = mcpRegistry.get(entry.name);
2072
+ if (!live?.transport) continue;
2073
+ for (const t of entry.tools) tools.push(bridgeMcpTool({
2074
+ server: entry.name,
2075
+ tool: t,
2076
+ client: live.transport.client,
2077
+ timeoutMs: live.config.timeoutMs
2078
+ }));
2079
+ const caps = live.transport.client.getServerCapabilities?.();
2080
+ if (caps?.resources) tools.push(...buildResourceTools({
2081
+ server: entry.name,
2082
+ client: live.transport.client,
2083
+ timeoutMs: live.config.timeoutMs
2084
+ }));
2085
+ if (caps?.prompts) tools.push(...buildPromptTools({
2086
+ server: entry.name,
2087
+ client: live.transport.client,
2088
+ timeoutMs: live.config.timeoutMs
2089
+ }));
2090
+ }
2091
+ return tools;
2448
2092
  }
2449
2093
  });
2450
- });
2094
+ this.bootstrap = await createBuiltinAgentHandler({
2095
+ agentServerUrl: this.options.agentServerUrl,
2096
+ workingDirectory: this.options.workingDirectory,
2097
+ streamFn: this.options.mockStreamFn,
2098
+ createElectricTools: this.options.createElectricTools,
2099
+ publicUrl,
2100
+ runtimeName: `builtin-agents`,
2101
+ serverHeaders: pullWake.headers
2102
+ });
2103
+ if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY or OPENAI_API_KEY must be set before starting builtin agents`);
2104
+ await registerBuiltinAgentTypes(this.bootstrap);
2105
+ const registeredRunner = pullWake.registerRunner ? await this.registerPullWakeRunner(pullWake) : null;
2106
+ this.pullWakeRunner = createPullWakeRunner({
2107
+ baseUrl: this.options.agentServerUrl,
2108
+ runnerId: pullWake.runnerId,
2109
+ runtime: this.bootstrap.runtime,
2110
+ headers: pullWake.headers,
2111
+ claimHeaders: pullWake.claimHeaders,
2112
+ claimTokenHeader: pullWake.claimTokenHeader,
2113
+ heartbeatIntervalMs: pullWake.heartbeatIntervalMs,
2114
+ leaseMs: pullWake.leaseMs,
2115
+ offset: registeredRunner?.wake_stream_offset,
2116
+ onError: (error) => {
2117
+ serverLog.error(`[builtin-agents] pull-wake runner failed`, error);
2118
+ return true;
2119
+ }
2120
+ });
2121
+ this.pullWakeRunner.start();
2122
+ serverLog.info(`[builtin-agents] pull-wake runner started: ${pullWake.runnerId}`);
2123
+ return `pull-wake:${pullWake.runnerId}`;
2124
+ } catch (error) {
2125
+ await this.stop().catch(() => {});
2126
+ throw error;
2127
+ }
2451
2128
  }
2452
2129
  async stop() {
2130
+ if (this.pullWakeRunner) {
2131
+ await this.pullWakeRunner.stop().catch((e) => {
2132
+ serverLog.error(`[builtin-agents] pull-wake runner stop failed`, e);
2133
+ });
2134
+ this.pullWakeRunner = null;
2135
+ }
2453
2136
  if (this.bootstrap) {
2454
2137
  this.bootstrap.runtime.abortWakes();
2455
- await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2138
+ await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
2139
+ serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
2140
+ }), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2456
2141
  this.bootstrap = null;
2457
2142
  }
2458
- if (this.server) {
2459
- const server = this.server;
2460
- await new Promise((resolve) => {
2461
- server.close(() => resolve());
2462
- });
2463
- this.server = null;
2143
+ this.mcpStopping = true;
2144
+ if (this.mcpWatcherCloser) {
2145
+ try {
2146
+ this.mcpWatcherCloser();
2147
+ } catch (e) {
2148
+ serverLog.error(`[mcp] watcher close failed:`, e);
2149
+ }
2150
+ this.mcpWatcherCloser = null;
2464
2151
  }
2465
- this._url = null;
2466
- this.publicBaseUrl = null;
2467
- }
2468
- async handleRequest(req, res) {
2469
- const method = req.method?.toUpperCase();
2470
- const path$1 = new URL(req.url ?? `/`, `http://localhost`).pathname;
2471
- const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
2472
- if (path$1 === `/_electric/health` && method === `GET`) {
2473
- res.writeHead(200, { "content-type": `application/json` });
2474
- res.end(JSON.stringify({ status: `ok` }));
2475
- return;
2152
+ if (this.mcpApplyInFlight.size > 0) await Promise.allSettled([...this.mcpApplyInFlight]);
2153
+ if (this.mcpToolProviderName) {
2154
+ unregisterToolProvider(this.mcpToolProviderName);
2155
+ this.mcpToolProviderName = null;
2476
2156
  }
2477
- if (path$1 === webhookPath && method === `POST` && this.bootstrap) {
2478
- await this.bootstrap.handler(req, res);
2479
- return;
2157
+ if (this._mcpRegistry) {
2158
+ await this._mcpRegistry.close().catch((e) => {
2159
+ serverLog.error(`[mcp] registry close failed:`, e);
2160
+ });
2161
+ this._mcpRegistry = null;
2480
2162
  }
2481
- res.writeHead(404, { "content-type": `application/json` });
2482
- res.end(JSON.stringify({ error: `Not found` }));
2163
+ this.mcpStopping = false;
2164
+ }
2165
+ async registerPullWakeRunner(pullWake) {
2166
+ const headers = new Headers(typeof pullWake.headers === `function` ? await pullWake.headers() : pullWake.headers);
2167
+ headers.set(`content-type`, `application/json`);
2168
+ const response = await fetch(appendPathToUrl(this.options.agentServerUrl, `/_electric/runners`), {
2169
+ method: `POST`,
2170
+ headers,
2171
+ body: JSON.stringify({
2172
+ id: pullWake.runnerId,
2173
+ owner_user_id: pullWake.ownerUserId,
2174
+ label: pullWake.label ?? `Built-in agents`,
2175
+ kind: `local`,
2176
+ admin_status: `enabled`
2177
+ })
2178
+ });
2179
+ if (!response.ok) throw new Error(`Failed to register pull-wake runner ${pullWake.runnerId}: ${response.status} ${await response.text()}`);
2180
+ return await response.json();
2483
2181
  }
2484
2182
  };
2485
2183
 
2486
2184
  //#endregion
2487
2185
  //#region src/entrypoint-lib.ts
2488
- const DEFAULT_HOST = `127.0.0.1`;
2489
- const DEFAULT_PORT = 4448;
2490
2186
  function readEnv(env, names) {
2491
2187
  for (const name of names) {
2492
2188
  const value = env[name]?.trim();
@@ -2499,13 +2195,6 @@ function readRequiredEnv(env, names, description) {
2499
2195
  if (value) return value;
2500
2196
  throw new Error(`Missing ${description}. Set one of: ${names.map((name) => `"${name}"`).join(`, `)}`);
2501
2197
  }
2502
- function readPort(env) {
2503
- const raw = readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_PORT`, `PORT`]);
2504
- if (!raw) return DEFAULT_PORT;
2505
- const port = Number(raw);
2506
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid builtin agents port "${raw}". Expected an integer between 1 and 65535.`);
2507
- return port;
2508
- }
2509
2198
  function validateUrl(name, value) {
2510
2199
  try {
2511
2200
  new URL(value);
@@ -2514,20 +2203,63 @@ function validateUrl(name, value) {
2514
2203
  throw new Error(`Invalid ${name}: "${value}"`);
2515
2204
  }
2516
2205
  }
2206
+ function buildAssertedAuthHeaders(env) {
2207
+ const headers = {};
2208
+ const email = readEnv(env, [`ELECTRIC_ASSERTED_AUTH_EMAIL`]);
2209
+ const name = readEnv(env, [`ELECTRIC_ASSERTED_AUTH_NAME`]);
2210
+ if (email) headers[`X-Electric-Asserted-Email`] = email;
2211
+ if (name) headers[`X-Electric-Asserted-Name`] = name;
2212
+ return Object.keys(headers).length > 0 ? headers : void 0;
2213
+ }
2214
+ function parseAdditionalServerHeaders(env) {
2215
+ const raw = readEnv(env, [`ELECTRIC_AGENTS_SERVER_HEADERS`]);
2216
+ if (!raw) return void 0;
2217
+ let parsed;
2218
+ try {
2219
+ parsed = JSON.parse(raw);
2220
+ } catch {
2221
+ throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: expected JSON`);
2222
+ }
2223
+ if (!parsed || typeof parsed !== `object` || Array.isArray(parsed)) throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: expected a JSON object`);
2224
+ const headers = new Headers();
2225
+ for (const [name, value] of Object.entries(parsed)) {
2226
+ if (typeof value !== `string`) throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: header "${name}" must be a string`);
2227
+ headers.set(name, value);
2228
+ }
2229
+ const normalized = Object.fromEntries(headers.entries());
2230
+ return Object.keys(normalized).length > 0 ? normalized : void 0;
2231
+ }
2232
+ function mergeHeaders(...sources) {
2233
+ const headers = new Headers();
2234
+ for (const source of sources) {
2235
+ if (!source) continue;
2236
+ new Headers(source).forEach((value, key) => headers.set(key, value));
2237
+ }
2238
+ const merged = Object.fromEntries(headers.entries());
2239
+ return Object.keys(merged).length > 0 ? merged : void 0;
2240
+ }
2241
+ function hasHeader(headers, name) {
2242
+ return headers ? new Headers(headers).has(name) : false;
2243
+ }
2517
2244
  function resolveBuiltinAgentsEntrypointOptions(env = process.env, cwd = process.cwd()) {
2518
2245
  const agentServerUrl = validateUrl(`agent server URL`, readRequiredEnv(env, [`ELECTRIC_AGENTS_SERVER_URL`, `ELECTRIC_AGENTS_BASE_URL`], `agent server base URL`));
2519
- const baseUrl = readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_BASE_URL`, `BUILTIN_AGENTS_BASE_URL`]);
2246
+ const runnerId = readRequiredEnv(env, [`ELECTRIC_AGENTS_PULL_WAKE_RUNNER_ID`, `PULL_WAKE_RUNNER_ID`], `pull-wake runner id`);
2247
+ const serverHeaders = mergeHeaders(buildAssertedAuthHeaders(env), parseAdditionalServerHeaders(env));
2520
2248
  return {
2521
2249
  agentServerUrl,
2522
- baseUrl: baseUrl ? validateUrl(`builtin agents base URL`, baseUrl) : void 0,
2523
- host: readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_HOST`, `HOST`]) ?? DEFAULT_HOST,
2524
- port: readPort(env),
2525
- workingDirectory: readEnv(env, [`ELECTRIC_AGENTS_WORKING_DIRECTORY`, `WORKING_DIRECTORY`]) ?? cwd
2250
+ workingDirectory: readEnv(env, [`ELECTRIC_AGENTS_WORKING_DIRECTORY`, `WORKING_DIRECTORY`]) ?? cwd,
2251
+ pullWake: {
2252
+ runnerId,
2253
+ registerRunner: readEnv(env, [`ELECTRIC_AGENTS_REGISTER_PULL_WAKE_RUNNER`]) === `true` || readEnv(env, [`ELECTRIC_AGENTS_REGISTER_PULL_WAKE_RUNNER`]) === `1`,
2254
+ headers: serverHeaders,
2255
+ claimHeaders: serverHeaders,
2256
+ claimTokenHeader: hasHeader(serverHeaders, `authorization`) ? `electric-claim-token` : void 0
2257
+ }
2526
2258
  };
2527
2259
  }
2528
- async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd(), createServer: createServer$1 = (options$1) => new BuiltinAgentsServer(options$1) } = {}) {
2260
+ async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd(), createServer = (options$1) => new BuiltinAgentsServer(options$1) } = {}) {
2529
2261
  const options = resolveBuiltinAgentsEntrypointOptions(env, cwd);
2530
- const server = createServer$1(options);
2262
+ const server = createServer(options);
2531
2263
  const url = await server.start();
2532
2264
  return {
2533
2265
  options,
@@ -2551,10 +2283,9 @@ async function main() {
2551
2283
  try {
2552
2284
  const started = await runBuiltinAgentsEntrypoint();
2553
2285
  server = started.server;
2554
- console.log(`Builtin agents server running at ${started.url}`);
2286
+ console.log(`Builtin agents pull-wake runner started at ${started.url}`);
2555
2287
  console.log(`Registering against: ${started.options.agentServerUrl}`);
2556
2288
  console.log(`Working directory: ${started.options.workingDirectory}`);
2557
- if (started.options.baseUrl) console.log(`Public webhook base URL: ${started.options.baseUrl}`);
2558
2289
  process.on(`SIGINT`, () => {
2559
2290
  stop(0);
2560
2291
  });