talon-agent 3.33.3 → 3.34.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.
Files changed (75) hide show
  1. package/package.json +6 -5
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/plugins/github/provision.ts +1 -1
  69. package/src/storage/metrics.ts +18 -0
  70. package/src/storage/session-record.ts +29 -0
  71. package/src/storage/sessions.ts +24 -0
  72. package/src/util/boot-timer.ts +31 -0
  73. package/src/util/concurrency.ts +28 -0
  74. package/src/frontend/discord/callbacks/components.ts +0 -793
  75. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -29,6 +29,8 @@ export interface PoolEntry {
29
29
 
30
30
  /** id → entry. */
31
31
  export const pool = new Map<string, PoolEntry>();
32
+ /** id → init in progress; concurrent callers await the same promise. */
33
+ export const initInFlight = new Map<string, Promise<PoolEntry>>();
32
34
  /** holder → backend id (only present for holders currently bound). */
33
35
  export const bindings = new Map<BackendHolder, string>();
34
36
 
@@ -56,6 +58,8 @@ export async function ensurePoolEntry(
56
58
  ): Promise<PoolEntry> {
57
59
  const existing = pool.get(id);
58
60
  if (existing) return existing;
61
+ const inFlight = initInFlight.get(id);
62
+ if (inFlight) return inFlight;
59
63
 
60
64
  const factory = getBackend(id);
61
65
  if (!factory) {
@@ -64,22 +68,31 @@ export async function ensurePoolEntry(
64
68
  .join(", ");
65
69
  throw new Error(`Unknown backend "${id}" — known: ${known}`);
66
70
  }
67
- if (!ctx.initCtx) {
71
+ const initCtx = ctx.initCtx;
72
+ if (!initCtx) {
68
73
  throw new Error(
69
74
  "Backend pool not initialised — call initBackendPool first",
70
75
  );
71
76
  }
72
- const instance = await factory.init(config, ctx.initCtx);
73
- const entry: PoolEntry = {
74
- id: factory.id,
75
- label: factory.label,
76
- backend: instance.backend,
77
- cleanup: instance.cleanup,
78
- holders: new Set(),
79
- };
80
- pool.set(factory.id, entry);
81
- log("backend-controller", `Pool init: ${factory.label} (${factory.id})`);
82
- return entry;
77
+ const init = (async (): Promise<PoolEntry> => {
78
+ const instance = await factory.init(config, initCtx);
79
+ const entry: PoolEntry = {
80
+ id: factory.id,
81
+ label: factory.label,
82
+ backend: instance.backend,
83
+ cleanup: instance.cleanup,
84
+ holders: new Set(),
85
+ };
86
+ pool.set(factory.id, entry);
87
+ log("backend-controller", `Pool init: ${factory.label} (${factory.id})`);
88
+ return entry;
89
+ })();
90
+ initInFlight.set(id, init);
91
+ try {
92
+ return await init;
93
+ } finally {
94
+ initInFlight.delete(id);
95
+ }
83
96
  }
84
97
 
85
98
  /** Drop a holder from an entry; clean up the entry if its refcount hits zero. */
@@ -8,14 +8,12 @@ import {
8
8
  getCronJobsForChat,
9
9
  updateCronJob,
10
10
  deleteCronJob,
11
- validateCronExpression,
12
11
  generateCronId,
13
12
  describeSchedule,
14
13
  nextRunAt,
15
- type CronJobType,
16
- type CatchupPolicy,
17
14
  } from "../../../storage/cron-store.js";
18
15
  import { runJobNow } from "../../background/cron.js";
16
+ import { parseCronSpec } from "../../background/cron-spec.js";
19
17
  import { log } from "../../../util/log.js";
20
18
  import type { Backend } from "../../agent-runtime/capabilities.js";
21
19
  import {
@@ -27,31 +25,6 @@ import {
27
25
  import { validateJobModelOverride } from "./shared.js";
28
26
  import type { SharedActionHandlers } from "./types.js";
29
27
 
30
- // ── Scheduler field parsing (shared by create/edit cron) ────────────────────
31
-
32
- /** The scheduler ticks once a minute, so sub-minute intervals are meaningless. */
33
- const MIN_INTERVAL_SECONDS = 60;
34
- const CATCHUP_POLICIES = new Set<CatchupPolicy>(["skip", "once", "all"]);
35
-
36
- /** True for a body field that was actually supplied (not absent/blank). */
37
- function provided(v: unknown): boolean {
38
- return v !== undefined && v !== null && v !== "";
39
- }
40
-
41
- /**
42
- * Parse an instant given as an ISO-8601 string or epoch-ms number into epoch
43
- * ms. Returns undefined when the field is absent or unparseable — callers
44
- * distinguish the two via `provided()`.
45
- */
46
- function parseInstant(v: unknown): number | undefined {
47
- if (!provided(v)) return undefined;
48
- if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
49
- const s = String(v).trim();
50
- if (/^\d+$/.test(s)) return Number(s);
51
- const d = Date.parse(s);
52
- return Number.isFinite(d) ? d : undefined;
53
- }
54
-
55
28
  /**
56
29
  * Validate a cron `query` job's model + optional provider override. Cron runs
57
30
  * isolated, so unlike triggers it may target a different provider — the backend
@@ -113,147 +86,38 @@ function validateCronBackgroundCapability(
113
86
 
114
87
  export const cronHandlers: SharedActionHandlers = {
115
88
  create_cron_job: async (body, chatId) => {
116
- const name = String(body.name ?? "Unnamed job");
117
- const jobType = (body.type as CronJobType) ?? "message";
118
- const content = String(body.content ?? "");
119
- const timezone = body.timezone ? String(body.timezone) : undefined;
120
- const model = body.model ? String(body.model) : undefined;
121
- const provider = body.provider ? String(body.provider) : undefined;
122
- const instructions = body.instructions
123
- ? String(body.instructions)
124
- : undefined;
125
-
126
- if (!content) return { ok: false, error: "Missing content" };
127
- if (content.length > 10_000)
128
- return { ok: false, error: "Content too long (max 10,000 chars)" };
129
-
130
- // Cadence: exactly one of `schedule` (cron expression) or
131
- // `every_seconds` (fixed interval).
132
- const schedule = provided(body.schedule)
133
- ? String(body.schedule)
134
- : undefined;
135
- const hasEvery = provided(body.every_seconds);
136
- if (!schedule && !hasEvery)
137
- return {
138
- ok: false,
139
- error:
140
- "Provide either 'schedule' (a cron expression) or 'every_seconds' (a fixed interval).",
141
- };
142
- if (schedule && hasEvery)
143
- return {
144
- ok: false,
145
- error: "Provide only one of 'schedule' or 'every_seconds', not both.",
146
- };
147
-
148
- let everyMs: number | undefined;
149
- if (hasEvery) {
150
- const everySeconds = Number(body.every_seconds);
151
- if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
152
- return {
153
- ok: false,
154
- error: `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS} (the scheduler ticks once a minute).`,
155
- };
156
- everyMs = Math.round(everySeconds * 1000);
157
- }
158
-
159
- if (schedule) {
160
- const validation = validateCronExpression(schedule, timezone);
161
- if (!validation.valid)
162
- return {
163
- ok: false,
164
- error: `Invalid cron expression: ${validation.error}`,
165
- };
166
- }
167
-
168
- // Lifecycle bounds.
169
- const startAt = parseInstant(body.start_at);
170
- if (provided(body.start_at) && startAt === undefined)
171
- return {
172
- ok: false,
173
- error:
174
- "Could not parse 'start_at' (use an ISO-8601 timestamp or epoch ms).",
175
- };
176
- const endAt = parseInstant(body.end_at);
177
- if (provided(body.end_at) && endAt === undefined)
178
- return {
179
- ok: false,
180
- error:
181
- "Could not parse 'end_at' (use an ISO-8601 timestamp or epoch ms).",
182
- };
183
- if (startAt !== undefined && endAt !== undefined && endAt <= startAt)
184
- return { ok: false, error: "'end_at' must be after 'start_at'." };
185
- if (endAt !== undefined && endAt <= Date.now())
186
- return {
187
- ok: false,
188
- error: "'end_at' is in the past — the job would never run.",
189
- };
190
-
191
- // Run cap. `once: true` is sugar for max_runs = 1 (one-shot).
192
- let maxRuns: number | undefined;
193
- if (body.once === true) maxRuns = 1;
194
- else if (provided(body.max_runs)) {
195
- const m = Number(body.max_runs);
196
- if (!Number.isInteger(m) || m < 1)
197
- return { ok: false, error: "'max_runs' must be a positive integer." };
198
- maxRuns = m;
199
- }
200
-
201
- // Missed-run catch-up policy. New jobs default to "once": a run that
202
- // came due while Talon was down (or while the scheduler was wedged)
203
- // replays a single time at startup instead of being lost silently — a
204
- // live audit found one-shot reminders that missed their date under the
205
- // old "skip" default and quietly rolled over a full year. Explicit
206
- // "skip" remains available for jobs where a late run is worthless.
207
- let catchup: CatchupPolicy = "once";
208
- if (provided(body.catchup)) {
209
- catchup = String(body.catchup) as CatchupPolicy;
210
- if (!CATCHUP_POLICIES.has(catchup))
211
- return {
212
- ok: false,
213
- error: "'catchup' must be one of: skip, once, all.",
214
- };
215
- }
216
-
217
- // The overrides only make sense for "query" jobs (a "message" job just
218
- // sends text — no model runs).
219
- if ((model || provider || instructions) && jobType !== "query")
220
- return {
221
- ok: false,
222
- error: "Model/provider/instructions only apply to 'query' jobs.",
223
- };
224
- if (provider && !model)
225
- return {
226
- ok: false,
227
- error: "A 'provider' override also requires a 'model'.",
228
- };
89
+ const parsed = parseCronSpec(body);
90
+ if (!parsed.ok) return { ok: false, error: parsed.error };
91
+ const spec = parsed.effective;
92
+ const {
93
+ name,
94
+ type: jobType,
95
+ schedule,
96
+ everyMs,
97
+ startAt,
98
+ endAt,
99
+ maxRuns,
100
+ } = spec;
229
101
 
230
102
  // Validate the target backend/model up front so a bad id or unsupported
231
103
  // backend is rejected here instead of silently failing at fire time.
232
104
  if (jobType === "query") {
233
- const modelErr = await validateCronModelOverride(chatId, model, provider);
105
+ const modelErr = await validateCronModelOverride(
106
+ chatId,
107
+ spec.model,
108
+ spec.provider,
109
+ );
234
110
  if (modelErr) return { ok: false, error: modelErr };
235
111
  }
236
112
 
237
113
  const id = generateCronId();
238
114
  addCronJob({
115
+ ...spec,
239
116
  id,
240
117
  chatId: String(chatId),
241
- type: jobType,
242
- content,
243
- name,
244
118
  enabled: true,
245
119
  createdAt: Date.now(),
246
120
  runCount: 0,
247
- timezone,
248
- ...(schedule ? { schedule } : {}),
249
- ...(everyMs !== undefined ? { everyMs } : {}),
250
- ...(startAt !== undefined ? { startAt } : {}),
251
- ...(endAt !== undefined ? { endAt } : {}),
252
- ...(maxRuns !== undefined ? { maxRuns } : {}),
253
- catchup,
254
- ...(model ? { model } : {}),
255
- ...(provider ? { provider } : {}),
256
- ...(instructions ? { instructions } : {}),
257
121
  });
258
122
  log(
259
123
  "gateway",
@@ -268,7 +132,7 @@ export const cronHandlers: SharedActionHandlers = {
268
132
  ? `starts: ${new Date(startAt).toISOString()}`
269
133
  : null,
270
134
  endAt !== undefined ? `ends: ${new Date(endAt).toISOString()}` : null,
271
- catchup ? `catch-up: ${catchup}` : null,
135
+ spec.catchup ? `catch-up: ${spec.catchup}` : null,
272
136
  ]
273
137
  .filter(Boolean)
274
138
  .join(", ");
@@ -335,157 +199,29 @@ export const cronHandlers: SharedActionHandlers = {
335
199
  if (job.chatId !== String(chatId))
336
200
  return { ok: false, error: "Job belongs to a different chat" };
337
201
 
338
- const updates: Record<string, unknown> = {};
339
- if (body.name !== undefined) updates.name = String(body.name);
340
- if (body.content !== undefined) updates.content = String(body.content);
341
- if (body.enabled !== undefined) updates.enabled = Boolean(body.enabled);
342
- if (body.type !== undefined) updates.type = String(body.type);
343
- if (body.timezone !== undefined)
344
- updates.timezone = body.timezone ? String(body.timezone) : undefined;
345
-
346
- // Cadence — schedule and every_seconds are mutually exclusive; setting
347
- // one switches mode and clears the other.
348
- const editSchedule = body.schedule !== undefined;
349
- const editEvery = provided(body.every_seconds);
350
- if (editSchedule && provided(body.schedule) && editEvery)
351
- return {
352
- ok: false,
353
- error: "Provide only one of 'schedule' or 'every_seconds', not both.",
354
- };
355
- if (editSchedule && provided(body.schedule)) {
356
- const tz = (updates.timezone as string | undefined) ?? job.timezone;
357
- const v = validateCronExpression(String(body.schedule), tz);
358
- if (!v.valid)
359
- return { ok: false, error: `Invalid cron expression: ${v.error}` };
360
- updates.schedule = String(body.schedule);
361
- updates.everyMs = undefined;
362
- }
363
- if (editEvery) {
364
- const everySeconds = Number(body.every_seconds);
365
- if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
366
- return {
367
- ok: false,
368
- error: `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS}.`,
369
- };
370
- updates.everyMs = Math.round(everySeconds * 1000);
371
- updates.schedule = undefined;
372
- }
202
+ const parsed = parseCronSpec(body, job);
203
+ if (!parsed.ok) return { ok: false, error: parsed.error };
204
+ const { updates, effective } = parsed;
373
205
 
374
- // Lifecycle bounds (pass null/"" to clear).
375
- if (body.start_at !== undefined) {
376
- if (!provided(body.start_at)) updates.startAt = undefined;
377
- else {
378
- const s = parseInstant(body.start_at);
379
- if (s === undefined)
380
- return { ok: false, error: "Could not parse 'start_at'." };
381
- updates.startAt = s;
382
- }
383
- }
384
- if (body.end_at !== undefined) {
385
- if (!provided(body.end_at)) updates.endAt = undefined;
386
- else {
387
- const e = parseInstant(body.end_at);
388
- if (e === undefined)
389
- return { ok: false, error: "Could not parse 'end_at'." };
390
- if (e <= Date.now())
391
- return {
392
- ok: false,
393
- error: "'end_at' is in the past — the job would never run.",
394
- };
395
- updates.endAt = e;
396
- }
397
- }
398
- if (body.once === true) updates.maxRuns = 1;
399
- else if (body.max_runs !== undefined) {
400
- if (!provided(body.max_runs)) updates.maxRuns = undefined;
401
- else {
402
- const m = Number(body.max_runs);
403
- if (!Number.isInteger(m) || m < 1)
404
- return {
405
- ok: false,
406
- error: "'max_runs' must be a positive integer.",
407
- };
408
- updates.maxRuns = m;
409
- }
410
- }
411
- if (body.catchup !== undefined) {
412
- const c = String(body.catchup) as CatchupPolicy;
413
- if (!CATCHUP_POLICIES.has(c))
414
- return {
415
- ok: false,
416
- error: "'catchup' must be one of: skip, once, all.",
417
- };
418
- updates.catchup = c;
419
- }
420
- if (body.model !== undefined) {
421
- updates.model = provided(body.model) ? String(body.model) : undefined;
422
- }
423
- if (body.provider !== undefined) {
424
- updates.provider = provided(body.provider)
425
- ? String(body.provider)
426
- : undefined;
427
- }
428
- if (body.instructions !== undefined) {
429
- updates.instructions = provided(body.instructions)
430
- ? String(body.instructions)
431
- : undefined;
432
- }
433
-
434
- // Query execution overrides only apply to 'query' jobs — mirror create.
435
206
  // Validate the effective post-edit target so provider+model edits are
436
207
  // checked together using cron's cross-provider rules.
437
- const effType = (updates.type as CronJobType | undefined) ?? job.type;
438
- const effModel =
439
- "model" in updates ? (updates.model as string | undefined) : job.model;
440
- const effProvider =
441
- "provider" in updates
442
- ? (updates.provider as string | undefined)
443
- : job.provider;
444
- const effInstructions =
445
- "instructions" in updates
446
- ? (updates.instructions as string | undefined)
447
- : job.instructions;
448
- if (effType !== "query" && (effModel || effProvider || effInstructions))
449
- return {
450
- ok: false,
451
- error: "Model/provider/instructions only apply to 'query' jobs.",
452
- };
453
- if (effProvider && !effModel)
454
- return {
455
- ok: false,
456
- error: "A 'provider' override also requires a 'model'.",
457
- };
458
208
  if (
459
- effType === "query" &&
209
+ effective.type === "query" &&
460
210
  ("model" in updates || "provider" in updates || "type" in updates)
461
211
  ) {
462
212
  const modelErr = await validateCronModelOverride(
463
213
  chatId,
464
- effModel,
465
- effProvider,
214
+ effective.model,
215
+ effective.provider,
466
216
  );
467
217
  if (modelErr) return { ok: false, error: modelErr };
468
218
  }
469
219
 
470
- // Reject a start/end window that can never fire, accounting for the merge.
471
- const effStart =
472
- "startAt" in updates
473
- ? (updates.startAt as number | undefined)
474
- : job.startAt;
475
- const effEnd =
476
- "endAt" in updates ? (updates.endAt as number | undefined) : job.endAt;
477
- if (effStart !== undefined && effEnd !== undefined && effEnd <= effStart)
478
- return { ok: false, error: "'end_at' must be after 'start_at'." };
479
-
480
220
  // If a lowered run cap already met/exceeds runCount, retire the job now so
481
221
  // it can't sneak one more run before enforceRunCap catches it post-run.
482
- const effMaxRuns =
483
- "maxRuns" in updates
484
- ? (updates.maxRuns as number | undefined)
485
- : job.maxRuns;
486
222
  if (
487
- effMaxRuns !== undefined &&
488
- job.runCount >= effMaxRuns &&
223
+ effective.maxRuns !== undefined &&
224
+ job.runCount >= effective.maxRuns &&
489
225
  updates.enabled !== true
490
226
  )
491
227
  updates.enabled = false;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The action gateway's HTTP routes — declared once, in order, rather than
3
+ * implied by where an `if` sits in `Gateway.start`. Every route shares the
4
+ * 127.0.0.1 trust boundary; the table is the list a reviewer reads.
5
+ */
6
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
7
+ import { bus } from "../bus/index.js";
8
+ import { taskTable } from "../tasks/index.js";
9
+ import { handleHubRequest, HUB_PATH_PREFIX } from "../mcp-hub/index.js";
10
+ import { log, logError } from "../../util/log.js";
11
+
12
+ /** What the routes need from the Gateway that owns them. */
13
+ export type GatewayRouteHost = {
14
+ /** The /health body — identity fields plus live counters. */
15
+ healthSnapshot: () => Record<string, unknown>;
16
+ /** Schedule a graceful stop; false when this process cannot be stopped this way. */
17
+ requestShutdown: () => boolean;
18
+ /** Hot-reload plugins from config; resolves to the loaded plugin names. */
19
+ reloadPlugins: () => Promise<string[]>;
20
+ /** Origin the MCP hub advertises for its own endpoints. */
21
+ hubOrigin: () => string;
22
+ /** The /action body handler. */
23
+ handleAction: (body: Record<string, unknown>) => Promise<unknown>;
24
+ };
25
+
26
+ type RouteContext = {
27
+ req: IncomingMessage;
28
+ res: ServerResponse;
29
+ url: URL;
30
+ host: GatewayRouteHost;
31
+ };
32
+
33
+ type GatewayRoute = {
34
+ method: "GET" | "POST" | "ANY";
35
+ path: string;
36
+ /** `prefix` matches `path` as a leading segment; default is an exact match. */
37
+ match?: "prefix";
38
+ handle: (ctx: RouteContext) => void | Promise<void>;
39
+ };
40
+
41
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
42
+ // Serialise before writing headers: a body that cannot be stringified
43
+ // must surface as a 500, not as a half-sent 200 that never ends.
44
+ const json = JSON.stringify(body);
45
+ res.writeHead(status, { "Content-Type": "application/json" });
46
+ res.end(json);
47
+ }
48
+
49
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
50
+ const chunks: Buffer[] = [];
51
+ for await (const chunk of req) chunks.push(chunk as Buffer);
52
+ return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
53
+ }
54
+
55
+ const ROUTES: readonly GatewayRoute[] = [
56
+ {
57
+ method: "GET",
58
+ path: "/health",
59
+ handle: ({ res, host }) => sendJson(res, 200, host.healthSnapshot()),
60
+ },
61
+ {
62
+ // Graceful stop for `talon stop`/`talon restart`. Respond before
63
+ // triggering so the client isn't cut off mid-request; the shutdown
64
+ // path takes seconds, so the reply flushes safely.
65
+ method: "POST",
66
+ path: "/shutdown",
67
+ handle: ({ res, host }) => {
68
+ if (!host.requestShutdown()) {
69
+ sendJson(res, 501, {
70
+ ok: false,
71
+ error: "Shutdown not supported by this process",
72
+ });
73
+ return;
74
+ }
75
+ sendJson(res, 200, { ok: true });
76
+ },
77
+ },
78
+ {
79
+ // Bus tail — recent events, optionally after a cursor. Read by `talon events`.
80
+ method: "GET",
81
+ path: "/events/recent",
82
+ match: "prefix",
83
+ handle: ({ res, url }) => {
84
+ const since = Number(url.searchParams.get("since") ?? "0");
85
+ sendJson(res, 200, {
86
+ ok: true,
87
+ events: bus.recent(Number.isInteger(since) && since > 0 ? since : 0),
88
+ });
89
+ },
90
+ },
91
+ {
92
+ // The task table — every live/recent unit of agent work. Read by `talon ps`.
93
+ method: "GET",
94
+ path: "/tasks",
95
+ handle: ({ res }) =>
96
+ sendJson(res, 200, { ok: true, tasks: taskTable.list() }),
97
+ },
98
+ {
99
+ // Abort one killable task by id — the transport for `talon kill`.
100
+ method: "POST",
101
+ path: "/tasks/kill",
102
+ handle: async ({ req, res }) => {
103
+ let id: unknown;
104
+ try {
105
+ id = ((await readJsonBody(req)) as { id?: unknown }).id;
106
+ } catch {
107
+ sendJson(res, 400, { ok: false, error: "Invalid JSON" });
108
+ return;
109
+ }
110
+ if (typeof id !== "number" || !Number.isInteger(id)) {
111
+ sendJson(res, 400, { ok: false, error: "id must be an integer" });
112
+ return;
113
+ }
114
+ sendJson(res, 200, taskTable.kill(id));
115
+ },
116
+ },
117
+ {
118
+ // Hot-reload plugins from config — the transport for `talon plugin
119
+ // install/enable/disable`, which has no chat context and so cannot use
120
+ // the reload_plugins action.
121
+ method: "POST",
122
+ path: "/plugins/reload",
123
+ handle: async ({ res, host }) => {
124
+ try {
125
+ const loaded = await host.reloadPlugins();
126
+ log("gateway", `/plugins/reload: ${loaded.length} plugins loaded`);
127
+ sendJson(res, 200, { ok: true, loaded });
128
+ } catch (err) {
129
+ sendJson(res, 200, {
130
+ ok: false,
131
+ error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
132
+ });
133
+ }
134
+ },
135
+ },
136
+ {
137
+ // MCP hub — daemon-hosted MCP-over-HTTP endpoints for every backend
138
+ // (see core/mcp-hub).
139
+ method: "ANY",
140
+ path: HUB_PATH_PREFIX,
141
+ match: "prefix",
142
+ handle: ({ req, res, host }) =>
143
+ handleHubRequest(req, res, host.hubOrigin()),
144
+ },
145
+ {
146
+ method: "POST",
147
+ path: "/action",
148
+ handle: async ({ req, res, host }) => {
149
+ let body: Record<string, unknown>;
150
+ try {
151
+ body = (await readJsonBody(req)) as Record<string, unknown>;
152
+ } catch {
153
+ sendJson(res, 400, { ok: false, error: "Invalid JSON" });
154
+ return;
155
+ }
156
+ sendJson(res, 200, await host.handleAction(body));
157
+ },
158
+ },
159
+ ];
160
+
161
+ function matches(route: GatewayRoute, req: IncomingMessage): boolean {
162
+ if (route.method !== "ANY" && req.method !== route.method) return false;
163
+ const url = req.url ?? "";
164
+ return route.match === "prefix"
165
+ ? url.startsWith(route.path)
166
+ : url === route.path;
167
+ }
168
+
169
+ /** Serve one request: first matching route wins; nothing matches → 404. */
170
+ export async function dispatchGatewayRoute(
171
+ req: IncomingMessage,
172
+ res: ServerResponse,
173
+ host: GatewayRouteHost,
174
+ ): Promise<void> {
175
+ const route = ROUTES.find((candidate) => matches(candidate, req));
176
+ if (!route) {
177
+ res.writeHead(404);
178
+ res.end("Not found");
179
+ return;
180
+ }
181
+ try {
182
+ await route.handle({
183
+ req,
184
+ res,
185
+ url: new URL(req.url ?? "/", "http://gateway"),
186
+ host,
187
+ });
188
+ } catch (err) {
189
+ if (res.headersSent) return;
190
+ // Log full error (incl. stack via logError's structured `stack` field)
191
+ // on the server; return a generic message to the client so we don't
192
+ // leak implementation details. CodeQL: js/stack-trace-exposure.
193
+ logError("gateway", `Unhandled error on ${req.method} ${req.url}`, err);
194
+ sendJson(res, 500, { ok: false, error: "Internal server error" });
195
+ }
196
+ }
197
+
198
+ const PORT_RETRIES = 5;
199
+
200
+ /**
201
+ * Bind to 127.0.0.1, walking up from `port` on EADDRINUSE (at most
202
+ * `PORT_RETRIES` times). Resolves with the port actually bound — `port`
203
+ * 0 asks the OS for a free one — and leaves a persistent error handler
204
+ * on the server, so a later server-level error is logged instead of
205
+ * crashing the process via an unhandled 'error' event.
206
+ */
207
+ export function listenWithRetry(server: Server, port: number): Promise<number> {
208
+ return new Promise<number>((resolve, reject) => {
209
+ let attempt = 0;
210
+ const tryPort = (candidate: number): void => {
211
+ server.once("error", (err: NodeJS.ErrnoException) => {
212
+ if (err.code === "EADDRINUSE" && attempt < PORT_RETRIES) {
213
+ attempt++;
214
+ server.removeAllListeners("error");
215
+ // The failed listen() left its one-shot 'listening' callback
216
+ // registered; drop it or every stale callback fires when a later
217
+ // port finally binds.
218
+ server.removeAllListeners("listening");
219
+ tryPort(candidate + 1);
220
+ } else {
221
+ reject(err);
222
+ }
223
+ });
224
+ server.listen(candidate, "127.0.0.1", () => {
225
+ const addr = server.address();
226
+ const bound =
227
+ typeof addr === "object" && addr !== null
228
+ ? (addr as { port: number }).port
229
+ : candidate;
230
+ server.removeAllListeners("error");
231
+ server.on("error", (err) =>
232
+ logError("gateway", "HTTP server error", err),
233
+ );
234
+ resolve(bound);
235
+ });
236
+ };
237
+ tryPort(port);
238
+ });
239
+ }