@electric-ax/agents 0.1.4 → 0.2.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.cjs CHANGED
@@ -27,18 +27,18 @@ const node_url = __toESM(require("node:url"));
27
27
  const __electric_ax_agents_runtime = __toESM(require("@electric-ax/agents-runtime"));
28
28
  const node_fs = __toESM(require("node:fs"));
29
29
  const pino = __toESM(require("pino"));
30
+ const node_child_process = __toESM(require("node:child_process"));
31
+ const node_os = __toESM(require("node:os"));
32
+ const zod = __toESM(require("zod"));
33
+ const agent_session_protocol = __toESM(require("agent-session-protocol"));
30
34
  const __anthropic_ai_sdk = __toESM(require("@anthropic-ai/sdk"));
31
35
  const node_crypto = __toESM(require("node:crypto"));
32
36
  const node_fs_promises = __toESM(require("node:fs/promises"));
33
37
  const better_sqlite3 = __toESM(require("better-sqlite3"));
34
38
  const __sinclair_typebox = __toESM(require("@sinclair/typebox"));
35
39
  const sqlite_vec = __toESM(require("sqlite-vec"));
36
- const node_child_process = __toESM(require("node:child_process"));
37
- const node_module = __toESM(require("node:module"));
38
- const __mozilla_readability = __toESM(require("@mozilla/readability"));
39
- const jsdom = __toESM(require("jsdom"));
40
- const turndown = __toESM(require("turndown"));
41
40
  const nanoid = __toESM(require("nanoid"));
41
+ const __electric_ax_agents_runtime_tools = __toESM(require("@electric-ax/agents-runtime/tools"));
42
42
  const node_http = __toESM(require("node:http"));
43
43
 
44
44
  //#region src/log.ts
@@ -90,6 +90,516 @@ const serverLog = {
90
90
  }
91
91
  };
92
92
 
93
+ //#endregion
94
+ //#region src/agents/coding-session.ts
95
+ const defaultCliRunner = { async run(opts) {
96
+ return new Promise((resolve, reject) => {
97
+ const isClaude = opts.agent === `claude`;
98
+ const bin = isClaude ? `claude` : `codex`;
99
+ const args = isClaude ? opts.sessionId ? [
100
+ `-r`,
101
+ opts.sessionId,
102
+ `--dangerously-skip-permissions`,
103
+ `-p`
104
+ ] : [`--dangerously-skip-permissions`, `-p`] : opts.sessionId ? [
105
+ `exec`,
106
+ `--skip-git-repo-check`,
107
+ `resume`,
108
+ opts.sessionId,
109
+ opts.prompt
110
+ ] : [
111
+ `exec`,
112
+ `--skip-git-repo-check`,
113
+ opts.prompt
114
+ ];
115
+ const child = (0, node_child_process.spawn)(bin, args, {
116
+ cwd: opts.cwd,
117
+ stdio: [
118
+ isClaude ? `pipe` : `ignore`,
119
+ `pipe`,
120
+ `pipe`
121
+ ]
122
+ });
123
+ const MAX_BUF_CHARS = 4096;
124
+ let stdout = ``;
125
+ let stderr = ``;
126
+ child.stdout?.on(`data`, (d) => {
127
+ if (stdout.length < MAX_BUF_CHARS) stdout += d.toString().slice(0, MAX_BUF_CHARS - stdout.length);
128
+ });
129
+ child.stderr?.on(`data`, (d) => {
130
+ if (stderr.length < MAX_BUF_CHARS) stderr += d.toString().slice(0, MAX_BUF_CHARS - stderr.length);
131
+ });
132
+ child.on(`error`, reject);
133
+ child.on(`exit`, (code) => {
134
+ resolve({
135
+ exitCode: code ?? -1,
136
+ stdout,
137
+ stderr
138
+ });
139
+ });
140
+ if (isClaude && child.stdin) {
141
+ child.stdin.write(opts.prompt);
142
+ child.stdin.end();
143
+ }
144
+ });
145
+ } };
146
+ async function discoverNewestSession(agent, cwd, excludeIds) {
147
+ const all = await (0, agent_session_protocol.discoverSessions)(agent);
148
+ const candidates = all.filter((s) => !excludeIds.has(s.sessionId) && (!s.cwd || s.cwd === cwd));
149
+ if (candidates.length === 0) return null;
150
+ return candidates[0].sessionId;
151
+ }
152
+ /**
153
+ * Compute the candidate directories where Claude Code stores per-cwd
154
+ * session JSONL files. Claude resolves the cwd to its realpath when
155
+ * choosing the directory name (so /tmp/foo on macOS lands under
156
+ * `-private-tmp-foo`), but the entity may have been spawned with the
157
+ * non-realpath form. Return both candidates so the caller can union
158
+ * their contents.
159
+ */
160
+ async function getClaudeProjectDirs(cwd) {
161
+ const home = (0, node_os.homedir)();
162
+ const make = (c) => node_path.default.join(home, `.claude`, `projects`, c.replace(/\//g, `-`));
163
+ const dirs = [make(cwd)];
164
+ try {
165
+ const real = await node_fs.promises.realpath(cwd);
166
+ if (real !== cwd) dirs.push(make(real));
167
+ } catch {}
168
+ return dirs;
169
+ }
170
+ async function listClaudeJsonlIdsByCwd(cwd) {
171
+ const ids = new Set();
172
+ for (const dir of await getClaudeProjectDirs(cwd)) try {
173
+ const files = await node_fs.promises.readdir(dir);
174
+ for (const f of files) if (f.endsWith(`.jsonl`)) ids.add(f.slice(0, -`.jsonl`.length));
175
+ } catch {}
176
+ return ids;
177
+ }
178
+ /**
179
+ * Deterministic-path discovery for a freshly created session. After the
180
+ * Claude CLI runs in `-p` mode it writes the new JSONL straight into
181
+ * `~/.claude/projects/<sanitize(cwd)>/<id>.jsonl` *without* leaving a
182
+ * `~/.claude/sessions/<pid>.json` lock file (those are interactive-only),
183
+ * so `discoverSessions` can miss it. Compute the expected dir directly
184
+ * and diff its contents against a pre-run snapshot. Returns the newest
185
+ * fresh sessionId or null. Codex falls back to discoverNewestSession.
186
+ */
187
+ async function findNewSessionAfterRun(agent, cwd, preDirectIds, preDiscoveredIds) {
188
+ if (agent === `claude`) {
189
+ const dirs = await getClaudeProjectDirs(cwd);
190
+ let best = null;
191
+ for (const dir of dirs) try {
192
+ const files = await node_fs.promises.readdir(dir);
193
+ for (const f of files) {
194
+ if (!f.endsWith(`.jsonl`)) continue;
195
+ const id = f.slice(0, -`.jsonl`.length);
196
+ if (preDirectIds.has(id)) continue;
197
+ const st = await node_fs.promises.stat(node_path.default.join(dir, f)).catch(() => null);
198
+ if (!st) continue;
199
+ if (!best || st.mtimeMs > best.mtime) best = {
200
+ id,
201
+ mtime: st.mtimeMs
202
+ };
203
+ }
204
+ } catch {}
205
+ if (best) return best.id;
206
+ }
207
+ return discoverNewestSession(agent, cwd, preDiscoveredIds);
208
+ }
209
+ const sessionMetaRowSchema = zod.z.object({
210
+ key: zod.z.literal(`current`),
211
+ electricSessionId: zod.z.string(),
212
+ nativeSessionId: zod.z.string().optional(),
213
+ agent: zod.z.enum([`claude`, `codex`]),
214
+ cwd: zod.z.string(),
215
+ status: zod.z.enum([
216
+ `initializing`,
217
+ `idle`,
218
+ `running`,
219
+ `error`
220
+ ]),
221
+ error: zod.z.string().optional(),
222
+ currentPromptInboxKey: zod.z.string().optional()
223
+ });
224
+ const cursorStateRowSchema = zod.z.object({
225
+ key: zod.z.literal(`current`),
226
+ cursor: zod.z.string(),
227
+ lastProcessedInboxKey: zod.z.string().optional()
228
+ });
229
+ const eventRowSchema = zod.z.object({
230
+ key: zod.z.string(),
231
+ ts: zod.z.number(),
232
+ type: zod.z.string(),
233
+ callId: zod.z.string().optional(),
234
+ payload: zod.z.looseObject({})
235
+ });
236
+ const creationArgsSchema = zod.z.object({
237
+ agent: zod.z.enum([`claude`, `codex`]),
238
+ cwd: zod.z.string().optional(),
239
+ nativeSessionId: zod.z.string().optional(),
240
+ importFrom: zod.z.object({
241
+ agent: zod.z.enum([`claude`, `codex`]),
242
+ sessionId: zod.z.string()
243
+ }).optional()
244
+ });
245
+ const promptMessageSchema = zod.z.object({ text: zod.z.string() });
246
+ /**
247
+ * Stable key for an events-collection row, derived from the event's content.
248
+ * Lets us re-insert the same event without producing duplicates — the caller
249
+ * (or the collection's uniqueness guard) uses this to de-dup across retries,
250
+ * replays, and crash recovery. Sorts chronologically by ts, then by type.
251
+ */
252
+ function eventKey(event) {
253
+ const tsPart = String(event.ts).padStart(16, `0`);
254
+ return `${tsPart}_${event.type}_${contentHashHex(event)}`;
255
+ }
256
+ function contentHashHex(event) {
257
+ const json = JSON.stringify(event);
258
+ let h = 5381;
259
+ for (let i = 0; i < json.length; i++) h = (h * 33 ^ json.charCodeAt(i)) >>> 0;
260
+ return h.toString(16).padStart(8, `0`);
261
+ }
262
+ function buildEventRow(event) {
263
+ const callId = `callId` in event && typeof event.callId === `string` ? event.callId : void 0;
264
+ return {
265
+ key: eventKey(event),
266
+ ts: event.ts,
267
+ type: event.type,
268
+ ...callId !== void 0 ? { callId } : {},
269
+ payload: event
270
+ };
271
+ }
272
+ function appendIfNew(ctx, event) {
273
+ const row = buildEventRow(event);
274
+ if (ctx.events.get(row.key) !== void 0) return;
275
+ ctx.actions.events_insert({ row });
276
+ }
277
+ /**
278
+ * Mirror every event that lands in the JSONL file while `runWork` is
279
+ * executing (i.e. while the CLI is running). Returns the advanced cursor
280
+ * and the `runWork` result once everything has settled and every append
281
+ * has been persisted to the entity's durable stream.
282
+ *
283
+ * If setup fails (e.g. the session file can't be resolved), `runWork`
284
+ * still runs — but nothing is mirrored and `setupError` is populated so
285
+ * the caller can surface the condition. If `runWork` throws, the error
286
+ * propagates after the watcher has been cleaned up.
287
+ */
288
+ async function runWithLiveMirror(opts) {
289
+ let cursor = null;
290
+ let setupError = void 0;
291
+ try {
292
+ const session = await (0, agent_session_protocol.resolveSession)(opts.nativeSessionId, opts.agent);
293
+ if (opts.serializedCursor) cursor = (0, agent_session_protocol.deserializeCursor)({
294
+ ...opts.serializedCursor,
295
+ path: session.path
296
+ });
297
+ else {
298
+ const initial = await (0, agent_session_protocol.loadSession)({
299
+ sessionId: opts.nativeSessionId,
300
+ agent: opts.agent
301
+ });
302
+ for (const ev of initial.events) appendIfNew(opts.ctx, ev);
303
+ cursor = initial.cursor;
304
+ }
305
+ } catch (e) {
306
+ setupError = e;
307
+ }
308
+ if (!cursor) {
309
+ const result$1 = await opts.runWork();
310
+ return {
311
+ cursor: opts.serializedCursor,
312
+ setupError,
313
+ result: result$1
314
+ };
315
+ }
316
+ let activeCursor = cursor;
317
+ let busy = false;
318
+ let pending = false;
319
+ let stopped = false;
320
+ const drainOnce = async () => {
321
+ if (stopped && busy) return;
322
+ if (busy) {
323
+ pending = true;
324
+ return;
325
+ }
326
+ busy = true;
327
+ try {
328
+ const res = await (0, agent_session_protocol.tailSession)({ cursor: activeCursor });
329
+ activeCursor = res.cursor;
330
+ for (const ev of res.newEvents) appendIfNew(opts.ctx, ev);
331
+ } catch {} finally {
332
+ busy = false;
333
+ if (pending && !stopped) {
334
+ pending = false;
335
+ drainOnce();
336
+ }
337
+ }
338
+ };
339
+ const fileWatcher = (0, node_fs.watch)(activeCursor.path, () => {
340
+ drainOnce();
341
+ });
342
+ const pollHandle = setInterval(() => {
343
+ drainOnce();
344
+ }, 1500);
345
+ let result;
346
+ try {
347
+ result = await opts.runWork();
348
+ } finally {
349
+ stopped = true;
350
+ clearInterval(pollHandle);
351
+ fileWatcher.close();
352
+ while (busy) await new Promise((r) => setTimeout(r, 10));
353
+ try {
354
+ const final = await (0, agent_session_protocol.tailSession)({ cursor: activeCursor });
355
+ activeCursor = final.cursor;
356
+ for (const ev of final.newEvents) appendIfNew(opts.ctx, ev);
357
+ } catch {}
358
+ }
359
+ return {
360
+ cursor: (0, agent_session_protocol.serializeCursor)(activeCursor),
361
+ setupError,
362
+ result
363
+ };
364
+ }
365
+ function registerCodingSession(registry, options = {}) {
366
+ const runner = options.cliRunner ?? defaultCliRunner;
367
+ const defaultCwd = options.defaultWorkingDirectory ?? process.cwd();
368
+ registry.define(`coder`, {
369
+ 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.`,
370
+ creationSchema: creationArgsSchema,
371
+ inboxSchemas: { prompt: promptMessageSchema },
372
+ state: {
373
+ sessionMeta: {
374
+ schema: sessionMetaRowSchema,
375
+ type: __electric_ax_agents_runtime.CODING_SESSION_META_COLLECTION_TYPE,
376
+ primaryKey: `key`
377
+ },
378
+ cursorState: {
379
+ schema: cursorStateRowSchema,
380
+ type: __electric_ax_agents_runtime.CODING_SESSION_CURSOR_COLLECTION_TYPE,
381
+ primaryKey: `key`
382
+ },
383
+ events: {
384
+ schema: eventRowSchema,
385
+ type: __electric_ax_agents_runtime.CODING_SESSION_EVENT_COLLECTION_TYPE,
386
+ primaryKey: `key`
387
+ }
388
+ },
389
+ async handler(ctx, _wake) {
390
+ const existingMeta = ctx.db.collections.sessionMeta.get(`current`);
391
+ if (!existingMeta) {
392
+ const args = creationArgsSchema.parse(ctx.args);
393
+ const cwd = args.cwd ?? defaultCwd;
394
+ const electricSessionId = ctx.entityUrl.split(`/`).pop() ?? ctx.entityUrl;
395
+ let resolvedNativeId = args.nativeSessionId;
396
+ if (args.importFrom) {
397
+ const result = await (0, agent_session_protocol.importLocalSession)({
398
+ source: {
399
+ sessionId: args.importFrom.sessionId,
400
+ agent: args.importFrom.agent
401
+ },
402
+ target: {
403
+ agent: args.agent,
404
+ cwd
405
+ }
406
+ });
407
+ resolvedNativeId = result.sessionId;
408
+ }
409
+ const hasNative = resolvedNativeId !== void 0;
410
+ ctx.db.actions.sessionMeta_insert({ row: {
411
+ key: `current`,
412
+ electricSessionId,
413
+ ...hasNative ? { nativeSessionId: resolvedNativeId } : {},
414
+ agent: args.agent,
415
+ cwd,
416
+ status: hasNative ? `idle` : `initializing`
417
+ } });
418
+ }
419
+ if (!ctx.db.collections.cursorState.get(`current`)) ctx.db.actions.cursorState_insert({ row: {
420
+ key: `current`,
421
+ cursor: ``
422
+ } });
423
+ const metaRow = ctx.db.collections.sessionMeta.get(`current`);
424
+ const cursorRow = ctx.db.collections.cursorState.get(`current`);
425
+ if (!metaRow || !cursorRow) throw new Error(`[coding-session] expected sessionMeta and cursorState rows to exist after init`);
426
+ if (metaRow.nativeSessionId && !cursorRow.cursor) {
427
+ const mirrorCtx = {
428
+ events: { get: (k) => ctx.db.collections.events.get(k) },
429
+ actions: { events_insert: ctx.db.actions.events_insert }
430
+ };
431
+ try {
432
+ const initial = await (0, agent_session_protocol.loadSession)({
433
+ sessionId: metaRow.nativeSessionId,
434
+ agent: metaRow.agent
435
+ });
436
+ for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
437
+ const serialized = (0, agent_session_protocol.serializeCursor)(initial.cursor);
438
+ ctx.db.actions.cursorState_update({
439
+ key: `current`,
440
+ updater: (d) => {
441
+ d.cursor = JSON.stringify(serialized);
442
+ }
443
+ });
444
+ } catch (e) {
445
+ const message = e instanceof Error ? e.message : String(e);
446
+ ctx.db.actions.sessionMeta_update({
447
+ key: `current`,
448
+ updater: (d) => {
449
+ d.error = `initial mirror failed: ${message}`;
450
+ }
451
+ });
452
+ }
453
+ }
454
+ const inboxRows = ctx.db.collections.inbox.toArray.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
455
+ const lastKey = cursorRow.lastProcessedInboxKey ?? ``;
456
+ const pending = inboxRows.filter((m) => m.key > lastKey);
457
+ if (pending.length === 0) {
458
+ if (metaRow.status === `running` || metaRow.status === `error`) ctx.db.actions.sessionMeta_update({
459
+ key: `current`,
460
+ updater: (d) => {
461
+ d.status = `idle`;
462
+ delete d.currentPromptInboxKey;
463
+ delete d.error;
464
+ }
465
+ });
466
+ return;
467
+ }
468
+ let runningMeta = metaRow;
469
+ let runningCursor = cursorRow;
470
+ for (const inboxMsg of pending) {
471
+ const parsed = promptMessageSchema.safeParse(inboxMsg.payload);
472
+ if (!parsed.success) {
473
+ ctx.db.actions.cursorState_update({
474
+ key: `current`,
475
+ updater: (d) => {
476
+ d.lastProcessedInboxKey = inboxMsg.key;
477
+ }
478
+ });
479
+ runningCursor = {
480
+ ...runningCursor,
481
+ lastProcessedInboxKey: inboxMsg.key
482
+ };
483
+ continue;
484
+ }
485
+ const prompt = parsed.data.text;
486
+ const existingTitle = ctx.tags.title;
487
+ if (typeof existingTitle !== `string` || existingTitle.length === 0) ctx.setTag(`title`, prompt.slice(0, 80));
488
+ ctx.db.actions.sessionMeta_update({
489
+ key: `current`,
490
+ updater: (d) => {
491
+ d.status = `running`;
492
+ d.currentPromptInboxKey = inboxMsg.key;
493
+ delete d.error;
494
+ }
495
+ });
496
+ const recordedRun = ctx.recordRun();
497
+ const eventKeysBefore = new Set(ctx.db.collections.events.toArray.map((e) => e.key));
498
+ try {
499
+ const mirrorCtx = {
500
+ events: { get: (k) => ctx.db.collections.events.get(k) },
501
+ actions: { events_insert: ctx.db.actions.events_insert }
502
+ };
503
+ let nextCursorJson = runningCursor.cursor;
504
+ if (!runningMeta.nativeSessionId) {
505
+ const preDirectIds = runningMeta.agent === `claude` ? await listClaudeJsonlIdsByCwd(runningMeta.cwd) : new Set();
506
+ const preDiscoveredIds = new Set((await (0, agent_session_protocol.discoverSessions)(runningMeta.agent)).map((s) => s.sessionId));
507
+ const cliResult = await runner.run({
508
+ agent: runningMeta.agent,
509
+ cwd: runningMeta.cwd,
510
+ prompt
511
+ });
512
+ 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>`}`);
513
+ const foundId = await findNewSessionAfterRun(runningMeta.agent, runningMeta.cwd, preDirectIds, preDiscoveredIds);
514
+ if (!foundId) throw new Error(`[coding-session] ${runningMeta.agent} CLI succeeded but no new session file was found`);
515
+ ctx.db.actions.sessionMeta_update({
516
+ key: `current`,
517
+ updater: (d) => {
518
+ d.nativeSessionId = foundId;
519
+ }
520
+ });
521
+ runningMeta = {
522
+ ...runningMeta,
523
+ nativeSessionId: foundId
524
+ };
525
+ const initial = await (0, agent_session_protocol.loadSession)({
526
+ sessionId: foundId,
527
+ agent: runningMeta.agent
528
+ });
529
+ for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
530
+ nextCursorJson = JSON.stringify((0, agent_session_protocol.serializeCursor)(initial.cursor));
531
+ } else {
532
+ const serializedCursor = runningCursor.cursor ? JSON.parse(runningCursor.cursor) : null;
533
+ const { cursor: nextSerialized, setupError, result: cliResult } = await runWithLiveMirror({
534
+ agent: runningMeta.agent,
535
+ nativeSessionId: runningMeta.nativeSessionId,
536
+ serializedCursor,
537
+ ctx: mirrorCtx,
538
+ runWork: () => runner.run({
539
+ agent: runningMeta.agent,
540
+ sessionId: runningMeta.nativeSessionId,
541
+ cwd: runningMeta.cwd,
542
+ prompt
543
+ })
544
+ });
545
+ if (setupError) throw setupError instanceof Error ? setupError : new Error(String(setupError));
546
+ 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>`}`);
547
+ const persistedCursor = nextSerialized ?? serializedCursor;
548
+ nextCursorJson = persistedCursor ? JSON.stringify(persistedCursor) : ``;
549
+ }
550
+ ctx.db.actions.cursorState_update({
551
+ key: `current`,
552
+ updater: (d) => {
553
+ d.cursor = nextCursorJson;
554
+ d.lastProcessedInboxKey = inboxMsg.key;
555
+ }
556
+ });
557
+ runningCursor = {
558
+ ...runningCursor,
559
+ cursor: nextCursorJson,
560
+ lastProcessedInboxKey: inboxMsg.key
561
+ };
562
+ for (const row of ctx.db.collections.events.toArray) {
563
+ if (eventKeysBefore.has(row.key)) continue;
564
+ if (row.type !== `assistant_message`) continue;
565
+ const text = row.payload?.text;
566
+ if (typeof text === `string` && text.length > 0) recordedRun.attachResponse(text);
567
+ }
568
+ recordedRun.end({ status: `completed` });
569
+ } catch (e) {
570
+ const message = e instanceof Error ? e.message : String(e);
571
+ recordedRun.end({
572
+ status: `failed`,
573
+ finishReason: `error`
574
+ });
575
+ ctx.db.actions.sessionMeta_update({
576
+ key: `current`,
577
+ updater: (d) => {
578
+ d.status = `error`;
579
+ d.error = message;
580
+ }
581
+ });
582
+ ctx.db.actions.cursorState_update({
583
+ key: `current`,
584
+ updater: (d) => {
585
+ d.lastProcessedInboxKey = inboxMsg.key;
586
+ }
587
+ });
588
+ throw e;
589
+ }
590
+ }
591
+ ctx.db.actions.sessionMeta_update({
592
+ key: `current`,
593
+ updater: (d) => {
594
+ d.status = `idle`;
595
+ delete d.currentPromptInboxKey;
596
+ delete d.error;
597
+ }
598
+ });
599
+ }
600
+ });
601
+ }
602
+
93
603
  //#endregion
94
604
  //#region src/docs/embed.ts
95
605
  const EMBEDDING_DIMENSIONS = 128;
@@ -862,319 +1372,6 @@ function listRefFiles(dir, prefix = ``) {
862
1372
  }
863
1373
  }
864
1374
 
865
- //#endregion
866
- //#region src/tools/bash.ts
867
- const TIMEOUT_MS = 3e4;
868
- const MAX_OUTPUT_CHARS = 5e4;
869
- function createBashTool(workingDirectory) {
870
- return {
871
- name: `bash`,
872
- label: `Bash`,
873
- description: `Execute a shell command and return its output. Commands run in a sandboxed working directory with a 30-second timeout.`,
874
- parameters: __sinclair_typebox.Type.Object({ command: __sinclair_typebox.Type.String({ description: `The shell command to execute` }) }),
875
- execute: async (_toolCallId, params) => {
876
- const { command } = params;
877
- return new Promise((resolve$3) => {
878
- const child = (0, node_child_process.exec)(command, {
879
- cwd: workingDirectory,
880
- timeout: TIMEOUT_MS,
881
- maxBuffer: 1024 * 1024,
882
- env: {
883
- ...process.env,
884
- HOME: workingDirectory
885
- }
886
- });
887
- let stdout = ``;
888
- let stderr = ``;
889
- child.stdout?.on(`data`, (data) => {
890
- stdout += data;
891
- });
892
- child.stderr?.on(`data`, (data) => {
893
- stderr += data;
894
- });
895
- child.on(`close`, (code, signal) => {
896
- const timedOut = signal === `SIGTERM`;
897
- let output = stdout;
898
- if (stderr) output += output ? `\n\nSTDERR:\n${stderr}` : stderr;
899
- if (timedOut) output += `\n\n[Command timed out after ${TIMEOUT_MS / 1e3}s]`;
900
- output = output.slice(0, MAX_OUTPUT_CHARS);
901
- resolve$3({
902
- content: [{
903
- type: `text`,
904
- text: output || `(no output)`
905
- }],
906
- details: {
907
- exitCode: code ?? 1,
908
- timedOut
909
- }
910
- });
911
- });
912
- child.on(`error`, (err) => {
913
- resolve$3({
914
- content: [{
915
- type: `text`,
916
- text: `Command failed: ${err.message}`
917
- }],
918
- details: {
919
- exitCode: 1,
920
- timedOut: false
921
- }
922
- });
923
- });
924
- });
925
- }
926
- };
927
- }
928
-
929
- //#endregion
930
- //#region src/tools/edit.ts
931
- const READ_GUARD_MESSAGE = (rel) => `File ${rel} has not been read in this session (sessions are per-wake — re-read after waking from a worker).`;
932
- function createEditTool(workingDirectory, readSet) {
933
- return {
934
- name: `edit`,
935
- label: `Edit File`,
936
- description: `Replace text in a file. The file must have been read with the read tool earlier in this session. By default the old_string must occur exactly once; set replace_all to true to replace every occurrence.`,
937
- parameters: __sinclair_typebox.Type.Object({
938
- path: __sinclair_typebox.Type.String({ description: `File path (relative to working directory)` }),
939
- old_string: __sinclair_typebox.Type.String({ description: `The literal text to find. Must be unique unless replace_all is true.` }),
940
- new_string: __sinclair_typebox.Type.String({ description: `The replacement text.` }),
941
- replace_all: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean({ description: `Replace every occurrence (default false).` }))
942
- }),
943
- execute: async (_toolCallId, params) => {
944
- const { path: filePath, old_string, new_string, replace_all } = params;
945
- try {
946
- const resolved = (0, node_path.resolve)(workingDirectory, filePath);
947
- const rel = (0, node_path.relative)(workingDirectory, resolved);
948
- if (rel.startsWith(`..`)) return {
949
- content: [{
950
- type: `text`,
951
- text: `Error: Path "${filePath}" is outside the working directory`
952
- }],
953
- details: { replacements: 0 }
954
- };
955
- if (!readSet.has(resolved)) return {
956
- content: [{
957
- type: `text`,
958
- text: READ_GUARD_MESSAGE(rel)
959
- }],
960
- details: { replacements: 0 }
961
- };
962
- const original = await (0, node_fs_promises.readFile)(resolved, `utf-8`);
963
- if (!replace_all) {
964
- const first = original.indexOf(old_string);
965
- if (first === -1) return {
966
- content: [{
967
- type: `text`,
968
- text: `Error: old_string not found in ${rel}`
969
- }],
970
- details: { replacements: 0 }
971
- };
972
- const second = original.indexOf(old_string, first + 1);
973
- if (second !== -1) {
974
- const matches = original.split(old_string).length - 1;
975
- return {
976
- content: [{
977
- type: `text`,
978
- text: `Error: found ${matches} matches for old_string in ${rel}; pass replace_all=true to replace all, or provide a more specific old_string.`
979
- }],
980
- details: { replacements: 0 }
981
- };
982
- }
983
- const updated = original.slice(0, first) + new_string + original.slice(first + old_string.length);
984
- await (0, node_fs_promises.writeFile)(resolved, updated, `utf-8`);
985
- return {
986
- content: [{
987
- type: `text`,
988
- text: `Edited ${rel}: 1 replacement`
989
- }],
990
- details: { replacements: 1 }
991
- };
992
- }
993
- const parts = original.split(old_string);
994
- const count = parts.length - 1;
995
- if (count === 0) return {
996
- content: [{
997
- type: `text`,
998
- text: `Error: old_string not found in ${rel}`
999
- }],
1000
- details: { replacements: 0 }
1001
- };
1002
- await (0, node_fs_promises.writeFile)(resolved, parts.join(new_string), `utf-8`);
1003
- return {
1004
- content: [{
1005
- type: `text`,
1006
- text: `Edited ${rel}: ${count} occurrences replaced`
1007
- }],
1008
- details: { replacements: count }
1009
- };
1010
- } catch (err) {
1011
- serverLog.warn(`[edit tool] failed to edit ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1012
- return {
1013
- content: [{
1014
- type: `text`,
1015
- text: `Error editing file: ${err instanceof Error ? err.message : `Unknown error`}`
1016
- }],
1017
- details: { replacements: 0 }
1018
- };
1019
- }
1020
- }
1021
- };
1022
- }
1023
-
1024
- //#endregion
1025
- //#region src/tools/fetch-url.ts
1026
- const MAX_RAW_CHARS = 1e5;
1027
- const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
1028
- const { gfm } = require$1(`turndown-plugin-gfm`);
1029
- function htmlToMarkdown(html, url) {
1030
- const virtualConsole = new jsdom.VirtualConsole();
1031
- const dom = new jsdom.JSDOM(html, {
1032
- url,
1033
- virtualConsole
1034
- });
1035
- const reader = new __mozilla_readability.Readability(dom.window.document);
1036
- const article = reader.parse();
1037
- const turndown$1 = new turndown.default({ headingStyle: `atx` });
1038
- turndown$1.use(gfm);
1039
- return turndown$1.turndown(article?.content ?? html);
1040
- }
1041
- let anthropic$1 = null;
1042
- function getClient$1() {
1043
- if (!anthropic$1) anthropic$1 = new __anthropic_ai_sdk.default();
1044
- return anthropic$1;
1045
- }
1046
- async function extractWithLLM(text, prompt) {
1047
- const client = getClient$1();
1048
- const res = await client.messages.create({
1049
- model: `claude-haiku-4-5-20251001`,
1050
- max_tokens: 2048,
1051
- messages: [{
1052
- role: `user`,
1053
- content: `${prompt}\n\n<page_content>\n${text.slice(0, MAX_RAW_CHARS)}\n</page_content>`
1054
- }]
1055
- });
1056
- const block = res.content[0];
1057
- return block?.type === `text` ? block.text : ``;
1058
- }
1059
- const fetchUrlTool = {
1060
- name: `fetch_url`,
1061
- label: `Fetch URL`,
1062
- description: `Fetch a web page and extract its key content using AI. Provide a prompt describing what information you want from the page. Returns a focused extraction rather than raw HTML.`,
1063
- parameters: __sinclair_typebox.Type.Object({
1064
- url: __sinclair_typebox.Type.String({ description: `The URL to fetch` }),
1065
- prompt: __sinclair_typebox.Type.String({ description: `What to extract from the page, e.g. 'Extract the main article content' or 'Find the pricing information'` })
1066
- }),
1067
- execute: async (_toolCallId, params) => {
1068
- const { url, prompt } = params;
1069
- try {
1070
- const res = await fetch(url, {
1071
- headers: {
1072
- "User-Agent": `Mozilla/5.0 (compatible; DurableStreamsAgent/1.0)`,
1073
- Accept: `text/html,application/xhtml+xml,text/plain,*/*`
1074
- },
1075
- redirect: `follow`,
1076
- signal: AbortSignal.timeout(1e4)
1077
- });
1078
- if (!res.ok) return {
1079
- content: [{
1080
- type: `text`,
1081
- text: `Failed to fetch: ${res.status} ${res.statusText}`
1082
- }],
1083
- details: {
1084
- charCount: 0,
1085
- usedLLM: false
1086
- }
1087
- };
1088
- const contentType = res.headers.get(`content-type`) ?? ``;
1089
- const raw = await res.text();
1090
- const markdown = contentType.includes(`text/html`) ? htmlToMarkdown(raw, url) : raw;
1091
- const extracted = await extractWithLLM(markdown, prompt);
1092
- return {
1093
- content: [{
1094
- type: `text`,
1095
- text: extracted
1096
- }],
1097
- details: {
1098
- charCount: extracted.length,
1099
- usedLLM: true
1100
- }
1101
- };
1102
- } catch (err) {
1103
- return {
1104
- content: [{
1105
- type: `text`,
1106
- text: `Error fetching URL: ${err instanceof Error ? err.message : `Unknown error`}`
1107
- }],
1108
- details: {
1109
- charCount: 0,
1110
- usedLLM: false
1111
- }
1112
- };
1113
- }
1114
- }
1115
- };
1116
-
1117
- //#endregion
1118
- //#region src/tools/read-file.ts
1119
- const MAX_FILE_SIZE = 512 * 1024;
1120
- function createReadFileTool(workingDirectory, readSet) {
1121
- return {
1122
- name: `read`,
1123
- label: `Read File`,
1124
- description: `Read the contents of a file. Path must be relative to or within the working directory. Binary files and files over 512KB are rejected.`,
1125
- parameters: __sinclair_typebox.Type.Object({ path: __sinclair_typebox.Type.String({ description: `File path (relative to working directory)` }) }),
1126
- execute: async (_toolCallId, params) => {
1127
- const { path: filePath } = params;
1128
- try {
1129
- const resolved = (0, node_path.resolve)(workingDirectory, filePath);
1130
- const rel = (0, node_path.relative)(workingDirectory, resolved);
1131
- if (rel.startsWith(`..`)) return {
1132
- content: [{
1133
- type: `text`,
1134
- text: `Error: Path "${filePath}" is outside the working directory`
1135
- }],
1136
- details: { charCount: 0 }
1137
- };
1138
- const fileStat = await (0, node_fs_promises.stat)(resolved);
1139
- if (fileStat.size > MAX_FILE_SIZE) return {
1140
- content: [{
1141
- type: `text`,
1142
- text: `Error: File is too large (${(fileStat.size / 1024).toFixed(0)}KB > ${MAX_FILE_SIZE / 1024}KB limit)`
1143
- }],
1144
- details: { charCount: 0 }
1145
- };
1146
- const buffer = await (0, node_fs_promises.readFile)(resolved);
1147
- const sample = buffer.subarray(0, 8192);
1148
- if (sample.includes(0)) return {
1149
- content: [{
1150
- type: `text`,
1151
- text: `Error: "${filePath}" appears to be a binary file`
1152
- }],
1153
- details: { charCount: 0 }
1154
- };
1155
- const text = buffer.toString(`utf-8`);
1156
- readSet?.add(resolved);
1157
- return {
1158
- content: [{
1159
- type: `text`,
1160
- text
1161
- }],
1162
- details: { charCount: text.length }
1163
- };
1164
- } catch (err) {
1165
- serverLog.warn(`[read tool] failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1166
- return {
1167
- content: [{
1168
- type: `text`,
1169
- text: `Error reading file: ${err instanceof Error ? err.message : `Unknown error`}`
1170
- }],
1171
- details: { charCount: 0 }
1172
- };
1173
- }
1174
- }
1175
- };
1176
- }
1177
-
1178
1375
  //#endregion
1179
1376
  //#region src/tools/spawn-worker.ts
1180
1377
  const WORKER_TOOL_NAMES = [
@@ -1250,114 +1447,117 @@ function createSpawnWorkerTool(ctx) {
1250
1447
  }
1251
1448
 
1252
1449
  //#endregion
1253
- //#region src/tools/write.ts
1254
- function createWriteTool(workingDirectory, readSet) {
1450
+ //#region src/tools/spawn-coder.ts
1451
+ const CODER_AGENT_NAMES = [`claude`, `codex`];
1452
+ function createSpawnCoderTool(ctx) {
1255
1453
  return {
1256
- name: `write`,
1257
- label: `Write File`,
1258
- description: `Create or overwrite a file. Path must be within the working directory. Parent directories are created as needed.`,
1454
+ name: `spawn_coder`,
1455
+ label: `Spawn Coder`,
1456
+ 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.`,
1259
1457
  parameters: __sinclair_typebox.Type.Object({
1260
- path: __sinclair_typebox.Type.String({ description: `File path (relative to working directory)` }),
1261
- content: __sinclair_typebox.Type.String({ description: `Full file contents to write` })
1458
+ prompt: __sinclair_typebox.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.` }),
1459
+ agent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union(CODER_AGENT_NAMES.map((n) => __sinclair_typebox.Type.Literal(n)), { description: `Which coding agent to use. Defaults to "claude". Use "codex" only if the user explicitly asks for it.` })),
1460
+ cwd: __sinclair_typebox.Type.Optional(__sinclair_typebox.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.` }))
1262
1461
  }),
1263
1462
  execute: async (_toolCallId, params) => {
1264
- const { path: filePath, content } = params;
1463
+ const { prompt, agent, cwd } = params;
1464
+ if (typeof prompt !== `string` || prompt.length === 0) return {
1465
+ content: [{
1466
+ type: `text`,
1467
+ text: `Error: prompt is required and must be a non-empty string.`
1468
+ }],
1469
+ details: { spawned: false }
1470
+ };
1471
+ const id = (0, nanoid.nanoid)(10);
1472
+ const spawnArgs = { agent: agent ?? `claude` };
1473
+ if (cwd) spawnArgs.cwd = cwd;
1265
1474
  try {
1266
- const resolved = (0, node_path.resolve)(workingDirectory, filePath);
1267
- const rel = (0, node_path.relative)(workingDirectory, resolved);
1268
- if (rel.startsWith(`..`)) return {
1269
- content: [{
1270
- type: `text`,
1271
- text: `Error: Path "${filePath}" is outside the working directory`
1272
- }],
1273
- details: { bytesWritten: 0 }
1274
- };
1275
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)(resolved), { recursive: true });
1276
- await (0, node_fs_promises.writeFile)(resolved, content, `utf-8`);
1277
- readSet?.add(resolved);
1278
- const bytesWritten = Buffer.byteLength(content, `utf-8`);
1475
+ const handle = await ctx.spawn(`coder`, id, spawnArgs, {
1476
+ initialMessage: { text: prompt },
1477
+ wake: {
1478
+ on: `runFinished`,
1479
+ includeResponse: true
1480
+ }
1481
+ });
1482
+ const coderUrl = handle.entityUrl;
1279
1483
  return {
1280
1484
  content: [{
1281
1485
  type: `text`,
1282
- text: `Wrote ${bytesWritten} bytes to ${rel}`
1486
+ 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.`
1283
1487
  }],
1284
- details: { bytesWritten }
1488
+ details: {
1489
+ spawned: true,
1490
+ coderUrl
1491
+ }
1285
1492
  };
1286
1493
  } catch (err) {
1287
- serverLog.warn(`[write tool] failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1494
+ serverLog.warn(`[spawn_coder tool] failed to spawn coder ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1288
1495
  return {
1289
1496
  content: [{
1290
1497
  type: `text`,
1291
- text: `Error writing file: ${err instanceof Error ? err.message : `Unknown error`}`
1498
+ text: `Error spawning coder: ${err instanceof Error ? err.message : `Unknown error`}`
1292
1499
  }],
1293
- details: { bytesWritten: 0 }
1500
+ details: { spawned: false }
1294
1501
  };
1295
1502
  }
1296
1503
  }
1297
1504
  };
1298
1505
  }
1299
-
1300
- //#endregion
1301
- //#region src/tools/brave-search.ts
1302
- const BRAVE_API_URL = `https://api.search.brave.com/res/v1/web/search`;
1303
- const braveSearchTool = {
1304
- name: `web_search`,
1305
- label: `Web Search`,
1306
- description: `Search the web for current information using Brave Search. Returns titles, URLs, and snippets from top results.`,
1307
- parameters: __sinclair_typebox.Type.Object({ query: __sinclair_typebox.Type.String({ description: `The search query` }) }),
1308
- execute: async (_toolCallId, params) => {
1309
- const apiKey = process.env.BRAVE_SEARCH_API_KEY;
1310
- if (!apiKey) return {
1311
- content: [{
1312
- type: `text`,
1313
- text: `Search failed: BRAVE_SEARCH_API_KEY not set`
1314
- }],
1315
- details: { resultCount: 0 }
1316
- };
1317
- const { query } = params;
1318
- try {
1319
- const url = `${BRAVE_API_URL}?q=${encodeURIComponent(query)}&count=5`;
1320
- const res = await fetch(url, { headers: { "X-Subscription-Token": apiKey } });
1321
- if (!res.ok) return {
1322
- content: [{
1323
- type: `text`,
1324
- text: `Search failed: ${res.status} ${res.statusText}`
1325
- }],
1326
- details: { resultCount: 0 }
1327
- };
1328
- const data = await res.json();
1329
- const results = data.web?.results ?? [];
1330
- if (results.length === 0) return {
1331
- content: [{
1332
- type: `text`,
1333
- text: `No results found for "${query}"`
1334
- }],
1335
- details: { resultCount: 0 }
1336
- };
1337
- const formatted = results.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.description}`).join(`\n\n`);
1338
- return {
1506
+ function createPromptCoderTool(ctx) {
1507
+ return {
1508
+ name: `prompt_coder`,
1509
+ label: `Prompt Coder`,
1510
+ 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.`,
1511
+ parameters: __sinclair_typebox.Type.Object({
1512
+ coder_url: __sinclair_typebox.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.` }),
1513
+ prompt: __sinclair_typebox.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.` })
1514
+ }),
1515
+ execute: async (_toolCallId, params) => {
1516
+ const { coder_url, prompt } = params;
1517
+ if (typeof coder_url !== `string` || !coder_url.startsWith(`/coder/`)) return {
1339
1518
  content: [{
1340
1519
  type: `text`,
1341
- text: formatted
1520
+ text: `Error: coder_url must be a path like "/coder/<id>".`
1342
1521
  }],
1343
- details: { resultCount: results.length }
1522
+ details: { sent: false }
1344
1523
  };
1345
- } catch (err) {
1346
- return {
1524
+ if (typeof prompt !== `string` || prompt.length === 0) return {
1347
1525
  content: [{
1348
1526
  type: `text`,
1349
- text: `Search failed: ${err instanceof Error ? err.message : `Unknown error`}`
1527
+ text: `Error: prompt is required and must be a non-empty string.`
1350
1528
  }],
1351
- details: { resultCount: 0 }
1529
+ details: { sent: false }
1352
1530
  };
1531
+ try {
1532
+ ctx.send(coder_url, { text: prompt });
1533
+ return {
1534
+ content: [{
1535
+ type: `text`,
1536
+ text: `Prompt queued for ${coder_url}. End your turn — you'll be woken when the coder's reply lands.`
1537
+ }],
1538
+ details: {
1539
+ sent: true,
1540
+ coderUrl: coder_url
1541
+ }
1542
+ };
1543
+ } catch (err) {
1544
+ serverLog.warn(`[prompt_coder tool] failed to send to ${coder_url}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1545
+ return {
1546
+ content: [{
1547
+ type: `text`,
1548
+ text: `Error sending prompt to coder: ${err instanceof Error ? err.message : `Unknown error`}`
1549
+ }],
1550
+ details: { sent: false }
1551
+ };
1552
+ }
1353
1553
  }
1354
- }
1355
- };
1554
+ };
1555
+ }
1356
1556
 
1357
1557
  //#endregion
1358
1558
  //#region src/agents/horton.ts
1359
1559
  const TITLE_MODEL = `claude-haiku-4-5-20251001`;
1360
- const HORTON_MODEL = `claude-sonnet-4-5-20250929`;
1560
+ const HORTON_MODEL = `claude-sonnet-4-6`;
1361
1561
  let anthropic = null;
1362
1562
  function getClient() {
1363
1563
  if (!anthropic) anthropic = new __anthropic_ai_sdk.default();
@@ -1463,7 +1663,7 @@ function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1463
1663
  const docsGuidance = opts.hasDocsSupport ? `\n- You have built-in Durable Agents docs context plus a docs search tool. Use that before broad web search when the question is about this repo, Electric Agents, or Durable Agents.\n- The docs TOC and docs search results include concrete file paths under the docs tree. Use the normal read tool with those returned paths.\n- Use repo read/bash tools for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
1464
1664
  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.
1465
1665
 
1466
- Some skills are user-invocable — the user can trigger them with a slash command like \`/tutorial\`. 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.
1666
+ 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.
1467
1667
 
1468
1668
  ## IMPORTANT: How to use a loaded skill
1469
1669
 
@@ -1475,8 +1675,33 @@ When you load a skill, it becomes your primary directive for that interaction. F
1475
1675
  4. **Unload when done.** Use remove_skill to free context space when the skill's workflow is complete.
1476
1676
 
1477
1677
  Do NOT load a skill and then ignore its instructions. The skill is there because it contains a tested, specific workflow. Your job is to execute it faithfully.` : ``;
1678
+ const onboardingGuidance = `\n# Onboarding
1679
+ When a user is new or asks how to get started with Electric Agents, **don't assume a single path**. Present the options and let them choose:
1680
+
1681
+ - **Learn the concepts first** → Explain what Electric Agents is, answer questions, point to docs.
1682
+ Use your docs tools or fetch_url. Only load the quickstart skill if the user explicitly asks for a hands-on guided tutorial.
1683
+
1684
+ - **Hands-on guided tutorial** → Load the quickstart skill (or tell them to type \`/quickstart\`).
1685
+ This is a step-by-step build that takes them from zero to a running app.
1686
+ Only load it when the user explicitly wants to build something hands-on.
1687
+
1688
+ - **Scaffold a new project** → Load the init skill.
1689
+ This sets up project structure and orients them in the codebase.
1690
+
1691
+ - **Have a specific question?** → Answer it directly.
1692
+ Use your docs tools, fetch_url, or general coding knowledge.
1693
+
1694
+ Don't force onboarding. If someone just wants to chat or code, let them. When in doubt, ask what they'd like to do rather than picking a path for them.`;
1695
+ const docsUrlGuidance = opts.docsUrl ? `\n# Electric Agents documentation
1696
+ - ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`}
1697
+ - The Electric Agents docs site is at ${opts.docsUrl}
1698
+ - 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).
1699
+ - For general coding questions unrelated to Electric Agents, use brave_search or your own knowledge.` : ``;
1478
1700
  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.
1479
1701
 
1702
+ # Greetings
1703
+ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statement like "I want to learn about Electric Agents", respond warmly and introduce yourself. Briefly explain what you can help with and ask what they'd like to do — don't jump straight into a skill or workflow. Let the user tell you what they need before you start loading skills or running tools.
1704
+
1480
1705
  # Tools
1481
1706
  - bash: run shell commands
1482
1707
  - read: read a file
@@ -1485,13 +1710,15 @@ Do NOT load a skill and then ignore its instructions. The skill is there because
1485
1710
  - brave_search: search the web
1486
1711
  - fetch_url: fetch and convert a URL to markdown
1487
1712
  - spawn_worker: dispatch a subagent for an isolated task
1713
+ - spawn_coder: spawn a long-lived coding agent (Claude Code or Codex CLI) for code changes, file edits, debugging
1714
+ - prompt_coder: send a follow-up prompt to a coder you previously spawned
1488
1715
  ${docsTools}${skillsTools}
1489
1716
 
1490
1717
  # Working with files
1491
1718
  - Prefer edit over write when modifying existing files.
1492
1719
  - You must read a file before you can edit it.
1493
1720
  - Use absolute paths or paths relative to the current working directory.
1494
- ${docsGuidance}${skillsGuidance}
1721
+ ${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
1495
1722
 
1496
1723
  # Risky actions
1497
1724
  Pause and confirm with the user before:
@@ -1512,6 +1739,13 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
1512
1739
 
1513
1740
  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.
1514
1741
 
1742
+ # When to spawn a coder
1743
+ 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.
1744
+
1745
+ 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.
1746
+
1747
+ 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.
1748
+
1515
1749
  # Reporting
1516
1750
  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.
1517
1751
 
@@ -1520,13 +1754,15 @@ The current year is ${new Date().getFullYear()}.`;
1520
1754
  }
1521
1755
  function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
1522
1756
  return [
1523
- createBashTool(workingDirectory),
1524
- createReadFileTool(workingDirectory, readSet),
1525
- createWriteTool(workingDirectory, readSet),
1526
- createEditTool(workingDirectory, readSet),
1527
- braveSearchTool,
1528
- fetchUrlTool,
1757
+ (0, __electric_ax_agents_runtime_tools.createBashTool)(workingDirectory),
1758
+ (0, __electric_ax_agents_runtime_tools.createReadFileTool)(workingDirectory, readSet),
1759
+ (0, __electric_ax_agents_runtime_tools.createWriteTool)(workingDirectory, readSet),
1760
+ (0, __electric_ax_agents_runtime_tools.createEditTool)(workingDirectory, readSet),
1761
+ __electric_ax_agents_runtime_tools.braveSearchTool,
1762
+ __electric_ax_agents_runtime_tools.fetchUrlTool,
1529
1763
  createSpawnWorkerTool(ctx),
1764
+ createSpawnCoderTool(ctx),
1765
+ createPromptCoderTool(ctx),
1530
1766
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1531
1767
  ];
1532
1768
  }
@@ -1542,7 +1778,7 @@ function extractFirstUserMessage(events) {
1542
1778
  return null;
1543
1779
  }
1544
1780
  function createAssistantHandler(options) {
1545
- const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry } = options;
1781
+ const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, docsUrl } = options;
1546
1782
  const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1547
1783
  return async function assistantHandler(ctx, wake) {
1548
1784
  const readSet = new Set();
@@ -1592,7 +1828,8 @@ function createAssistantHandler(options) {
1592
1828
  ctx.useAgent({
1593
1829
  systemPrompt: buildHortonSystemPrompt(workingDirectory, {
1594
1830
  hasDocsSupport: Boolean(docsSupport),
1595
- hasSkills
1831
+ hasSkills,
1832
+ docsUrl
1596
1833
  }),
1597
1834
  model: HORTON_MODEL,
1598
1835
  tools,
@@ -1620,6 +1857,9 @@ function createAssistantHandler(options) {
1620
1857
  }
1621
1858
  function registerHorton(registry, options) {
1622
1859
  const { workingDirectory, streamFn, skillsRegistry = null } = options;
1860
+ const docsUrl = options.docsUrl ?? process.env.HORTON_DOCS_URL;
1861
+ if (process.env.BRAVE_SEARCH_API_KEY) serverLog.info(`[horton] Web search: using Brave Search API`);
1862
+ 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)`);
1623
1863
  const docsSupport = createHortonDocsSupport(workingDirectory);
1624
1864
  const docsSearchTool = docsSupport?.createSearchTool();
1625
1865
  docsSupport?.ensureReady().catch((error) => {
@@ -1630,7 +1870,8 @@ function registerHorton(registry, options) {
1630
1870
  streamFn,
1631
1871
  docsSupport,
1632
1872
  docsSearchTool,
1633
- skillsRegistry
1873
+ skillsRegistry,
1874
+ docsUrl
1634
1875
  });
1635
1876
  registry.define(`horton`, {
1636
1877
  description: `Friendly capable assistant — chat, code, research, dispatch`,
@@ -1684,22 +1925,22 @@ function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
1684
1925
  const out = [];
1685
1926
  for (const name of tools) switch (name) {
1686
1927
  case `bash`:
1687
- out.push(createBashTool(workingDirectory));
1928
+ out.push((0, __electric_ax_agents_runtime_tools.createBashTool)(workingDirectory));
1688
1929
  break;
1689
1930
  case `read`:
1690
- out.push(createReadFileTool(workingDirectory, readSet));
1931
+ out.push((0, __electric_ax_agents_runtime_tools.createReadFileTool)(workingDirectory, readSet));
1691
1932
  break;
1692
1933
  case `write`:
1693
- out.push(createWriteTool(workingDirectory, readSet));
1934
+ out.push((0, __electric_ax_agents_runtime_tools.createWriteTool)(workingDirectory, readSet));
1694
1935
  break;
1695
1936
  case `edit`:
1696
- out.push(createEditTool(workingDirectory, readSet));
1937
+ out.push((0, __electric_ax_agents_runtime_tools.createEditTool)(workingDirectory, readSet));
1697
1938
  break;
1698
1939
  case `brave_search`:
1699
- out.push(braveSearchTool);
1940
+ out.push(__electric_ax_agents_runtime_tools.braveSearchTool);
1700
1941
  break;
1701
1942
  case `fetch_url`:
1702
- out.push(fetchUrlTool);
1943
+ out.push(__electric_ax_agents_runtime_tools.fetchUrlTool);
1703
1944
  break;
1704
1945
  case `spawn_worker`:
1705
1946
  out.push(createSpawnWorkerTool(ctx));
@@ -2116,6 +2357,8 @@ async function createBuiltinAgentHandler(options) {
2116
2357
  streamFn
2117
2358
  });
2118
2359
  typeNames.push(`worker`);
2360
+ registerCodingSession(registry, { defaultWorkingDirectory: cwd });
2361
+ typeNames.push(`coder`);
2119
2362
  const runtime = (0, __electric_ax_agents_runtime.createRuntimeHandler)({
2120
2363
  baseUrl: agentServerUrl,
2121
2364
  serveEndpoint,
@@ -2168,7 +2411,7 @@ var BuiltinAgentsServer = class {
2168
2411
  }
2169
2412
  async start() {
2170
2413
  if (this.server) throw new Error(`Builtin agents server already started`);
2171
- return new Promise((resolve$3, reject) => {
2414
+ return new Promise((resolve, reject) => {
2172
2415
  this.server = (0, node_http.createServer)((req, res) => {
2173
2416
  this.handleRequest(req, res).catch((error) => {
2174
2417
  serverLog.error(`[builtin-agents] unhandled request error`, error);
@@ -2201,7 +2444,7 @@ var BuiltinAgentsServer = class {
2201
2444
  if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY must be set before starting builtin agents`);
2202
2445
  await registerBuiltinAgentTypes(this.bootstrap);
2203
2446
  serverLog.info(`[builtin-agents] webhook handler listening at ${serveEndpoint}`);
2204
- resolve$3(this._url);
2447
+ resolve(this._url);
2205
2448
  } catch (error) {
2206
2449
  await this.stop().catch(() => {});
2207
2450
  reject(error);
@@ -2212,13 +2455,13 @@ var BuiltinAgentsServer = class {
2212
2455
  async stop() {
2213
2456
  if (this.bootstrap) {
2214
2457
  this.bootstrap.runtime.abortWakes();
2215
- await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve$3) => setTimeout(resolve$3, 5e3))]);
2458
+ await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2216
2459
  this.bootstrap = null;
2217
2460
  }
2218
2461
  if (this.server) {
2219
2462
  const server = this.server;
2220
- await new Promise((resolve$3) => {
2221
- server.close(() => resolve$3());
2463
+ await new Promise((resolve) => {
2464
+ server.close(() => resolve());
2222
2465
  });
2223
2466
  this.server = null;
2224
2467
  }
@@ -2227,14 +2470,14 @@ var BuiltinAgentsServer = class {
2227
2470
  }
2228
2471
  async handleRequest(req, res) {
2229
2472
  const method = req.method?.toUpperCase();
2230
- const path$5 = new URL(req.url ?? `/`, `http://localhost`).pathname;
2473
+ const path$6 = new URL(req.url ?? `/`, `http://localhost`).pathname;
2231
2474
  const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
2232
- if (path$5 === `/_electric/health` && method === `GET`) {
2475
+ if (path$6 === `/_electric/health` && method === `GET`) {
2233
2476
  res.writeHead(200, { "content-type": `application/json` });
2234
2477
  res.end(JSON.stringify({ status: `ok` }));
2235
2478
  return;
2236
2479
  }
2237
- if (path$5 === webhookPath && method === `POST` && this.bootstrap) {
2480
+ if (path$6 === webhookPath && method === `POST` && this.bootstrap) {
2238
2481
  await this.bootstrap.handler(req, res);
2239
2482
  return;
2240
2483
  }
@@ -2301,6 +2544,12 @@ exports.BuiltinAgentsServer = BuiltinAgentsServer
2301
2544
  exports.DEFAULT_BUILTIN_AGENT_HANDLER_PATH = DEFAULT_BUILTIN_AGENT_HANDLER_PATH
2302
2545
  exports.HORTON_MODEL = HORTON_MODEL
2303
2546
  exports.WORKER_TOOL_NAMES = WORKER_TOOL_NAMES
2547
+ Object.defineProperty(exports, 'braveSearchTool', {
2548
+ enumerable: true,
2549
+ get: function () {
2550
+ return __electric_ax_agents_runtime_tools.braveSearchTool;
2551
+ }
2552
+ });
2304
2553
  exports.buildHortonSystemPrompt = buildHortonSystemPrompt
2305
2554
  exports.createAgentHandler = createAgentHandler
2306
2555
  exports.createBuiltinAgentHandler = createBuiltinAgentHandler
@@ -2310,6 +2559,7 @@ exports.createSpawnWorkerTool = createSpawnWorkerTool
2310
2559
  exports.generateTitle = generateTitle
2311
2560
  exports.registerAgentTypes = registerAgentTypes
2312
2561
  exports.registerBuiltinAgentTypes = registerBuiltinAgentTypes
2562
+ exports.registerCodingSession = registerCodingSession
2313
2563
  exports.registerHorton = registerHorton
2314
2564
  exports.registerWorker = registerWorker
2315
2565
  exports.resolveBuiltinAgentsEntrypointOptions = resolveBuiltinAgentsEntrypointOptions