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
@@ -0,0 +1,151 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type {
3
+ BackendOption,
4
+ BridgeEvent,
5
+ BridgeStatus,
6
+ ClientChat,
7
+ ClientMessage,
8
+ DeviceInfo,
9
+ DeviceLocation,
10
+ LogEntry,
11
+ LogLevel,
12
+ ModelOption,
13
+ PluginItem,
14
+ SearchResult,
15
+ SkillItem,
16
+ ToggleResult,
17
+ } from "../protocol.js";
18
+ import type { ConfigSnapshot } from "../settings.js";
19
+
20
+ /** Optional attachment references carried alongside a sent message. */
21
+ type SendOptions = {
22
+ /** Relative bridge path to render inline (e.g. `/media?id=…`). */
23
+ imagePath?: string;
24
+ /** Absolute on-disk path handed to the model so it can read the file. */
25
+ attachmentPath?: string;
26
+ };
27
+
28
+ /** Everything the transport needs the frontend to implement. */
29
+ export type BridgeServerHandlers = {
30
+ status(): BridgeStatus;
31
+ listChats(): ClientChat[];
32
+ createChat(title?: string): ClientChat;
33
+ renameChat(id: string, title: string): ClientChat | null;
34
+ deleteChat(id: string): boolean;
35
+ /** A page of history: newest window, or the window before `before`. */
36
+ history(
37
+ id: string,
38
+ opts?: { before?: number; limit?: number },
39
+ ): ClientMessage[];
40
+ /** Full-text search across chats (or one chat when `chatId` is given). */
41
+ search(query: string, chatId?: string): SearchResult[];
42
+ /** Fire-and-forget: streams its results back through `broadcast`. */
43
+ send(id: string, text: string, opts?: SendOptions): void;
44
+ /** Persist an uploaded image and return its render path + on-disk path. */
45
+ upload(
46
+ filename: string,
47
+ contentType: string,
48
+ bytes: Buffer,
49
+ ): Promise<{ imagePath: string; path: string }>;
50
+ listModels(
51
+ id?: string,
52
+ ):
53
+ | { active: string; models: ModelOption[] }
54
+ | Promise<{ active: string; models: ModelOption[] }>;
55
+ setModel(id: string, model: string): void;
56
+ /** Backends selectable for a chat + the chat's active backend id. */
57
+ listBackends(id: string): { active: string; backends: BackendOption[] };
58
+ /** Switch a chat to another backend; returns ok + an optional error. */
59
+ setBackend(
60
+ id: string,
61
+ backend: string,
62
+ ): Promise<{ ok: boolean; error?: string }>;
63
+ setEffort(id: string, effort: string): void;
64
+ effortLevels(id: string): Promise<{ active: string; levels: string[] }>;
65
+ resetChat(id: string): boolean;
66
+ /** Best-effort interrupt of a chat's in-flight turn. `true` if one was
67
+ * running and got signalled. */
68
+ interruptTurn(id: string): Promise<boolean>;
69
+ setPulse(id: string, on: boolean): void;
70
+ /** Set/replace/clear the chat's queued follow-up (empty text clears). */
71
+ queueMessage(id: string, text: string): void;
72
+ /** Read the daemon's own (allowlisted) settings + health. */
73
+ getConfig(): ConfigSnapshot;
74
+ /** Change daemon settings; returns the fresh snapshot. */
75
+ setConfig(update: Record<string, unknown>): ConfigSnapshot;
76
+ /** Installed plugins (built-ins + configured entries) with state. */
77
+ listPlugins(): PluginItem[];
78
+ /** Enable/disable a plugin; persists + hot-reloads. */
79
+ setPluginEnabled(name: string, enabled: boolean): Promise<ToggleResult>;
80
+ /** Installed skills with state. */
81
+ listSkills(): SkillItem[];
82
+ /** Enable/disable a skill; rebuilds the prompt index. */
83
+ setSkillEnabled(name: string, enabled: boolean): ToggleResult;
84
+ /** Fire a daemon-level control action (e.g. "restart", "dream"). */
85
+ control(action: string): Promise<{ ok: boolean; message: string }>;
86
+ /** Newest daemon log entries (for the client's log viewer). */
87
+ logs(opts: {
88
+ lines: number;
89
+ minLevel?: LogLevel;
90
+ component?: string;
91
+ }): LogEntry[];
92
+ /** Events reconstructing any in-progress turns, for a just-connected client. */
93
+ liveTurnEvents(): BridgeEvent[];
94
+ /** Resolve a media id to an absolute file path (or null if unknown). */
95
+ mediaPath(id: string): string | null;
96
+ /** Register/update one mesh device. */
97
+ registerDevice(body: Record<string, unknown>): Promise<DeviceInfo>;
98
+ /** Store the last-known location for one mesh device. */
99
+ storeLocation(body: Record<string, unknown>): Promise<DeviceLocation>;
100
+ /** List mesh devices and their last-known locations. */
101
+ listDevices():
102
+ | { devices: DeviceInfo[]; locations: DeviceLocation[] }
103
+ | Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
104
+ /** A device answered a device_command; true when a call was waiting. */
105
+ completeCommand(body: Record<string, unknown>): boolean;
106
+ /** A device streams a pull-transfer's file body up (raw request body).
107
+ * `fromDeviceId` is the caller's claimed identity, when it sent one. */
108
+ acceptFileUpload(
109
+ token: string,
110
+ body: IncomingMessage,
111
+ fromDeviceId?: string,
112
+ ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }>;
113
+ /** Resolve a push-transfer token to the file to stream down, or null. */
114
+ openFileDownload(
115
+ token: string,
116
+ fromDeviceId?: string,
117
+ ): Promise<{ path: string; size: number } | null>;
118
+ /** Resolve a companion-pairing grant to its page/payload, or null. */
119
+ openCompanionPair(
120
+ token: string,
121
+ format: "html" | "json",
122
+ ): { contentType: string; body: string } | null;
123
+ /** Resolve a node-provisioning token to its installer script, or null. */
124
+ openNodeInstall(token: string): { script: string; filename: string } | null;
125
+ /** Resolve a node-provisioning token to the binary to stream, or null. */
126
+ openNodeBinary(token: string): { path: string; size: number } | null;
127
+ };
128
+
129
+ /**
130
+ * What a route handler may do with the server that owns it. Built once
131
+ * by BridgeServer as bound closures, so the route modules never see the
132
+ * class — only this surface.
133
+ */
134
+ export type RouteHost = {
135
+ handlers: BridgeServerHandlers;
136
+ opts: { host: string; token?: string; startedAt: string };
137
+ port: () => number;
138
+ scheme: () => "http" | "https";
139
+ fingerprint: () => string | null;
140
+ json: (res: ServerResponse, code: number, body: unknown) => void;
141
+ readJson: (req: IncomingMessage) => Promise<Record<string, unknown>>;
142
+ readRaw: (req: IncomingMessage, max: number) => Promise<Buffer>;
143
+ corsHeaders: () => Record<string, string>;
144
+ streamFile: (
145
+ res: ServerResponse,
146
+ file: { path: string; size: number },
147
+ ) => void;
148
+ serveMedia: (res: ServerResponse, id: string) => Promise<void>;
149
+ openStream: (res: ServerResponse, deviceId?: string) => void;
150
+ unknownProvision: (res: ServerResponse) => void;
151
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One handler per BRIDGE_ROUTE_AUTH key, assembled from the route groups.
3
+ * The return type makes the object exhaustive: a route declared in the
4
+ * table without a handler here, or vice versa, does not compile.
5
+ */
6
+ import type { RouteHost } from "./host.js";
7
+ import type { BridgeRoutes } from "./table.js";
8
+ import { preAuthRoutes } from "./pre-auth.js";
9
+ import { chatRoutes } from "./chats.js";
10
+ import { modelRoutes } from "./models.js";
11
+ import { daemonRoutes } from "./daemon.js";
12
+ import { meshRoutes } from "./mesh.js";
13
+
14
+ export function buildRoutes(host: RouteHost): BridgeRoutes {
15
+ return {
16
+ ...preAuthRoutes(host),
17
+ ...chatRoutes(host),
18
+ ...modelRoutes(host),
19
+ ...daemonRoutes(host),
20
+ ...meshRoutes(host),
21
+ };
22
+ }
@@ -0,0 +1,72 @@
1
+ import type { RouteHost } from "./host.js";
2
+ import type { BridgeRoutes } from "./table.js";
3
+ import type { ServerResponse } from "node:http";
4
+ import { deviceIdParam } from "./params.js";
5
+
6
+ // Streamed device file transfers (see core/mesh/transfers.ts). The
7
+ // one-time `transfer` token authorizes exactly one direction+path; the
8
+ // caller names itself so the token's device binding can be checked.
9
+ function transferToken(
10
+ host: RouteHost,
11
+ url: URL,
12
+ res: ServerResponse,
13
+ ): string | null {
14
+ const token = url.searchParams.get("transfer") ?? "";
15
+ if (!token) {
16
+ host.json(res, 400, { ok: false, error: "transfer required" });
17
+ return null;
18
+ }
19
+ return token;
20
+ }
21
+
22
+ export function meshRoutes(
23
+ host: RouteHost,
24
+ ): Pick<
25
+ BridgeRoutes,
26
+ | "POST /devices/register"
27
+ | "POST /location"
28
+ | "GET /devices"
29
+ | "POST /devices/command-result"
30
+ | "POST /devices/file"
31
+ | "GET /devices/file"
32
+ > {
33
+ const { json, readJson, handlers: h } = host;
34
+ return {
35
+ // ── Mesh ───────────────────────────────────────────────────────────
36
+
37
+ "POST /devices/register": async ({ req, res }) => {
38
+ const body = await readJson(req);
39
+ const device = await h.registerDevice(body);
40
+ json(res, 200, { ok: true, deviceId: device.id });
41
+ },
42
+ "POST /location": async ({ req, res }) => {
43
+ const body = await readJson(req);
44
+ await h.storeLocation(body);
45
+ json(res, 200, { ok: true });
46
+ },
47
+ "GET /devices": async ({ res }) => json(res, 200, await h.listDevices()),
48
+ "POST /devices/command-result": async ({ req, res }) => {
49
+ const body = await readJson(req);
50
+ // ok:false for a late/unknown correlation id — not an HTTP error,
51
+ // the device's POST was well-formed; nothing was waiting anymore.
52
+ json(res, 200, { ok: h.completeCommand(body) });
53
+ },
54
+ "POST /devices/file": async ({ req, res, url }) => {
55
+ const token = transferToken(host, url, res);
56
+ if (token === null) return;
57
+ const result = await h.acceptFileUpload(token, req, deviceIdParam(url));
58
+ json(res, result.ok ? 200 : 409, result);
59
+ },
60
+ "GET /devices/file": async ({ res, url }) => {
61
+ const token = transferToken(host, url, res);
62
+ if (token === null) return;
63
+ const file = await h.openFileDownload(token, deviceIdParam(url));
64
+ if (!file)
65
+ return json(res, 404, {
66
+ ok: false,
67
+ error: "Unknown or already-used transfer token",
68
+ });
69
+ host.streamFile(res, file);
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,54 @@
1
+ import type { RouteHost } from "./host.js";
2
+ import type { BridgeRoutes } from "./table.js";
3
+ import { asString } from "./params.js";
4
+
5
+ export function modelRoutes(
6
+ host: RouteHost,
7
+ ): Pick<
8
+ BridgeRoutes,
9
+ | "GET /models"
10
+ | "POST /model"
11
+ | "GET /backends"
12
+ | "POST /backend"
13
+ | "GET /effort"
14
+ | "POST /effort"
15
+ > {
16
+ const { json, readJson, handlers: h } = host;
17
+ return {
18
+ // ── Models / backends / effort ─────────────────────────────────────
19
+
20
+ "GET /models": async ({ res, url }) => {
21
+ const id = url.searchParams.get("chatId") ?? undefined;
22
+ json(res, 200, await h.listModels(id));
23
+ },
24
+ "POST /model": async ({ req, res }) => {
25
+ const body = await readJson(req);
26
+ h.setModel(asString(body.chatId) ?? "", asString(body.model) ?? "");
27
+ json(res, 200, { ok: true });
28
+ },
29
+ "GET /backends": ({ res, url }) =>
30
+ json(res, 200, h.listBackends(url.searchParams.get("chatId") ?? "")),
31
+ "POST /backend": async ({ req, res }) => {
32
+ const body = await readJson(req);
33
+ // Always 200: ok/error is an application result the client renders,
34
+ // not an HTTP-level failure (the client's decoder drops >=400
35
+ // bodies).
36
+ const result = await h.setBackend(
37
+ asString(body.chatId) ?? "",
38
+ asString(body.backend) ?? "",
39
+ );
40
+ json(res, 200, result);
41
+ },
42
+ "GET /effort": async ({ res, url }) =>
43
+ json(
44
+ res,
45
+ 200,
46
+ await h.effortLevels(url.searchParams.get("chatId") ?? ""),
47
+ ),
48
+ "POST /effort": async ({ req, res }) => {
49
+ const body = await readJson(req);
50
+ h.setEffort(asString(body.chatId) ?? "", asString(body.effort) ?? "");
51
+ json(res, 200, { ok: true });
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,29 @@
1
+ /** Query/body coercions shared by the bridge's route handlers. */
2
+
3
+ /**
4
+ * Longest device id a client may claim (`?deviceId=…`). Matches the
5
+ * registry's own id cap — a longer id can never name a real device, and the
6
+ * claim is held for the life of a connection, so it stays a bounded key.
7
+ */
8
+ const MAX_DEVICE_ID_CHARS = 128;
9
+
10
+ export function asString(v: unknown): string | undefined {
11
+ return typeof v === "string" ? v : undefined;
12
+ }
13
+
14
+ /**
15
+ * The `deviceId` a mesh client claims on `/events` and `/devices/file`.
16
+ * Undefined when absent or blank — every consumer treats "no claim" as the
17
+ * legacy case, so an empty string must never look like a claimed id.
18
+ */
19
+ export function deviceIdParam(url: URL): string | undefined {
20
+ const raw = (url.searchParams.get("deviceId") ?? "").trim();
21
+ return raw ? raw.slice(0, MAX_DEVICE_ID_CHARS) : undefined;
22
+ }
23
+
24
+ /** Parse a positive-integer query param; undefined when absent/invalid. */
25
+ export function asPositiveInt(v: string | null): number | undefined {
26
+ if (!v) return undefined;
27
+ const n = Number(v);
28
+ return Number.isInteger(n) && n > 0 ? n : undefined;
29
+ }
@@ -0,0 +1,94 @@
1
+ import type { RouteHost } from "./host.js";
2
+ import type { BridgeRoutes } from "./table.js";
3
+ import { BRIDGE_PROTOCOL_VERSION } from "../protocol.js";
4
+
5
+ export function preAuthRoutes(
6
+ host: RouteHost,
7
+ ): Pick<
8
+ BridgeRoutes,
9
+ "GET /health" | "GET /pair" | "GET /node/install" | "GET /node/binary"
10
+ > {
11
+ const { json, handlers: h } = host;
12
+ return {
13
+ // ── Pre-auth ───────────────────────────────────────────────────────
14
+
15
+ // Unauthenticated so clients can discover/ping the bridge before they
16
+ // hold a token. Pre-auth it serves only what pairing needs (identity,
17
+ // protocol, fingerprint) — operational details like bot name,
18
+ // backend, and chat count are not for internet scanners to enumerate.
19
+ "GET /health": ({ res, auth }) => {
20
+ const base = {
21
+ app: "talon-bridge",
22
+ ok: true,
23
+ protocol: BRIDGE_PROTOCOL_VERSION,
24
+ port: host.port(),
25
+ scheme: host.scheme(),
26
+ // The certificate's own hash — public by definition (any TLS
27
+ // client sees the certificate), surfaced so pairing UIs can
28
+ // display it.
29
+ fingerprint: host.fingerprint(),
30
+ authRequired: Boolean(host.opts.token),
31
+ };
32
+ if (auth !== "ok") return json(res, 200, base);
33
+ const s = h.status();
34
+ return json(res, 200, {
35
+ ...base,
36
+ host: host.opts.host,
37
+ startedAt: host.opts.startedAt,
38
+ botName: s.botName,
39
+ backend: s.backend,
40
+ model: s.model,
41
+ activeChats: s.activeChats,
42
+ capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
43
+ });
44
+ },
45
+
46
+ // Companion pairing: the phone holds no bridge credential yet, and
47
+ // the single-use grant is what it comes to collect. Serving the page
48
+ // IS the handover, so the grant is spent whichever leg is hit.
49
+ "GET /pair": ({ req, res, url }) => {
50
+ const token = url.searchParams.get("grant") ?? "";
51
+ const wantsJson =
52
+ url.searchParams.get("format") === "json" ||
53
+ (req.headers.accept ?? "").includes("application/json");
54
+ const served = token
55
+ ? h.openCompanionPair(token, wantsJson ? "json" : "html")
56
+ : null;
57
+ if (!served) {
58
+ return json(res, 404, {
59
+ ok: false,
60
+ error: "Unknown, expired, or already-used pairing link",
61
+ });
62
+ }
63
+ res.writeHead(200, {
64
+ "Content-Type": served.contentType,
65
+ // A pairing payload is a credential; nothing may keep a copy.
66
+ "Cache-Control": "no-store",
67
+ ...host.corsHeaders(),
68
+ });
69
+ res.end(served.body);
70
+ },
71
+
72
+ // Node provisioning: the target host holds no bridge credential yet —
73
+ // the single-use grant token (minted by make_node_install_link,
74
+ // expiring, one serve per leg) is the entire authorization, the same
75
+ // trust model as streamed-transfer tokens.
76
+ "GET /node/install": ({ res, url }) => {
77
+ const token = url.searchParams.get("provision") ?? "";
78
+ const install = token ? h.openNodeInstall(token) : null;
79
+ if (!install) return host.unknownProvision(res);
80
+ res.writeHead(200, {
81
+ "Content-Type": "text/plain; charset=utf-8",
82
+ "Content-Disposition": `attachment; filename="${install.filename}"`,
83
+ ...host.corsHeaders(),
84
+ });
85
+ res.end(install.script);
86
+ },
87
+ "GET /node/binary": ({ res, url }) => {
88
+ const token = url.searchParams.get("provision") ?? "";
89
+ const binary = token ? h.openNodeBinary(token) : null;
90
+ if (!binary) return host.unknownProvision(res);
91
+ host.streamFile(res, binary);
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,92 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ /**
4
+ * ok: request carries the right token (or none is required).
5
+ * anonymous: no credential presented — a pre-pairing probe, not an attack.
6
+ * bad: a credential was presented and it is wrong.
7
+ */
8
+ export type AuthState = "ok" | "anonymous" | "bad";
9
+
10
+ /**
11
+ * "public": served without a bearer token. Every entry is gated some other
12
+ * way — a single-use grant minted by the daemon, or (for /health) by
13
+ * answering only what pairing needs until a token is presented.
14
+ * "bearer": the request must carry the bridge token.
15
+ */
16
+ export type BridgeRouteAuth = "public" | "bearer";
17
+
18
+ /**
19
+ * The bridge's routes and the auth tier of each — declared once, here,
20
+ * rather than implied by where an `if` sits relative to the auth check.
21
+ * The security posture of the transport is this table: a route is
22
+ * pre-auth only by appearing in it as "public", and the route test walks
23
+ * every entry and proves the tier holds on the wire. Adding a route
24
+ * without an entry is a type error (`buildRoutes` is exhaustive over
25
+ * these keys); adding one as "public" is a diff a reviewer sees.
26
+ */
27
+ export const BRIDGE_ROUTE_AUTH = {
28
+ // Pre-auth by design. /health serves pairing data (identity, protocol,
29
+ // fingerprint) to anyone and the operational view only to a token
30
+ // holder. /pair, /node/install and /node/binary hand over a credential
31
+ // to a device that holds none yet; the single-use grant in the query is
32
+ // the entire authorization.
33
+ "GET /health": "public",
34
+ "GET /pair": "public",
35
+ "GET /node/install": "public",
36
+ "GET /node/binary": "public",
37
+
38
+ // Everything a client can do once paired.
39
+ "GET /events": "bearer",
40
+ "GET /chats": "bearer",
41
+ "POST /chats": "bearer",
42
+ "POST /chats/rename": "bearer",
43
+ "POST /chats/delete": "bearer",
44
+ "POST /chats/reset": "bearer",
45
+ "POST /chats/interrupt": "bearer",
46
+ "POST /chats/pulse": "bearer",
47
+ "POST /queue": "bearer",
48
+ "GET /history": "bearer",
49
+ "GET /search": "bearer",
50
+ "POST /send": "bearer",
51
+ "POST /upload": "bearer",
52
+ "GET /media": "bearer",
53
+ "GET /models": "bearer",
54
+ "POST /model": "bearer",
55
+ "GET /backends": "bearer",
56
+ "POST /backend": "bearer",
57
+ "GET /effort": "bearer",
58
+ "POST /effort": "bearer",
59
+ "GET /logs": "bearer",
60
+ "GET /plugins": "bearer",
61
+ "POST /plugins/toggle": "bearer",
62
+ "GET /skills": "bearer",
63
+ "POST /skills/toggle": "bearer",
64
+ "GET /config": "bearer",
65
+ "POST /config": "bearer",
66
+ "POST /control": "bearer",
67
+
68
+ // Mesh. The one-time `transfer` token on /devices/file authorizes one
69
+ // direction+path, but the route still sits behind the bearer like every
70
+ // device route — the token is a scope, not a credential.
71
+ "POST /devices/register": "bearer",
72
+ "POST /location": "bearer",
73
+ "GET /devices": "bearer",
74
+ "POST /devices/command-result": "bearer",
75
+ "POST /devices/file": "bearer",
76
+ "GET /devices/file": "bearer",
77
+ } as const satisfies Record<string, BridgeRouteAuth>;
78
+
79
+ export type BridgeRouteKey = keyof typeof BRIDGE_ROUTE_AUTH;
80
+
81
+ /** What a route handler receives; `auth` is already evaluated. */
82
+ export type RouteContext = {
83
+ req: IncomingMessage;
84
+ res: ServerResponse;
85
+ url: URL;
86
+ auth: AuthState;
87
+ };
88
+
89
+ export type RouteHandler = (ctx: RouteContext) => void | Promise<void>;
90
+
91
+ /** Every route with its handler — exhaustive over BRIDGE_ROUTE_AUTH by construction. */
92
+ export type BridgeRoutes = Record<BridgeRouteKey, RouteHandler>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Native frontend runtime — the state every bridge module shares.
3
+ *
4
+ * `createNativeFrontend` used to hold all of this as closure variables with
5
+ * every handler nested inside it. It is now one explicit object, constructed
6
+ * once, that each module (chat-wire, context, emit, turn, …) takes as its
7
+ * first parameter. The runtime carries state plus two primitives — the
8
+ * message-id minter and the broadcast sink; the modules own the behaviour.
9
+ */
10
+
11
+ import type { TalonConfig } from "../../util/config.js";
12
+ import type { Gateway } from "../../core/engine/gateway.js";
13
+ import { getMeshService, type MeshService } from "../../core/mesh/index.js";
14
+ import { NativeChats } from "./chats.js";
15
+ import type { BridgeEvent, ClientToolCall, ContextInfo } from "./protocol.js";
16
+
17
+ /** One tool call observed during a running turn, kept for mid-turn replay. */
18
+ export type LiveToolEntry = {
19
+ call: ClientToolCall;
20
+ startedAt: number;
21
+ done?: boolean;
22
+ };
23
+
24
+ /** A chat's single queued follow-up, with its attachment paths intact. */
25
+ export type QueuedEntry = {
26
+ text: string;
27
+ imagePath?: string;
28
+ attachmentPath?: string;
29
+ };
30
+
31
+ export type NativeRuntime = {
32
+ readonly config: TalonConfig;
33
+ readonly gateway: Gateway;
34
+ /** ISO timestamp of frontend construction — surfaced on /health + status. */
35
+ readonly startedAt: string;
36
+ readonly botName: string;
37
+ readonly chats: NativeChats;
38
+ /**
39
+ * Daemon-wide mesh service (core/mesh). This frontend is its transport:
40
+ * companions register/report/answer through the bridge routes, and the
41
+ * SSE transport (wired in init) pushes locate requests and device commands
42
+ * out. The model's mesh tools are served by the shared gateway actions, so
43
+ * they work from every frontend.
44
+ */
45
+ readonly mesh: MeshService;
46
+ /**
47
+ * Ephemeral registry mapping a short media id → absolute file path, so the
48
+ * bridge can serve images the bot attaches without exposing raw paths in
49
+ * the URL. Lives for the process; history rows keep only a text
50
+ * placeholder, so images render live in-session (mirroring how the chat
51
+ * frontends behave).
52
+ */
53
+ readonly media: Map<string, string>;
54
+ /**
55
+ * Live context-window fill per chat, refreshed at the end of each turn.
56
+ * Cached (not computed inline) so the sync `toClientChat` projection stays
57
+ * sync — it just reads the last computed value.
58
+ */
59
+ readonly contextByChat: Map<string, ContextInfo>;
60
+ /**
61
+ * In-progress turns, keyed by chat id, so a client that (re)connects
62
+ * mid-turn can be replayed the turn's tool activity instead of waiting for
63
+ * the turn to finish. Holds the same live tool map `runTurn` mutates;
64
+ * cleared at turn end.
65
+ */
66
+ readonly liveTurns: Map<string, Map<string, LiveToolEntry>>;
67
+ /**
68
+ * The single queued follow-up per chat, held server-side so every
69
+ * connected client shares (and can edit/cancel) the same queue. Keeps the
70
+ * attachment paths so a queued image sends intact when the turn ends.
71
+ */
72
+ readonly queuedByChat: Map<string, QueuedEntry>;
73
+ /**
74
+ * The most recent assistant message id per chat — turn_end attaches the
75
+ * turn's meta (tools/stats) to this message so history hydration can show
76
+ * what the model did after a reload.
77
+ */
78
+ readonly lastAssistantId: Map<string, string>;
79
+ /**
80
+ * Monotonic message-id minter. Seeded from the wall clock so ids stay
81
+ * unique and ascending across restarts (history rows persist their ids).
82
+ */
83
+ nextId(): number;
84
+ /** Fan an event out to every connected bridge client. */
85
+ broadcast(event: BridgeEvent): void;
86
+ };
87
+
88
+ export function createNativeRuntime(
89
+ config: TalonConfig,
90
+ gateway: Gateway,
91
+ broadcast: (event: BridgeEvent) => void,
92
+ ): NativeRuntime {
93
+ let seq = Date.now();
94
+ return {
95
+ config,
96
+ gateway,
97
+ startedAt: new Date().toISOString(),
98
+ botName: config.botDisplayName || "Talon",
99
+ chats: new NativeChats(),
100
+ mesh: getMeshService(),
101
+ media: new Map(),
102
+ contextByChat: new Map(),
103
+ liveTurns: new Map(),
104
+ queuedByChat: new Map(),
105
+ lastAssistantId: new Map(),
106
+ nextId: () => ++seq,
107
+ broadcast,
108
+ };
109
+ }