talon-agent 3.33.4 → 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 (74) hide show
  1. package/package.json +2 -1
  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/storage/metrics.ts +18 -0
  69. package/src/storage/session-record.ts +29 -0
  70. package/src/storage/sessions.ts +24 -0
  71. package/src/util/boot-timer.ts +31 -0
  72. package/src/util/concurrency.ts +28 -0
  73. package/src/frontend/discord/callbacks/components.ts +0 -793
  74. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -27,145 +27,23 @@ import { stat } from "node:fs/promises";
27
27
  import { extname } from "node:path";
28
28
  import { log, logError, logDebug, logWarn } from "../../util/log.js";
29
29
  import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
30
+ import { type BridgeEvent } from "./protocol.js";
31
+ import { buildRoutes } from "./routes/index.js";
32
+ import type { BridgeServerHandlers, RouteHost } from "./routes/host.js";
30
33
  import {
31
- BRIDGE_PROTOCOL_VERSION,
32
- isLogLevel,
33
- type BackendOption,
34
- type BridgeEvent,
35
- type BridgeStatus,
36
- type ClientChat,
37
- type ClientMessage,
38
- type DeviceInfo,
39
- type DeviceLocation,
40
- type LogEntry,
41
- type LogLevel,
42
- type ModelOption,
43
- type PluginItem,
44
- type SearchResult,
45
- type SkillItem,
46
- type ToggleResult,
47
- } from "./protocol.js";
48
- import type { ConfigSnapshot } from "./settings.js";
34
+ BRIDGE_ROUTE_AUTH,
35
+ type AuthState,
36
+ type BridgeRouteKey,
37
+ type RouteContext,
38
+ type RouteHandler,
39
+ } from "./routes/table.js";
49
40
 
50
- /** Optional attachment references carried alongside a sent message. */
51
- type SendOptions = {
52
- /** Relative bridge path to render inline (e.g. `/media?id=…`). */
53
- imagePath?: string;
54
- /** Absolute on-disk path handed to the model so it can read the file. */
55
- attachmentPath?: string;
56
- };
57
-
58
- /** Everything the transport needs the frontend to implement. */
59
- export type BridgeServerHandlers = {
60
- status(): BridgeStatus;
61
- listChats(): ClientChat[];
62
- createChat(title?: string): ClientChat;
63
- renameChat(id: string, title: string): ClientChat | null;
64
- deleteChat(id: string): boolean;
65
- /** A page of history: newest window, or the window before `before`. */
66
- history(
67
- id: string,
68
- opts?: { before?: number; limit?: number },
69
- ): ClientMessage[];
70
- /** Full-text search across chats (or one chat when `chatId` is given). */
71
- search(query: string, chatId?: string): SearchResult[];
72
- /** Fire-and-forget: streams its results back through `broadcast`. */
73
- send(id: string, text: string, opts?: SendOptions): void;
74
- /** Persist an uploaded image and return its render path + on-disk path. */
75
- upload(
76
- filename: string,
77
- contentType: string,
78
- bytes: Buffer,
79
- ): Promise<{ imagePath: string; path: string }>;
80
- listModels(
81
- id?: string,
82
- ):
83
- | { active: string; models: ModelOption[] }
84
- | Promise<{ active: string; models: ModelOption[] }>;
85
- setModel(id: string, model: string): void;
86
- /** Backends selectable for a chat + the chat's active backend id. */
87
- listBackends(id: string): { active: string; backends: BackendOption[] };
88
- /** Switch a chat to another backend; returns ok + an optional error. */
89
- setBackend(
90
- id: string,
91
- backend: string,
92
- ): Promise<{ ok: boolean; error?: string }>;
93
- setEffort(id: string, effort: string): void;
94
- effortLevels(id: string): Promise<{ active: string; levels: string[] }>;
95
- resetChat(id: string): boolean;
96
- /** Best-effort interrupt of a chat's in-flight turn. `true` if one was
97
- * running and got signalled. */
98
- interruptTurn(id: string): Promise<boolean>;
99
- setPulse(id: string, on: boolean): void;
100
- /** Set/replace/clear the chat's queued follow-up (empty text clears). */
101
- queueMessage(id: string, text: string): void;
102
- /** Read the daemon's own (allowlisted) settings + health. */
103
- getConfig(): ConfigSnapshot;
104
- /** Change daemon settings; returns the fresh snapshot. */
105
- setConfig(update: Record<string, unknown>): ConfigSnapshot;
106
- /** Installed plugins (built-ins + configured entries) with state. */
107
- listPlugins(): PluginItem[];
108
- /** Enable/disable a plugin; persists + hot-reloads. */
109
- setPluginEnabled(name: string, enabled: boolean): Promise<ToggleResult>;
110
- /** Installed skills with state. */
111
- listSkills(): SkillItem[];
112
- /** Enable/disable a skill; rebuilds the prompt index. */
113
- setSkillEnabled(name: string, enabled: boolean): ToggleResult;
114
- /** Fire a daemon-level control action (e.g. "restart", "dream"). */
115
- control(action: string): Promise<{ ok: boolean; message: string }>;
116
- /** Newest daemon log entries (for the client's log viewer). */
117
- logs(opts: {
118
- lines: number;
119
- minLevel?: LogLevel;
120
- component?: string;
121
- }): LogEntry[];
122
- /** Events reconstructing any in-progress turns, for a just-connected client. */
123
- liveTurnEvents(): BridgeEvent[];
124
- /** Resolve a media id to an absolute file path (or null if unknown). */
125
- mediaPath(id: string): string | null;
126
- /** Register/update one mesh device. */
127
- registerDevice(body: Record<string, unknown>): Promise<DeviceInfo>;
128
- /** Store the last-known location for one mesh device. */
129
- storeLocation(body: Record<string, unknown>): Promise<DeviceLocation>;
130
- /** List mesh devices and their last-known locations. */
131
- listDevices():
132
- | { devices: DeviceInfo[]; locations: DeviceLocation[] }
133
- | Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
134
- /** A device answered a device_command; true when a call was waiting. */
135
- completeCommand(body: Record<string, unknown>): boolean;
136
- /** A device streams a pull-transfer's file body up (raw request body).
137
- * `fromDeviceId` is the caller's claimed identity, when it sent one. */
138
- acceptFileUpload(
139
- token: string,
140
- body: IncomingMessage,
141
- fromDeviceId?: string,
142
- ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }>;
143
- /** Resolve a push-transfer token to the file to stream down, or null. */
144
- openFileDownload(
145
- token: string,
146
- fromDeviceId?: string,
147
- ): Promise<{ path: string; size: number } | null>;
148
- /** Resolve a companion-pairing grant to its page/payload, or null. */
149
- openCompanionPair(
150
- token: string,
151
- format: "html" | "json",
152
- ): { contentType: string; body: string } | null;
153
- /** Resolve a node-provisioning token to its installer script, or null. */
154
- openNodeInstall(token: string): { script: string; filename: string } | null;
155
- /** Resolve a node-provisioning token to the binary to stream, or null. */
156
- openNodeBinary(token: string): { path: string; size: number } | null;
157
- };
41
+ export type { BridgeServerHandlers } from "./routes/host.js";
42
+ export { BRIDGE_ROUTE_AUTH, type BridgeRouteKey } from "./routes/table.js";
158
43
 
159
44
  const SSE_PING_MS = 25_000;
160
45
  const MAX_BODY_BYTES = 256 * 1024;
161
- const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
162
46
  const PORT_FALLBACKS = 5;
163
- /**
164
- * Longest device id a client may claim (`?deviceId=…`). Matches the
165
- * registry's own id cap — a longer id can never name a real device, and the
166
- * claim is held for the life of a connection, so it stays a bounded key.
167
- */
168
- const MAX_DEVICE_ID_CHARS = 128;
169
47
 
170
48
  // Failed-auth lockout: after this many wrong tokens from one address inside
171
49
  // the window, that address gets 429s until the window lapses. The token's
@@ -178,94 +56,6 @@ const AUTH_LOCKOUT_WINDOW_MS = 15 * 60 * 1000;
178
56
  /** Hard cap on tracked addresses so the map can't become a memory lever. */
179
57
  const AUTH_LOCKOUT_MAX_TRACKED = 10_000;
180
58
 
181
- /**
182
- * ok: request carries the right token (or none is required).
183
- * anonymous: no credential presented — a pre-pairing probe, not an attack.
184
- * bad: a credential was presented and it is wrong.
185
- */
186
- type AuthState = "ok" | "anonymous" | "bad";
187
-
188
- /**
189
- * "public": served without a bearer token. Every entry is gated some other
190
- * way — a single-use grant minted by the daemon, or (for /health) by
191
- * answering only what pairing needs until a token is presented.
192
- * "bearer": the request must carry the bridge token.
193
- */
194
- export type BridgeRouteAuth = "public" | "bearer";
195
-
196
- /**
197
- * The bridge's routes and the auth tier of each — declared once, here,
198
- * rather than implied by where an `if` sits relative to the auth check.
199
- * The security posture of the transport is this table: a route is
200
- * pre-auth only by appearing in it as "public", and the route test walks
201
- * every entry and proves the tier holds on the wire. Adding a route
202
- * without an entry is a type error (`buildRoutes` is exhaustive over
203
- * these keys); adding one as "public" is a diff a reviewer sees.
204
- */
205
- export const BRIDGE_ROUTE_AUTH = {
206
- // Pre-auth by design. /health serves pairing data (identity, protocol,
207
- // fingerprint) to anyone and the operational view only to a token
208
- // holder. /pair, /node/install and /node/binary hand over a credential
209
- // to a device that holds none yet; the single-use grant in the query is
210
- // the entire authorization.
211
- "GET /health": "public",
212
- "GET /pair": "public",
213
- "GET /node/install": "public",
214
- "GET /node/binary": "public",
215
-
216
- // Everything a client can do once paired.
217
- "GET /events": "bearer",
218
- "GET /chats": "bearer",
219
- "POST /chats": "bearer",
220
- "POST /chats/rename": "bearer",
221
- "POST /chats/delete": "bearer",
222
- "POST /chats/reset": "bearer",
223
- "POST /chats/interrupt": "bearer",
224
- "POST /chats/pulse": "bearer",
225
- "POST /queue": "bearer",
226
- "GET /history": "bearer",
227
- "GET /search": "bearer",
228
- "POST /send": "bearer",
229
- "POST /upload": "bearer",
230
- "GET /media": "bearer",
231
- "GET /models": "bearer",
232
- "POST /model": "bearer",
233
- "GET /backends": "bearer",
234
- "POST /backend": "bearer",
235
- "GET /effort": "bearer",
236
- "POST /effort": "bearer",
237
- "GET /logs": "bearer",
238
- "GET /plugins": "bearer",
239
- "POST /plugins/toggle": "bearer",
240
- "GET /skills": "bearer",
241
- "POST /skills/toggle": "bearer",
242
- "GET /config": "bearer",
243
- "POST /config": "bearer",
244
- "POST /control": "bearer",
245
-
246
- // Mesh. The one-time `transfer` token on /devices/file authorizes one
247
- // direction+path, but the route still sits behind the bearer like every
248
- // device route — the token is a scope, not a credential.
249
- "POST /devices/register": "bearer",
250
- "POST /location": "bearer",
251
- "GET /devices": "bearer",
252
- "POST /devices/command-result": "bearer",
253
- "POST /devices/file": "bearer",
254
- "GET /devices/file": "bearer",
255
- } as const satisfies Record<string, BridgeRouteAuth>;
256
-
257
- export type BridgeRouteKey = keyof typeof BRIDGE_ROUTE_AUTH;
258
-
259
- /** What a route handler receives; `auth` is already evaluated. */
260
- type RouteContext = {
261
- req: IncomingMessage;
262
- res: ServerResponse;
263
- url: URL;
264
- auth: AuthState;
265
- };
266
-
267
- type RouteHandler = (ctx: RouteContext) => void | Promise<void>;
268
-
269
59
  export class BridgeServer {
270
60
  private server: Server | null = null;
271
61
  /**
@@ -302,7 +92,10 @@ export class BridgeServer {
302
92
  private readonly handlers: BridgeServerHandlers,
303
93
  ) {
304
94
  this.routes = new Map(
305
- Object.entries(this.buildRoutes()) as [BridgeRouteKey, RouteHandler][],
95
+ Object.entries(buildRoutes(this.routeHost())) as [
96
+ BridgeRouteKey,
97
+ RouteHandler,
98
+ ][],
306
99
  );
307
100
  }
308
101
 
@@ -549,319 +342,22 @@ export class BridgeServer {
549
342
  }
550
343
  }
551
344
 
552
- /**
553
- * One handler per BRIDGE_ROUTE_AUTH key. The return type makes the
554
- * object exhaustive: a route declared in the table without a handler
555
- * here, or vice versa, does not compile.
556
- */
557
- private buildRoutes(): Record<BridgeRouteKey, RouteHandler> {
558
- const h = this.handlers;
559
- const json = (res: ServerResponse, code: number, body: unknown) =>
560
- this.json(res, code, body);
561
- const readJson = (req: IncomingMessage) => this.readJson(req);
562
-
563
- // Streamed device file transfers (see core/mesh/transfers.ts). The
564
- // one-time `transfer` token authorizes exactly one direction+path; the
565
- // caller names itself so the token's device binding can be checked.
566
- const transferToken = (url: URL, res: ServerResponse): string | null => {
567
- const token = url.searchParams.get("transfer") ?? "";
568
- if (!token) {
569
- json(res, 400, { ok: false, error: "transfer required" });
570
- return null;
571
- }
572
- return token;
573
- };
574
-
345
+ /** The surface the route modules get — bound closures, never the class. */
346
+ private routeHost(): RouteHost {
575
347
  return {
576
- // ── Pre-auth ───────────────────────────────────────────────────────
577
-
578
- // Unauthenticated so clients can discover/ping the bridge before they
579
- // hold a token. Pre-auth it serves only what pairing needs (identity,
580
- // protocol, fingerprint) — operational details like bot name,
581
- // backend, and chat count are not for internet scanners to enumerate.
582
- "GET /health": ({ res, auth }) => {
583
- const base = {
584
- app: "talon-bridge",
585
- ok: true,
586
- protocol: BRIDGE_PROTOCOL_VERSION,
587
- port: this.port,
588
- scheme: this.getScheme(),
589
- // The certificate's own hash — public by definition (any TLS
590
- // client sees the certificate), surfaced so pairing UIs can
591
- // display it.
592
- fingerprint: this.getFingerprint(),
593
- authRequired: Boolean(this.opts.token),
594
- };
595
- if (auth !== "ok") return json(res, 200, base);
596
- const s = h.status();
597
- return json(res, 200, {
598
- ...base,
599
- host: this.opts.host,
600
- startedAt: this.opts.startedAt,
601
- botName: s.botName,
602
- backend: s.backend,
603
- model: s.model,
604
- activeChats: s.activeChats,
605
- capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
606
- });
607
- },
608
-
609
- // Companion pairing: the phone holds no bridge credential yet, and
610
- // the single-use grant is what it comes to collect. Serving the page
611
- // IS the handover, so the grant is spent whichever leg is hit.
612
- "GET /pair": ({ req, res, url }) => {
613
- const token = url.searchParams.get("grant") ?? "";
614
- const wantsJson =
615
- url.searchParams.get("format") === "json" ||
616
- (req.headers.accept ?? "").includes("application/json");
617
- const served = token
618
- ? h.openCompanionPair(token, wantsJson ? "json" : "html")
619
- : null;
620
- if (!served) {
621
- return json(res, 404, {
622
- ok: false,
623
- error: "Unknown, expired, or already-used pairing link",
624
- });
625
- }
626
- res.writeHead(200, {
627
- "Content-Type": served.contentType,
628
- // A pairing payload is a credential; nothing may keep a copy.
629
- "Cache-Control": "no-store",
630
- ...this.corsHeaders(),
631
- });
632
- res.end(served.body);
633
- },
634
-
635
- // Node provisioning: the target host holds no bridge credential yet —
636
- // the single-use grant token (minted by make_node_install_link,
637
- // expiring, one serve per leg) is the entire authorization, the same
638
- // trust model as streamed-transfer tokens.
639
- "GET /node/install": ({ res, url }) => {
640
- const token = url.searchParams.get("provision") ?? "";
641
- const install = token ? h.openNodeInstall(token) : null;
642
- if (!install) return this.unknownProvision(res);
643
- res.writeHead(200, {
644
- "Content-Type": "text/plain; charset=utf-8",
645
- "Content-Disposition": `attachment; filename="${install.filename}"`,
646
- ...this.corsHeaders(),
647
- });
648
- res.end(install.script);
649
- },
650
- "GET /node/binary": ({ res, url }) => {
651
- const token = url.searchParams.get("provision") ?? "";
652
- const binary = token ? h.openNodeBinary(token) : null;
653
- if (!binary) return this.unknownProvision(res);
654
- this.streamFile(res, binary);
655
- },
656
-
657
- // ── Chats ──────────────────────────────────────────────────────────
658
-
659
- // A mesh client names itself here so device-addressed events reach
660
- // it alone (see sendToDevice); UI clients simply omit it.
661
- "GET /events": ({ res, url }) => this.openStream(res, deviceIdParam(url)),
662
- "GET /chats": ({ res }) => json(res, 200, { chats: h.listChats() }),
663
- "POST /chats": async ({ req, res }) => {
664
- const body = await readJson(req);
665
- json(res, 200, { chat: h.createChat(asString(body.title)) });
666
- },
667
- "POST /chats/rename": async ({ req, res }) => {
668
- const body = await readJson(req);
669
- const chat = h.renameChat(
670
- asString(body.chatId) ?? "",
671
- asString(body.title) ?? "",
672
- );
673
- return chat
674
- ? json(res, 200, { chat })
675
- : json(res, 404, { ok: false, error: "No such chat" });
676
- },
677
- "POST /chats/delete": async ({ req, res }) => {
678
- const body = await readJson(req);
679
- json(res, 200, { ok: h.deleteChat(asString(body.chatId) ?? "") });
680
- },
681
- "POST /chats/reset": async ({ req, res }) => {
682
- const body = await readJson(req);
683
- json(res, 200, { ok: h.resetChat(asString(body.chatId) ?? "") });
684
- },
685
- "POST /chats/interrupt": async ({ req, res }) => {
686
- const body = await readJson(req);
687
- const ok = await h.interruptTurn(asString(body.chatId) ?? "");
688
- json(res, 200, { ok });
689
- },
690
- "POST /chats/pulse": async ({ req, res }) => {
691
- const body = await readJson(req);
692
- h.setPulse(asString(body.chatId) ?? "", body.on === true);
693
- json(res, 200, { ok: true });
694
- },
695
- "POST /queue": async ({ req, res }) => {
696
- const body = await readJson(req);
697
- h.queueMessage(asString(body.chatId) ?? "", asString(body.text) ?? "");
698
- json(res, 200, { ok: true });
699
- },
700
- "GET /history": ({ res, url }) => {
701
- const id = url.searchParams.get("chatId") ?? "";
702
- const before = asPositiveInt(url.searchParams.get("before"));
703
- const limit = asPositiveInt(url.searchParams.get("limit"));
704
- json(res, 200, {
705
- chatId: id,
706
- messages: h.history(id, { before, limit }),
707
- });
708
- },
709
- "GET /search": ({ res, url }) => {
710
- const q = (url.searchParams.get("q") ?? "").trim();
711
- if (!q) return json(res, 400, { ok: false, error: "q required" });
712
- const chatId = url.searchParams.get("chatId") ?? undefined;
713
- json(res, 200, { results: h.search(q, chatId) });
714
- },
715
- "POST /send": async ({ req, res }) => {
716
- const body = await readJson(req);
717
- const id = asString(body.chatId) ?? "";
718
- const text = asString(body.text) ?? "";
719
- const imagePath = asString(body.imagePath);
720
- const attachmentPath = asString(body.attachmentPath);
721
- // Text may be empty when an image is attached; require one or the
722
- // other.
723
- if (!id || (!text.trim() && !attachmentPath))
724
- return json(res, 400, {
725
- ok: false,
726
- error: "chatId and text (or an attachment) required",
727
- });
728
- h.send(id, text, { imagePath, attachmentPath });
729
- json(res, 202, { ok: true });
730
- },
731
- "POST /upload": async ({ req, res, url }) => {
732
- const filename = url.searchParams.get("filename") ?? "upload";
733
- const contentType =
734
- req.headers["content-type"] ?? "application/octet-stream";
735
- const bytes = await this.readRaw(req, MAX_UPLOAD_BYTES);
736
- if (!bytes.length)
737
- return json(res, 400, { ok: false, error: "Empty upload" });
738
- const result = await h.upload(filename, contentType, bytes);
739
- json(res, 200, { ok: true, ...result });
740
- },
741
- "GET /media": ({ res, url }) =>
742
- this.serveMedia(res, url.searchParams.get("id") ?? ""),
743
-
744
- // ── Models / backends / effort ─────────────────────────────────────
745
-
746
- "GET /models": async ({ res, url }) => {
747
- const id = url.searchParams.get("chatId") ?? undefined;
748
- json(res, 200, await h.listModels(id));
749
- },
750
- "POST /model": async ({ req, res }) => {
751
- const body = await readJson(req);
752
- h.setModel(asString(body.chatId) ?? "", asString(body.model) ?? "");
753
- json(res, 200, { ok: true });
754
- },
755
- "GET /backends": ({ res, url }) =>
756
- json(res, 200, h.listBackends(url.searchParams.get("chatId") ?? "")),
757
- "POST /backend": async ({ req, res }) => {
758
- const body = await readJson(req);
759
- // Always 200: ok/error is an application result the client renders,
760
- // not an HTTP-level failure (the client's decoder drops >=400
761
- // bodies).
762
- const result = await h.setBackend(
763
- asString(body.chatId) ?? "",
764
- asString(body.backend) ?? "",
765
- );
766
- json(res, 200, result);
767
- },
768
- "GET /effort": async ({ res, url }) =>
769
- json(
770
- res,
771
- 200,
772
- await h.effortLevels(url.searchParams.get("chatId") ?? ""),
773
- ),
774
- "POST /effort": async ({ req, res }) => {
775
- const body = await readJson(req);
776
- h.setEffort(asString(body.chatId) ?? "", asString(body.effort) ?? "");
777
- json(res, 200, { ok: true });
778
- },
779
-
780
- // ── Daemon ─────────────────────────────────────────────────────────
781
-
782
- "GET /logs": ({ res, url }) => {
783
- const lines = Math.min(
784
- asPositiveInt(url.searchParams.get("lines")) ?? 200,
785
- 1000,
786
- );
787
- const level = url.searchParams.get("level") ?? "";
788
- const component = url.searchParams.get("component") ?? undefined;
789
- json(res, 200, {
790
- entries: h.logs({
791
- lines,
792
- minLevel: isLogLevel(level) ? level : undefined,
793
- component,
794
- }),
795
- });
796
- },
797
- "GET /plugins": ({ res }) => json(res, 200, { plugins: h.listPlugins() }),
798
- "POST /plugins/toggle": async ({ req, res }) => {
799
- const body = await readJson(req);
800
- // Always 200: ok/error is an application result the client renders
801
- // (mirrors /backend).
802
- const result = await h.setPluginEnabled(
803
- asString(body.name) ?? "",
804
- body.enabled === true,
805
- );
806
- json(res, 200, result);
807
- },
808
- "GET /skills": ({ res }) => json(res, 200, { skills: h.listSkills() }),
809
- "POST /skills/toggle": async ({ req, res }) => {
810
- const body = await readJson(req);
811
- json(
812
- res,
813
- 200,
814
- h.setSkillEnabled(asString(body.name) ?? "", body.enabled === true),
815
- );
816
- },
817
- "GET /config": ({ res }) => json(res, 200, h.getConfig()),
818
- "POST /config": async ({ req, res }) => {
819
- const body = await readJson(req);
820
- json(res, 200, h.setConfig(body));
821
- },
822
- "POST /control": async ({ req, res }) => {
823
- const body = await readJson(req);
824
- // Always 200: ok/message is an application result the client
825
- // renders (mirrors /backend).
826
- json(res, 200, await h.control(asString(body.action) ?? ""));
827
- },
828
-
829
- // ── Mesh ───────────────────────────────────────────────────────────
830
-
831
- "POST /devices/register": async ({ req, res }) => {
832
- const body = await readJson(req);
833
- const device = await h.registerDevice(body);
834
- json(res, 200, { ok: true, deviceId: device.id });
835
- },
836
- "POST /location": async ({ req, res }) => {
837
- const body = await readJson(req);
838
- await h.storeLocation(body);
839
- json(res, 200, { ok: true });
840
- },
841
- "GET /devices": async ({ res }) => json(res, 200, await h.listDevices()),
842
- "POST /devices/command-result": async ({ req, res }) => {
843
- const body = await readJson(req);
844
- // ok:false for a late/unknown correlation id — not an HTTP error,
845
- // the device's POST was well-formed; nothing was waiting anymore.
846
- json(res, 200, { ok: h.completeCommand(body) });
847
- },
848
- "POST /devices/file": async ({ req, res, url }) => {
849
- const token = transferToken(url, res);
850
- if (token === null) return;
851
- const result = await h.acceptFileUpload(token, req, deviceIdParam(url));
852
- json(res, result.ok ? 200 : 409, result);
853
- },
854
- "GET /devices/file": async ({ res, url }) => {
855
- const token = transferToken(url, res);
856
- if (token === null) return;
857
- const file = await h.openFileDownload(token, deviceIdParam(url));
858
- if (!file)
859
- return json(res, 404, {
860
- ok: false,
861
- error: "Unknown or already-used transfer token",
862
- });
863
- this.streamFile(res, file);
864
- },
348
+ handlers: this.handlers,
349
+ opts: this.opts,
350
+ port: () => this.port,
351
+ scheme: () => this.getScheme(),
352
+ fingerprint: () => this.getFingerprint(),
353
+ json: (res, code, body) => this.json(res, code, body),
354
+ readJson: (req) => this.readJson(req),
355
+ readRaw: (req, max) => this.readRaw(req, max),
356
+ corsHeaders: () => this.corsHeaders(),
357
+ streamFile: (res, file) => this.streamFile(res, file),
358
+ serveMedia: (res, id) => this.serveMedia(res, id),
359
+ openStream: (res, deviceId) => this.openStream(res, deviceId),
360
+ unknownProvision: (res) => this.unknownProvision(res),
865
361
  };
866
362
  }
867
363
 
@@ -1126,27 +622,6 @@ export class BridgeServer {
1126
622
  }
1127
623
  }
1128
624
 
1129
- function asString(v: unknown): string | undefined {
1130
- return typeof v === "string" ? v : undefined;
1131
- }
1132
-
1133
- /**
1134
- * The `deviceId` a mesh client claims on `/events` and `/devices/file`.
1135
- * Undefined when absent or blank — every consumer treats "no claim" as the
1136
- * legacy case, so an empty string must never look like a claimed id.
1137
- */
1138
- function deviceIdParam(url: URL): string | undefined {
1139
- const raw = (url.searchParams.get("deviceId") ?? "").trim();
1140
- return raw ? raw.slice(0, MAX_DEVICE_ID_CHARS) : undefined;
1141
- }
1142
-
1143
- /** Parse a positive-integer query param; undefined when absent/invalid. */
1144
- function asPositiveInt(v: string | null): number | undefined {
1145
- if (!v) return undefined;
1146
- const n = Number(v);
1147
- return Number.isInteger(n) && n > 0 ? n : undefined;
1148
- }
1149
-
1150
625
  /** Minimal image content-type map for the media endpoint. */
1151
626
  function contentTypeFor(filePath: string): string {
1152
627
  switch (extname(filePath).toLowerCase()) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Bridge status — the daemon-level summary served on `/status` and pushed
3
+ * as a `status` event whenever something it reports changes.
4
+ */
5
+
6
+ import { resolveModel } from "../../core/models/catalog.js";
7
+ import { BRIDGE_PROTOCOL_VERSION, type BridgeStatus } from "./protocol.js";
8
+ import type { NativeRuntime } from "./runtime.js";
9
+
10
+ export function bridgeStatus(runtime: NativeRuntime): BridgeStatus {
11
+ const { config } = runtime;
12
+ return {
13
+ app: "talon-bridge",
14
+ protocol: BRIDGE_PROTOCOL_VERSION,
15
+ capabilities: ["mesh", "mesh-commands", "plugins-skills"],
16
+ botName: runtime.botName,
17
+ backend: config.backend,
18
+ model: resolveModel(config.model)?.displayName ?? config.model,
19
+ activeChats: runtime.chats.count(),
20
+ startedAt: runtime.startedAt,
21
+ };
22
+ }
23
+
24
+ export function broadcastStatus(runtime: NativeRuntime): void {
25
+ runtime.broadcast({ kind: "status", status: bridgeStatus(runtime) });
26
+ }