talon-agent 3.33.4 → 3.34.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.
Files changed (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -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
+ }
@@ -8,11 +8,7 @@
8
8
  * No platform-specific imports — frontends register their handler at startup.
9
9
  */
10
10
 
11
- import {
12
- createServer,
13
- type IncomingMessage,
14
- type ServerResponse,
15
- } from "node:http";
11
+ import { createServer } from "node:http";
16
12
  import pRetry, { AbortError } from "p-retry";
17
13
  import { classify } from "../errors.js";
18
14
  import { getActiveCount } from "./dispatcher.js";
@@ -26,14 +22,13 @@ import {
26
22
  isChatFreeAction,
27
23
  } from "./gateway-actions/index.js";
28
24
  import { registerCrossSendTarget } from "./gateway-actions/cross-send.js";
25
+ import { getHubSessionCount } from "../mcp-hub/index.js";
29
26
  import {
30
- handleHubRequest,
31
- getHubSessionCount,
32
- HUB_PATH_PREFIX,
33
- } from "../mcp-hub/index.js";
27
+ dispatchGatewayRoute,
28
+ listenWithRetry,
29
+ type GatewayRouteHost,
30
+ } from "./gateway-routes.js";
34
31
  import { handlePluginAction } from "../plugin/index.js";
35
- import { bus } from "../bus/index.js";
36
- import { taskTable } from "../tasks/index.js";
37
32
  import type { FrontendActionHandler } from "../types.js";
38
33
  import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
39
34
  import type { Backend } from "../agent-runtime/capabilities.js";
@@ -362,236 +357,69 @@ export class Gateway {
362
357
 
363
358
  async start(port = 19876): Promise<number> {
364
359
  if (this.server) return this.port;
365
-
366
- const httpServer = createServer(
367
- async (req: IncomingMessage, res: ServerResponse) => {
368
- if (req.method === "GET" && req.url === "/health") {
369
- const w = getHealthStatus();
370
- res.writeHead(200, { "Content-Type": "application/json" });
371
- res.end(
372
- JSON.stringify({
373
- // Identity fields — daemon discovery matches on these to
374
- // distinguish a Talon daemon from chat sessions and
375
- // unrelated localhost services.
376
- app: "talon",
377
- mode: this.mode,
378
- pid: process.pid,
379
- port: this.port,
380
- startedAt: this.startedAt,
381
- ok: w.healthy,
382
- uptime: Math.round(process.uptime()),
383
- memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
384
- bridge: {
385
- activeChats: this.loom.activeContextCount(),
386
- threads: this.loom.size(),
387
- hubSessions: getHubSessionCount(),
388
- },
389
- queue: getActiveCount(),
390
- sessions: getActiveSessionCount(),
391
- messages: w.totalMessagesProcessed,
392
- errors: w.recentErrorCount,
393
- lastActivity:
394
- w.msSinceLastMessage < 60000
395
- ? "just now"
396
- : `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
397
- }),
398
- );
399
- return;
400
- }
401
-
402
- if (req.method === "POST" && req.url === "/shutdown") {
403
- // Graceful stop for `talon stop`/`talon restart`. Bound to
404
- // 127.0.0.1 like everything else here. Respond before
405
- // triggering so the client isn't cut off mid-request; the
406
- // shutdown path takes seconds, so the reply flushes safely.
407
- if (!this.shutdownHandler) {
408
- res.writeHead(501, { "Content-Type": "application/json" });
409
- res.end(
410
- JSON.stringify({
411
- ok: false,
412
- error: "Shutdown not supported by this process",
413
- }),
414
- );
415
- return;
416
- }
417
- res.writeHead(200, { "Content-Type": "application/json" });
418
- res.end(JSON.stringify({ ok: true }));
419
- const handler = this.shutdownHandler;
420
- setImmediate(() => handler("gateway /shutdown"));
421
- return;
422
- }
423
-
424
- if (req.method === "GET" && req.url?.startsWith("/events/recent")) {
425
- // Bus tail — recent events, optionally after a cursor. Read by
426
- // `talon events`. Same 127.0.0.1 trust boundary as /action.
427
- const url = new URL(req.url, "http://gateway");
428
- const since = Number(url.searchParams.get("since") ?? "0");
429
- res.writeHead(200, { "Content-Type": "application/json" });
430
- res.end(
431
- JSON.stringify({
432
- ok: true,
433
- events: bus.recent(
434
- Number.isInteger(since) && since > 0 ? since : 0,
435
- ),
436
- }),
437
- );
438
- return;
439
- }
440
-
441
- if (req.method === "GET" && req.url === "/tasks") {
442
- // The task table — every live/recent unit of agent work. Read by
443
- // `talon ps`. Same 127.0.0.1 trust boundary as /action.
444
- res.writeHead(200, { "Content-Type": "application/json" });
445
- res.end(JSON.stringify({ ok: true, tasks: taskTable.list() }));
446
- return;
447
- }
448
-
449
- if (req.method === "POST" && req.url === "/tasks/kill") {
450
- // Abort one killable task by id — the transport for `talon kill`.
451
- const chunks: Buffer[] = [];
452
- for await (const chunk of req) chunks.push(chunk as Buffer);
453
- let id: unknown;
454
- try {
455
- const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
456
- id = (body as { id?: unknown }).id;
457
- } catch {
458
- res.writeHead(400, { "Content-Type": "application/json" });
459
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
460
- return;
461
- }
462
- if (typeof id !== "number" || !Number.isInteger(id)) {
463
- res.writeHead(400, { "Content-Type": "application/json" });
464
- res.end(
465
- JSON.stringify({ ok: false, error: "id must be an integer" }),
466
- );
467
- return;
468
- }
469
- res.writeHead(200, { "Content-Type": "application/json" });
470
- res.end(JSON.stringify(taskTable.kill(id)));
471
- return;
472
- }
473
-
474
- if (req.method === "POST" && req.url === "/plugins/reload") {
475
- // Hot-reload plugins from config — the transport for
476
- // `talon plugin install/enable/disable`, which has no chat
477
- // context and so cannot use the reload_plugins action. Same
478
- // 127.0.0.1 trust boundary as /action.
479
- try {
480
- const { performPluginReload } =
481
- await import("./gateway-actions/plugins.js");
482
- const { names } = await performPluginReload(this.backend);
483
- log("gateway", `/plugins/reload: ${names.length} plugins loaded`);
484
- res.writeHead(200, { "Content-Type": "application/json" });
485
- res.end(JSON.stringify({ ok: true, loaded: names }));
486
- } catch (err) {
487
- res.writeHead(200, { "Content-Type": "application/json" });
488
- res.end(
489
- JSON.stringify({
490
- ok: false,
491
- error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
492
- }),
493
- );
494
- }
495
- return;
496
- }
497
-
498
- if (req.url?.startsWith(HUB_PATH_PREFIX)) {
499
- // MCP hub — daemon-hosted MCP-over-HTTP endpoints for every
500
- // backend (see core/mcp-hub). Same 127.0.0.1 trust boundary
501
- // as /action.
502
- await handleHubRequest(req, res, `http://127.0.0.1:${this.port}`);
503
- return;
504
- }
505
-
506
- if (req.method !== "POST" || req.url !== "/action") {
507
- res.writeHead(404);
508
- res.end("Not found");
509
- return;
510
- }
511
-
512
- try {
513
- const chunks: Buffer[] = [];
514
- for await (const chunk of req) chunks.push(chunk as Buffer);
515
- let body: Record<string, unknown>;
516
- try {
517
- body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
518
- } catch {
519
- res.writeHead(400, { "Content-Type": "application/json" });
520
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
521
- return;
522
- }
523
- const result = await this.handleAction(body);
524
- const json = JSON.stringify(result);
525
- res.writeHead(200, { "Content-Type": "application/json" });
526
- res.end(json);
527
- } catch (err) {
528
- if (res.headersSent) return;
529
- // Log full error (incl. stack via logError's structured `stack`
530
- // field) on the server; return a generic message to the client so
531
- // we don't leak implementation details.
532
- // CodeQL: js/stack-trace-exposure (alert #4).
533
- logError(
534
- "gateway",
535
- `Unhandled error on ${req.method} ${req.url}`,
536
- err,
537
- );
538
- res.writeHead(500, { "Content-Type": "application/json" });
539
- res.end(
540
- JSON.stringify({ ok: false, error: "Internal server error" }),
541
- );
542
- }
360
+ const host: GatewayRouteHost = {
361
+ healthSnapshot: () => this.healthSnapshot(),
362
+ requestShutdown: () => {
363
+ const handler = this.shutdownHandler;
364
+ if (!handler) return false;
365
+ setImmediate(() => handler("gateway /shutdown"));
366
+ return true;
543
367
  },
368
+ reloadPlugins: async () => {
369
+ const { performPluginReload } =
370
+ await import("./gateway-actions/plugins.js");
371
+ return (await performPluginReload(this.backend)).names;
372
+ },
373
+ hubOrigin: () => `http://127.0.0.1:${this.port}`,
374
+ handleAction: (body) => this.handleAction(body),
375
+ };
376
+ const httpServer = createServer(
377
+ (req, res) => void dispatchGatewayRoute(req, res, host),
544
378
  );
379
+ const bound = await listenWithRetry(httpServer, port);
380
+ this.server = httpServer;
381
+ this.port = bound;
382
+ log("gateway", `Action gateway on :${this.port}`);
383
+ for (const cb of this.startedListeners.splice(0)) {
384
+ try {
385
+ cb(this.port);
386
+ } catch (err) {
387
+ logError("gateway", "onStarted listener failed", err);
388
+ }
389
+ }
390
+ return this.port;
391
+ }
545
392
 
546
- return new Promise<number>((resolve, reject) => {
547
- let attempt = 0;
548
- const tryPort = (p: number) => {
549
- httpServer.once("error", (err: NodeJS.ErrnoException) => {
550
- if (err.code === "EADDRINUSE" && attempt < 5) {
551
- attempt++;
552
- httpServer.removeAllListeners("error");
553
- // The failed listen() left its one-shot 'listening' success
554
- // callback registered (Node attaches it via once('listening')).
555
- // Drop it before retrying — otherwise every stale callback also
556
- // fires when a later port finally binds, running the startup body
557
- // (and logging "Action gateway on :…") once per attempted port.
558
- httpServer.removeAllListeners("listening");
559
- tryPort(p + 1);
560
- } else {
561
- reject(err);
562
- }
563
- });
564
- httpServer.listen(p, "127.0.0.1", () => {
565
- this.server = httpServer;
566
- // When the caller asks for port 0 the OS assigns a random free
567
- // port — read the actual port off the listening socket instead
568
- // of saving the requested 0.
569
- const addr = httpServer.address();
570
- this.port =
571
- typeof addr === "object" && addr !== null
572
- ? (addr as { port: number }).port
573
- : p;
574
- log("gateway", `Action gateway on :${this.port}`);
575
- // Replace the one-shot startup error listener with a persistent
576
- // handler. Without this, once the `once("error")` from port-binding
577
- // fires (and auto-removes itself), any subsequent server-level error
578
- // has no listener and crashes the process via uncaught 'error' event.
579
- httpServer.removeAllListeners("error");
580
- httpServer.on("error", (err) =>
581
- logError("gateway", "HTTP server error", err),
582
- );
583
- for (const cb of this.startedListeners.splice(0)) {
584
- try {
585
- cb(this.port);
586
- } catch (err) {
587
- logError("gateway", "onStarted listener failed", err);
588
- }
589
- }
590
- resolve(this.port);
591
- });
592
- };
593
- tryPort(port);
594
- });
393
+ /**
394
+ * The /health body. Identity fields first — daemon discovery matches on
395
+ * them to tell a Talon daemon from chat sessions and unrelated localhost
396
+ * services — then live counters.
397
+ */
398
+ private healthSnapshot(): Record<string, unknown> {
399
+ const w = getHealthStatus();
400
+ return {
401
+ app: "talon",
402
+ mode: this.mode,
403
+ pid: process.pid,
404
+ port: this.port,
405
+ startedAt: this.startedAt,
406
+ ok: w.healthy,
407
+ uptime: Math.round(process.uptime()),
408
+ memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
409
+ bridge: {
410
+ activeChats: this.loom.activeContextCount(),
411
+ threads: this.loom.size(),
412
+ hubSessions: getHubSessionCount(),
413
+ },
414
+ queue: getActiveCount(),
415
+ sessions: getActiveSessionCount(),
416
+ messages: w.totalMessagesProcessed,
417
+ errors: w.recentErrorCount,
418
+ lastActivity:
419
+ w.msSinceLastMessage < 60000
420
+ ? "just now"
421
+ : `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
422
+ };
595
423
  }
596
424
 
597
425
  async stop(): Promise<void> {