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.
- package/package.json +5 -2
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +33 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/util/watchdog.ts +32 -7
- package/src/frontend/discord/callbacks/components.ts +0 -793
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} from "
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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> {
|