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.
- package/package.json +6 -5
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- 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/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/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -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/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/plugins/github/provision.ts +1 -1
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- 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
|
-
|
|
32
|
-
|
|
33
|
-
type
|
|
34
|
-
type
|
|
35
|
-
type
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
type
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
}
|