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
@@ -8,137 +8,28 @@
8
8
  * a remote Android app over a token-authed LAN connection, a web client —
9
9
  * works identically.
10
10
  *
11
- * Per turn it drives `dispatcher.execute()` and forwards the canonical
12
- * `AgentEvent` stream to clients as Bridge events: reasoning + tool activity
13
- * stream live, while the persisted reply arrives via the gateway action
14
- * handler (tool-only backends) or the trailing-prose fallback (text-mode
15
- * backends) — mirroring the terminal renderer's delivery semantics.
11
+ * This file is wiring only: it constructs the shared runtime (runtime.ts),
12
+ * binds the bridge handlers (handlers.ts) to a server, and owns the
13
+ * frontend lifecycle. The behaviour lives in the modules the handlers
14
+ * delegate to — chat-wire, context, emit, turn, models, history, ….
16
15
  */
17
16
 
18
17
  import type { TalonConfig } from "../../util/config.js";
19
- import { isBunRuntime } from "../../util/runtime.js";
20
18
  import type { ContextManager } from "../../core/types.js";
21
19
  import type { Gateway } from "../../core/engine/gateway.js";
22
- import { mkdir, writeFile } from "node:fs/promises";
23
- import { basename, dirname, join, resolve } from "node:path";
24
- import { spawn } from "node:child_process";
25
20
  import { log, logError } from "../../util/log.js";
26
- import { dirs, files } from "../../util/paths.js";
27
- import { PKG_ROOT } from "../../cli/context.js";
28
- import { getSessionInfo } from "../../storage/sessions.js";
29
- import { buildContextDisplay } from "../shared/status-context.js";
30
- import { resolveActiveModelForChat } from "../../core/models/active-model.js";
31
- import { forceDream } from "../../core/background/dream.js";
32
- import { execute } from "../../core/engine/dispatcher.js";
33
- import { toolInputToRecord } from "../../core/agent-runtime/events.js";
34
- import { isDeliveryTool } from "../../core/tools/index.js";
35
- import {
36
- pushMessage,
37
- getRecentHistory,
38
- getHistoryBefore,
39
- searchHistoryMessages,
40
- clearHistory,
41
- } from "../../storage/history.js";
42
- import { recordTurnMeta, getTurnMeta, clearTurnMeta } from "./turn-meta.js";
43
- import {
44
- getChatSettings,
45
- setChatEffort,
46
- setChatModelForBackend,
47
- getChatModelForBackend,
48
- setChatBackend,
49
- setChatPulse,
50
- EFFORT_LEVELS,
51
- type EffortLevel,
52
- } from "../../storage/chat-settings.js";
53
- import { resetSession } from "../../storage/sessions.js";
54
- import { resetPulseCheckpoint } from "../../core/background/pulse.js";
55
- import { configSnapshot, applyConfigUpdate } from "./settings.js";
56
- import {
57
- pluginItems,
58
- skillItems,
59
- togglePlugin,
60
- toggleSkill,
61
- } from "./extensions.js";
62
- import { resolveModel } from "../../core/models/catalog.js";
63
- import {
64
- getBackendForChat,
65
- getBackendIdForChat,
66
- getPooledBackend,
67
- acquireBackendInstance,
68
- listAvailableBackends,
69
- rebindChat,
70
- } from "../../core/engine/backend-controller/index.js";
71
- import { getActiveReasoningLevels } from "../shared/reasoning-levels.js";
72
- import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
73
- import { extractSessionName } from "../../util/session-name.js";
74
- import { BridgeServer, type BridgeServerHandlers } from "./server.js";
75
21
  import { createNativeActionHandler } from "./actions.js";
76
- import { getMeshService } from "../../core/mesh/index.js";
22
+ import { loadOrCreateBridgeToken } from "./auth.js";
23
+ import { warmContextCache } from "./context.js";
77
24
  import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
25
+ import { emitAssistant, emitPhoto } from "./emit.js";
26
+ import { startEmptyChatSweep } from "./empty-chat-sweep.js";
27
+ import { buildBridgeHandlers } from "./handlers.js";
28
+ import { createNativeRuntime, type NativeRuntime } from "./runtime.js";
29
+ import { BridgeServer } from "./server.js";
78
30
  import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./tls.js";
79
- import { loadOrCreateBridgeToken } from "./auth.js";
80
- import { readLogEntries } from "./logs.js";
81
- import {
82
- BRIDGE_PROTOCOL_VERSION,
83
- BOT_SENDER_ID,
84
- USER_SENDER_ID,
85
- historyToClientMessage,
86
- type BackendOption,
87
- type BridgeEvent,
88
- type BridgeStatus,
89
- type ClientButton,
90
- type ClientChat,
91
- type ClientMessage,
92
- type ClientToolCall,
93
- type ContextInfo,
94
- type ModelOption,
95
- type QueuedMessage,
96
- type SearchResult,
97
- } from "./protocol.js";
98
-
99
- /** Best-effort JSON stringify that never throws (circular refs → String()). */
100
- function safeJson(value: unknown): string {
101
- try {
102
- return JSON.stringify(value, null, 2);
103
- } catch {
104
- return String(value);
105
- }
106
- }
107
31
 
108
- /**
109
- * Reduce a tool's raw result to a readable, bounded string for the app's
110
- * expanded tool view. MCP results are typically
111
- * `{ content: [{ type: "text", text }] }`, so pull the text parts out; fall
112
- * back to pretty JSON for anything else. Truncated so a huge file read or
113
- * search dump can't bloat the wire payload or the history sidecar.
114
- */
115
- export function summarizeToolResult(result: unknown): string | undefined {
116
- if (result == null) return undefined;
117
- let text: string;
118
- if (typeof result === "string") {
119
- text = result;
120
- } else {
121
- const content = (result as { content?: unknown }).content;
122
- if (Array.isArray(content)) {
123
- const parts = content
124
- .map((c) =>
125
- c &&
126
- typeof c === "object" &&
127
- typeof (c as { text?: unknown }).text === "string"
128
- ? (c as { text: string }).text
129
- : "",
130
- )
131
- .filter((s) => s.length > 0);
132
- text = parts.length > 0 ? parts.join("\n") : safeJson(result);
133
- } else {
134
- text = safeJson(result);
135
- }
136
- }
137
- text = text.trim();
138
- if (!text) return undefined;
139
- const MAX = 4000;
140
- return text.length > MAX ? `${text.slice(0, MAX)}\n… (truncated)` : text;
141
- }
32
+ export { summarizeToolResult } from "./tool-result.js";
142
33
 
143
34
  export type NativeFrontend = {
144
35
  name: "native";
@@ -151,1128 +42,20 @@ export type NativeFrontend = {
151
42
  stop: () => Promise<void>;
152
43
  };
153
44
 
154
- export function createNativeFrontend(
155
- config: TalonConfig,
156
- gateway: Gateway,
157
- ): NativeFrontend {
158
- const startedAt = new Date().toISOString();
159
- const botName = config.botDisplayName || "Talon";
160
- const chats = new NativeChats();
161
- // Daemon-wide mesh service (core/mesh). This frontend is its transport:
162
- // companions register/report/answer through the bridge routes below, and
163
- // the SSE transport (wired in init) pushes locate requests and device
164
- // commands out. The model's mesh tools are served by the shared gateway
165
- // actions, so they work from every frontend.
166
- const mesh = getMeshService();
167
- let unregisterMeshTransport: (() => void) | null = null;
168
-
169
- // Monotonic message-id minter. Seeded from the wall clock so ids stay
170
- // unique and ascending across restarts (history rows persist their ids).
171
- let seq = Date.now();
172
- const nextId = (): number => ++seq;
173
-
174
- // Ephemeral registry mapping a short media id → absolute file path, so the
175
- // bridge can serve images the bot attaches without exposing raw paths in the
176
- // URL. Lives for the process; history rows keep only a text placeholder, so
177
- // images render live in-session (mirroring how the chat frontends behave).
178
- const media = new Map<string, string>();
179
- const registerMedia = (filePath: string): string => {
180
- const id = `m${nextId().toString(36)}`;
181
- media.set(id, filePath);
182
- return id;
183
- };
184
-
185
- // Persist an uploaded attachment to the workspace uploads dir under a safe,
186
- // unique name, and return its absolute path (handed to the model to read).
187
- const saveUpload = async (
188
- filename: string,
189
- bytes: Buffer,
190
- ): Promise<string> => {
191
- await mkdir(dirs.uploads, { recursive: true });
192
- const safe = basename(filename).replace(/[^\w.-]+/g, "_") || "upload";
193
- const dest = join(
194
- dirs.uploads,
195
- `${Date.now()}-${nextId().toString(36)}-${safe}`,
196
- );
197
- await writeFile(dest, bytes);
198
- return dest;
199
- };
200
-
201
- // Live context-window fill per chat, refreshed at the end of each turn.
202
- // Cached (not computed inline) so the sync `toClientChat` projection stays
203
- // sync — it just reads the last computed value.
204
- const contextByChat = new Map<string, ContextInfo>();
205
-
206
- // In-progress turns, keyed by chat id, so a client that (re)connects mid-turn
207
- // can be replayed the turn's tool activity instead of waiting for the turn to
208
- // finish. Holds the same live tool map `runTurn` mutates; cleared at turn end.
209
- type LiveToolEntry = {
210
- call: ClientToolCall;
211
- startedAt: number;
212
- done?: boolean;
213
- };
214
- const liveTurns = new Map<string, Map<string, LiveToolEntry>>();
215
-
216
- /** True when a turn is currently running for a chat (used to decide queue). */
217
- const isBusy = (chatId: string): boolean => liveTurns.has(chatId);
218
-
219
- // The single queued follow-up per chat, held server-side so every connected
220
- // client shares (and can edit/cancel) the same queue. Keeps the attachment
221
- // paths so a queued image sends intact when the turn ends.
222
- type QueuedEntry = {
223
- text: string;
224
- imagePath?: string;
225
- attachmentPath?: string;
226
- };
227
- const queuedByChat = new Map<string, QueuedEntry>();
228
-
229
- /** Project the stored queue entry to its wire shape (text + attachment flag). */
230
- function toQueued(chatId: string): QueuedMessage | undefined {
231
- const q = queuedByChat.get(chatId);
232
- if (!q) return undefined;
233
- return { text: q.text, hasAttachment: Boolean(q.attachmentPath) };
234
- }
235
-
236
- /**
237
- * Set (or replace) a chat's queued follow-up and sync it to every client via
238
- * chat_updated. Empty text with no attachment clears the queue.
239
- */
240
- function setQueued(chatId: string, next: QueuedEntry): void {
241
- const entry = chats.get(chatId);
242
- if (!entry) return;
243
- const text = next.text.trim();
244
- if (!text && !next.attachmentPath) {
245
- if (!queuedByChat.delete(chatId)) return;
246
- } else {
247
- queuedByChat.set(chatId, {
248
- text,
249
- imagePath: next.imagePath,
250
- attachmentPath: next.attachmentPath,
251
- });
252
- }
253
- broadcastChatUpdated(entry);
254
- }
255
-
256
- /**
257
- * Events that reconstruct every in-progress turn for a freshly-connected
258
- * client: a turn_start (so the live turn renders), a typing indicator, and
259
- * each tool's call — plus its result when it has already finished. Replayed
260
- * right after `hello` so a reconnect mid-turn shows the full tool timeline
261
- * immediately rather than only the tools that happen to fire afterwards.
262
- */
263
- function liveTurnEvents(): BridgeEvent[] {
264
- const events: BridgeEvent[] = [];
265
- for (const [chatId, tools] of liveTurns) {
266
- events.push({ kind: "turn_start", chatId });
267
- events.push({ kind: "typing", chatId, on: true });
268
- for (const { call, done } of tools.values()) {
269
- events.push({
270
- kind: "tool",
271
- chatId,
272
- id: call.id,
273
- name: call.name,
274
- phase: "call",
275
- ...(call.input ? { input: call.input } : {}),
276
- });
277
- if (done) {
278
- events.push({
279
- kind: "tool",
280
- chatId,
281
- id: call.id,
282
- name: call.name,
283
- phase: "result",
284
- ...(call.error ? { error: call.error } : {}),
285
- ...(call.output ? { output: call.output } : {}),
286
- });
287
- }
288
- }
289
- }
290
- return events;
291
- }
292
-
293
- // ── Per-chat wire projection ─────────────────────────────────────────────
294
-
295
- function toClientChat(entry: ChatEntry): ClientChat {
296
- const settings = getChatSettings(entry.id);
297
- let model: string | undefined;
298
- let backend: string | undefined;
299
- try {
300
- // The persisted per-chat setting is the source of truth for what the
301
- // user picked; the in-memory binding can lag it (boot-time rebind
302
- // still pending or transiently failed). Reporting the binding here
303
- // made clients show a chat "reset" to the default backend after a
304
- // daemon restart even though the user's choice was intact.
305
- backend = settings.backend ?? getBackendIdForChat(entry.id);
306
- model = getChatModelForBackend(entry.id, backend);
307
- } catch {
308
- backend = settings.backend;
309
- /* backend pool not ready (early boot) — omit model */
310
- }
311
- return {
312
- id: entry.id,
313
- title: entry.title,
314
- createdAt: entry.createdAt,
315
- lastActive: entry.lastActive,
316
- preview: entry.preview,
317
- model,
318
- backend,
319
- effort: settings.effort,
320
- pulse: settings.pulse,
321
- context: contextByChat.get(entry.id),
322
- queued: toQueued(entry.id),
323
- };
324
- }
325
-
326
- /**
327
- * Compute the current context-window fill for a chat from its session
328
- * usage, reusing the same `buildContextDisplay` the terminal/Discord
329
- * `/status` line uses. When the session's usage doesn't carry a window
330
- * size yet (fresh session), fall back to the resolved model's window so
331
- * the readout still lands. Returns undefined when nothing is known — the
332
- * clients hide the indicator rather than render a bogus 0%.
333
- */
334
- async function computeContext(
335
- chatId: string,
336
- opts?: { resolveModel?: boolean },
337
- ): Promise<ContextInfo | undefined> {
338
- try {
339
- const info = getSessionInfo(chatId);
340
- const u = info.usage;
341
- let ctxMax = u.contextWindow;
342
- // The model fallback touches the backend pool, which is fine for one
343
- // chat the user just opened and wrong for every chat at boot — the
344
- // startup warm asks for the cheap path and lets anything it can't
345
- // resolve fill in when that chat is next opened.
346
- if (!ctxMax && opts?.resolveModel !== false) {
347
- try {
348
- const backend = getBackendForChat(chatId);
349
- const backendId = getBackendIdForChat(chatId);
350
- const { ref } = await resolveActiveModelForChat(
351
- chatId,
352
- backend,
353
- backendId,
354
- config,
355
- );
356
- if (ref?.contextWindow) ctxMax = ref.contextWindow;
357
- } catch {
358
- /* backend pool not ready — leave max unknown */
359
- }
360
- }
361
- const ctx = buildContextDisplay({
362
- contextTokens: u.contextTokens,
363
- lastPromptTokens: u.lastPromptTokens,
364
- contextWindow: ctxMax,
365
- });
366
- if (!ctx.known && ctx.max === 0) return undefined;
367
- return {
368
- known: ctx.known,
369
- used: ctx.used,
370
- max: ctx.max,
371
- pct: ctx.pct,
372
- warn: ctx.warn,
373
- };
374
- } catch {
375
- return undefined;
376
- }
377
- }
378
-
379
- /** Recompute a chat's context fill and, if it changed, push it to clients. */
380
- async function refreshContext(
381
- entry: ChatEntry,
382
- opts?: { resolveModel?: boolean },
383
- ): Promise<void> {
384
- const next = await computeContext(entry.id, opts);
385
- if (!next) return;
386
- const prev = contextByChat.get(entry.id);
387
- if (prev && prev.used === next.used && prev.max === next.max) return;
388
- contextByChat.set(entry.id, next);
389
- broadcastChatUpdated(entry);
390
- }
391
-
392
- /**
393
- * Fill the context cache for chats restored at startup.
394
- *
395
- * The readout is served from an in-memory map that was only ever written at
396
- * turn end, so after a restart every existing chat reported no context at
397
- * all — the header chip vanished until that chat ran another turn, which
398
- * read as "context usage doesn't save". The numbers themselves are
399
- * persisted with the session; only the cache was cold. This re-reads them
400
- * for the most recently active chats (bounded, and on the cheap path that
401
- * never touches the backend pool); anything it skips or can't resolve is
402
- * filled in the moment the chat is opened.
403
- */
404
- const CONTEXT_WARM_LIMIT = 40;
405
-
406
- async function warmContextCache(): Promise<void> {
407
- for (const entry of chats.list().slice(0, CONTEXT_WARM_LIMIT)) {
408
- await refreshContext(entry, { resolveModel: false });
409
- }
410
- }
411
-
412
- const broadcast = (event: BridgeEvent): void => server.broadcast(event);
413
-
414
- // The most recent assistant message id per chat — turn_end attaches the
415
- // turn's meta (tools/stats) to this message so history hydration can show
416
- // what the model did after a reload.
417
- const lastAssistantId = new Map<string, string>();
418
-
419
- const broadcastChatUpdated = (entry: ChatEntry): void =>
420
- broadcast({ kind: "chat_updated", chat: toClientChat(entry) });
421
-
422
- /**
423
- * Name a chat from its first user message — instantly and for free.
424
- *
425
- * The title is a trimmed slice of the first message (no model call), so it
426
- * lands the moment the user hits send instead of staying "New chat" until
427
- * the turn finishes and a restart re-hydrates the persisted name. Only fires
428
- * while the chat still carries the placeholder title, so a user's manual
429
- * rename is never clobbered. Callers broadcast `chat_updated` afterwards, so
430
- * the change propagates to every connected client live.
431
- */
432
- function maybeAutoTitle(entry: ChatEntry, text: string): void {
433
- if (entry.title && entry.title !== DEFAULT_CHAT_TITLE) return;
434
- const title = extractSessionName(text);
435
- if (title) chats.rename(entry.id, title);
436
- }
437
-
438
- // ── Outbound message helpers (persist + broadcast) ───────────────────────
439
-
440
- function emitAssistant(
441
- entry: ChatEntry,
442
- text: string,
443
- buttons?: ClientButton[][],
444
- ): number {
445
- const id = nextId();
446
- const ts = Date.now();
447
- const message: ClientMessage = {
448
- id: String(id),
449
- chatId: entry.id,
450
- role: "assistant",
451
- text,
452
- ts,
453
- ...(buttons ? { buttons } : {}),
454
- };
455
- pushMessage(entry.id, {
456
- msgId: id,
457
- senderId: BOT_SENDER_ID,
458
- senderName: botName,
459
- text,
460
- timestamp: ts,
461
- });
462
- chats.touch(entry.id, text);
463
- lastAssistantId.set(entry.id, String(id));
464
- broadcast({ kind: "message", chatId: entry.id, message });
465
- broadcastChatUpdated(entry);
466
- return id;
467
- }
468
-
469
- /** Persist + broadcast an assistant photo message (image + optional caption). */
470
- function emitPhoto(
471
- entry: ChatEntry,
472
- filePath: string,
473
- caption?: string,
474
- ): number {
475
- const id = nextId();
476
- const ts = Date.now();
477
- const text = caption?.trim() ?? "";
478
- const mediaId = registerMedia(filePath);
479
- const message: ClientMessage = {
480
- id: String(id),
481
- chatId: entry.id,
482
- role: "assistant",
483
- text,
484
- ts,
485
- imagePath: `/media?id=${encodeURIComponent(mediaId)}`,
486
- };
487
- // Persist the image reference so it re-renders when history reloads (the
488
- // filePath is re-registered into the media map on read). The caption is
489
- // stored as plain text; the `photo` mediaType carries the image marker.
490
- pushMessage(entry.id, {
491
- msgId: id,
492
- senderId: BOT_SENDER_ID,
493
- senderName: botName,
494
- text,
495
- timestamp: ts,
496
- mediaType: "photo",
497
- filePath,
498
- });
499
- chats.touch(entry.id, text || "[photo]");
500
- lastAssistantId.set(entry.id, String(id));
501
- broadcast({ kind: "message", chatId: entry.id, message });
502
- broadcastChatUpdated(entry);
503
- return id;
504
- }
505
-
506
- /** Persist + broadcast a user message; returns its numeric id so the turn
507
- * hands the model that same id (as `[msg_id:N]`) to react/reply to.
508
- * An optional imagePath renders an attached image inline. */
509
- function emitUser(
510
- entry: ChatEntry,
511
- text: string,
512
- imagePath?: string,
513
- attachmentPath?: string,
514
- ): number {
515
- const id = nextId();
516
- const ts = Date.now();
517
- const message: ClientMessage = {
518
- id: String(id),
519
- chatId: entry.id,
520
- role: "user",
521
- text,
522
- ts,
523
- ...(imagePath ? { imagePath } : {}),
524
- };
525
- // For an attached image, persist the on-disk path + a `photo` mediaType so
526
- // it re-renders on history reload (rehydrated in the `history` handler)
527
- // instead of vanishing to a text-only placeholder. Caption stays as text.
528
- pushMessage(entry.id, {
529
- msgId: id,
530
- senderId: USER_SENDER_ID,
531
- senderName: "User",
532
- text,
533
- timestamp: ts,
534
- ...(imagePath
535
- ? {
536
- mediaType: "photo" as const,
537
- ...(attachmentPath ? { filePath: attachmentPath } : {}),
538
- }
539
- : {}),
540
- });
541
- chats.touch(entry.id, imagePath ? text || "[photo]" : text);
542
- maybeAutoTitle(entry, text);
543
- broadcast({ kind: "message", chatId: entry.id, message });
544
- broadcastChatUpdated(entry);
545
- return id;
546
- }
547
-
548
- /** Transient, non-persisted notice (e.g. "session reset"). */
549
- function emitSystem(entry: ChatEntry, text: string): void {
550
- broadcast({
551
- kind: "message",
552
- chatId: entry.id,
553
- message: {
554
- id: `sys-${nextId()}`,
555
- chatId: entry.id,
556
- role: "system",
557
- text,
558
- ts: Date.now(),
559
- },
560
- });
561
- }
562
-
563
- // ── A user turn ──────────────────────────────────────────────────────────
564
-
565
- async function runTurn(
566
- entry: ChatEntry,
567
- text: string,
568
- messageId: number,
569
- attachmentPath?: string,
570
- ): Promise<void> {
571
- const start = Date.now();
572
- // Tool calls observed during this turn, in call order — recorded into the
573
- // turn-meta sidecar at turn_end so history can replay the timeline. Also
574
- // registered in `liveTurns` so a client connecting mid-turn can be replayed
575
- // the activity so far (cleared in the `finally`).
576
- const turnTools = new Map<string, LiveToolEntry>();
577
- liveTurns.set(entry.id, turnTools);
578
- // Safety net: any tool the backend announced but never resolved
579
- // (a crash can eat a tool_result; callback backends historically
580
- // never emitted one — handler-to-events now pairs each tool_call
581
- // with an immediate synthetic result) gets a synthetic result at
582
- // turn end — a spinner the app opened on phase:"call" must always
583
- // see a phase:"result".
584
- const flushOpenTools = () => {
585
- for (const [id, live] of turnTools) {
586
- if (live.done) continue;
587
- live.done = true;
588
- live.call.durationMs = Date.now() - live.startedAt;
589
- broadcast({
590
- kind: "tool",
591
- chatId: entry.id,
592
- id,
593
- name: live.call.name,
594
- phase: "result",
595
- });
596
- }
597
- };
598
- // Point the model at an attached image so it can read the file itself.
599
- const prompt = attachmentPath
600
- ? `${text ? `${text}\n\n` : ""}[Attached image: ${attachmentPath}]`
601
- : text;
602
- try {
603
- const result = await execute({
604
- chatId: entry.id,
605
- numericChatId: entry.numericId,
606
- prompt,
607
- senderName: "User",
608
- isGroup: false,
609
- // Give the model the user message's id so `[msg_id:N]` is present and
610
- // react/reply/edit target the user's message — not the bot's.
611
- messageId,
612
- source: "message",
613
- onEvent: async (event) => {
614
- switch (event.type) {
615
- case "reasoning":
616
- if (event.text)
617
- broadcast({
618
- kind: "reasoning",
619
- chatId: entry.id,
620
- text: event.text,
621
- });
622
- break;
623
- case "text_delta":
624
- broadcast({ kind: "delta", chatId: entry.id, text: event.text });
625
- break;
626
- case "tool_call": {
627
- // Delivery plumbing (end_turn / send_message / react) never
628
- // enters the tool timeline — its effect arrives as the
629
- // `message`/reaction itself. Skipping here keeps the live
630
- // stream, the mid-turn replay to late joiners, and the
631
- // persisted turn meta consistent from one choke point.
632
- if (isDeliveryTool(event.name)) break;
633
- const input = toolInputToRecord(event.name, event.input);
634
- turnTools.set(event.id, {
635
- call: { id: event.id, name: event.name, input },
636
- startedAt: Date.now(),
637
- });
638
- broadcast({
639
- kind: "tool",
640
- chatId: entry.id,
641
- id: event.id,
642
- name: event.name,
643
- phase: "call",
644
- input,
645
- });
646
- break;
647
- }
648
- case "tool_result": {
649
- if (isDeliveryTool(event.name)) break;
650
- const output = summarizeToolResult(event.result);
651
- const live = turnTools.get(event.id);
652
- if (live) {
653
- live.done = true;
654
- live.call.durationMs = Date.now() - live.startedAt;
655
- if (event.error) live.call.error = event.error;
656
- if (output) live.call.output = output;
657
- }
658
- broadcast({
659
- kind: "tool",
660
- chatId: entry.id,
661
- id: event.id,
662
- name: event.name,
663
- phase: "result",
664
- ...(event.error ? { error: event.error } : {}),
665
- ...(output ? { output } : {}),
666
- });
667
- break;
668
- }
669
- case "error":
670
- broadcast({
671
- kind: "error",
672
- chatId: entry.id,
673
- message: event.error.message,
674
- });
675
- break;
676
- }
677
- },
678
- });
679
-
680
- // Trailing-prose fallback: text-mode backends (kilo/opencode/codex)
681
- // deliver the reply as plain text rather than a bridge send. When no
682
- // delivery tool fired this turn, surface result.text as the message —
683
- // exactly what the terminal renderer does.
684
- let delivered = result.bridgeMessageCount;
685
- if (delivered === 0 && result.text.trim()) {
686
- emitAssistant(entry, result.text.trim());
687
- delivered = 1;
688
- }
689
-
690
- // Resolve open tool spinners BEFORE persisting turn meta, so the
691
- // synthetic durations land in the recorded timeline too.
692
- flushOpenTools();
693
-
694
- // Persist what this turn did against its final assistant message, so
695
- // reloaded history keeps the tool timeline + stats footer. Only when
696
- // the turn actually delivered — otherwise the "last assistant id"
697
- // belongs to a previous turn and the meta would land on the wrong row.
698
- if (delivered > 0) {
699
- const msgId = lastAssistantId.get(entry.id);
700
- if (msgId) {
701
- const tools = [...turnTools.values()].map((t) => t.call);
702
- recordTurnMeta(entry.id, msgId, {
703
- durationMs: result.durationMs,
704
- tokensIn: result.inputTokens,
705
- tokensOut: result.outputTokens,
706
- ...(tools.length ? { tools } : {}),
707
- });
708
- }
709
- }
710
-
711
- broadcast({ kind: "typing", chatId: entry.id, on: false });
712
- broadcast({
713
- kind: "turn_end",
714
- chatId: entry.id,
715
- delivered,
716
- durationMs: result.durationMs,
717
- usage: { input: result.inputTokens, output: result.outputTokens },
718
- });
719
-
720
- // Refresh the live context-window readout now the turn's usage has
721
- // settled, and push it to clients via chat_updated. Best-effort — a
722
- // failure here must never surface as a turn error.
723
- void refreshContext(entry).catch(() => {});
724
- } catch (err) {
725
- flushOpenTools();
726
- broadcast({ kind: "typing", chatId: entry.id, on: false });
727
- broadcast({
728
- kind: "error",
729
- chatId: entry.id,
730
- message: err instanceof Error ? err.message : String(err),
731
- });
732
- broadcast({
733
- kind: "turn_end",
734
- chatId: entry.id,
735
- delivered: 0,
736
- durationMs: Date.now() - start,
737
- });
738
- } finally {
739
- // The turn is over (delivered or errored) — stop replaying it to new
740
- // clients. Any late tool spinner has already been flushed above.
741
- liveTurns.delete(entry.id);
742
- // Flush a queued follow-up as a fresh turn: "once it's done it will
743
- // send". Deferred so we don't re-enter runTurn inside its own finally.
744
- const queued = queuedByChat.get(entry.id);
745
- if (queued) {
746
- queuedByChat.delete(entry.id);
747
- broadcastChatUpdated(entry);
748
- setImmediate(() =>
749
- startTurn(entry, queued.text, {
750
- imagePath: queued.imagePath,
751
- attachmentPath: queued.attachmentPath,
752
- }),
753
- );
754
- }
755
- }
756
- }
757
-
758
- /** Emit the user message, open the turn, and drive it. Shared by the /send
759
- * handler and the queued-follow-up flush. */
760
- function startTurn(
761
- entry: ChatEntry,
762
- text: string,
763
- opts?: { imagePath?: string; attachmentPath?: string },
764
- ): void {
765
- const messageId = emitUser(
766
- entry,
767
- text,
768
- opts?.imagePath,
769
- opts?.attachmentPath,
770
- );
771
- broadcast({ kind: "turn_start", chatId: entry.id });
772
- broadcast({ kind: "typing", chatId: entry.id, on: true });
773
- void runTurn(entry, text, messageId, opts?.attachmentPath);
774
- }
775
-
776
- // ── Status + model helpers ───────────────────────────────────────────────
777
-
778
- function status(): BridgeStatus {
779
- return {
780
- app: "talon-bridge",
781
- protocol: BRIDGE_PROTOCOL_VERSION,
782
- capabilities: ["mesh", "mesh-commands", "plugins-skills"],
783
- botName,
784
- backend: config.backend,
785
- model: resolveModel(config.model)?.displayName ?? config.model,
786
- activeChats: chats.count(),
787
- startedAt,
788
- };
789
- }
790
-
791
- async function listModels(chatId?: string): Promise<{
792
- active: string;
793
- models: ModelOption[];
794
- }> {
795
- // Resolve the chat's *own* backend so the model list tracks whatever
796
- // backend the chat is currently bound to (fixes the list staying on the
797
- // previous backend's models after a switch). Fall back to the global
798
- // default backend when there's no chat / the pool isn't ready yet.
799
- let backendId: string = config.backend;
800
- let active = config.model;
801
- if (chatId) {
802
- try {
803
- // Persisted setting first — see toClientChat.
804
- backendId =
805
- getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
806
- active = getChatModelForBackend(chatId, backendId) ?? config.model;
807
- } catch {
808
- /* pool not ready — keep global defaults */
809
- }
810
- }
811
-
812
- // Pull the models dynamically from the gateway for that backend. Prefer
813
- // an already-pooled instance of the *resolved* backend (the persisted
814
- // choice — the chat's live binding can lag it after a failed boot-time
815
- // rebind, and listing the live binding's catalog here showed the wrong
816
- // backend's models); otherwise boot the backend transiently to read its
817
- // catalog, then release it so we never leak an instance — mirroring the
818
- // shared `list_models` action.
819
- let instance:
820
- | Awaited<ReturnType<typeof acquireBackendInstance>>["backend"]
821
- | null
822
- | undefined = getPooledBackend(backendId);
823
- let release: (() => Promise<void>) | null = null;
824
- if (!instance) {
825
- try {
826
- const acquired = await acquireBackendInstance(backendId);
827
- instance = acquired.backend;
828
- release = acquired.release;
829
- } catch {
830
- return { active, models: [] };
831
- }
832
- }
833
-
834
- try {
835
- const catalog = instance.models;
836
- if (!catalog?.listModels) return { active, models: [] };
837
- const { models } = await catalog.listModels("all");
838
- const options: ModelOption[] = models
839
- .filter((m) => m.selectable)
840
- .map((m) => ({
841
- id: m.id,
842
- displayName: m.displayName,
843
- provider: m.provider,
844
- reasoning:
845
- Boolean(m.supportedReasoningLevels?.length) || Boolean(m.reasoning),
846
- }));
847
- return { active, models: options };
848
- } catch {
849
- return { active, models: [] };
850
- } finally {
851
- if (release) await release();
852
- }
853
- }
854
-
855
- function setModel(chatId: string, model: string): void {
856
- const entry = chats.get(chatId);
857
- if (!entry) return;
858
- // Persisted setting first — see toClientChat. Writing the pick under the
859
- // live binding's key would attach it to the wrong backend whenever the
860
- // boot-time rebind to the persisted backend is still pending.
861
- const backendId =
862
- getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
863
- setChatModelForBackend(chatId, backendId, model.trim() || undefined);
864
- broadcastChatUpdated(entry);
865
- }
866
-
867
- function listBackends(chatId: string): {
868
- active: string;
869
- backends: BackendOption[];
870
- } {
871
- const backends = listAvailableBackends(config);
872
- let active: string = config.backend;
873
- try {
874
- if (chatId) {
875
- // Persisted setting first — see toClientChat.
876
- active = getChatSettings(chatId).backend ?? getBackendIdForChat(chatId);
877
- }
878
- } catch {
879
- /* pool not ready — report the global default backend */
880
- }
881
- return { active, backends };
882
- }
883
-
884
- /**
885
- * Switch a chat to another backend. Mirrors the Telegram `/model` backend
886
- * submenu: verify the target is enabled, rebind the chat's pool holder, pin
887
- * the override, and drop the previous backend's per-chat session state
888
- * (sessions aren't portable across backends). Per-backend model picks are
889
- * kept, so switching back restores the prior model automatically.
890
- */
891
- async function setBackend(
892
- chatId: string,
893
- backend: string,
894
- ): Promise<{ ok: boolean; error?: string }> {
895
- const entry = chats.get(chatId);
896
- if (!entry) return { ok: false, error: "No such chat" };
897
- const target = backend.trim();
898
- const available = listAvailableBackends(config);
899
- if (!available.some((b) => b.id === target)) {
900
- return { ok: false, error: "Backend not available" };
901
- }
902
- const persisted = getChatSettings(chatId).backend;
903
- if (getBackendIdForChat(chatId) === target) {
904
- // Already live on the target — just make sure the choice is persisted.
905
- if (persisted !== target && target !== config.backend) {
906
- setChatBackend(chatId, target);
907
- broadcastChatUpdated(entry);
908
- }
909
- return { ok: true };
910
- }
911
-
912
- const result = await rebindChat(chatId, target, config);
913
- if (!result.ok) {
914
- return { ok: false, error: result.error ?? "Rebind failed" };
915
- }
916
-
917
- // Re-selecting the backend this chat is already persisted to is a
918
- // RE-ATTACH (e.g. the boot-time rebind failed transiently and the user —
919
- // or a client retry — picks it again). The conversation belongs to that
920
- // backend; resetting the session and wiping history here destroyed real
921
- // conversations and surfaced as a phantom "backend reset" in clients.
922
- if (persisted === target) {
923
- broadcastChatUpdated(entry);
924
- broadcast({ kind: "status", status: status() });
925
- return { ok: true };
926
- }
927
-
928
- setChatBackend(chatId, target);
929
- resetSession(chatId);
930
- clearHistory(chatId);
931
- clearTurnMeta(chatId);
932
- contextByChat.delete(chatId);
933
- queuedByChat.delete(chatId);
934
- resetPulseCheckpoint(chatId);
935
- emitSystem(entry, `Switched to ${target} — starting a fresh conversation.`);
936
- broadcastChatUpdated(entry);
937
- broadcast({ kind: "status", status: status() });
938
- return { ok: true };
939
- }
940
-
941
- function setEffort(chatId: string, effort: string): void {
942
- const entry = chats.get(chatId);
943
- if (!entry) return;
944
- const level = EFFORT_LEVELS.includes(effort as EffortLevel)
945
- ? (effort as EffortLevel)
946
- : undefined;
947
- setChatEffort(chatId, level);
948
- broadcastChatUpdated(entry);
949
- }
950
-
951
- async function effortLevels(
952
- chatId: string,
953
- ): Promise<{ active: string; levels: string[] }> {
954
- try {
955
- const backend = getBackendForChat(chatId);
956
- const backendId = getBackendIdForChat(chatId);
957
- const { levels } = await getActiveReasoningLevels({
958
- chatId,
959
- backend,
960
- backendId,
961
- config,
962
- });
963
- const active = getChatSettings(chatId).effort ?? "adaptive";
964
- return { active, levels };
965
- } catch {
966
- return { active: "adaptive", levels: [] };
967
- }
968
- }
969
-
970
- // ── Daemon control (restart / dream) ─────────────────────────────────────
971
-
972
- /**
973
- * Restart the daemon by spawning a detached `talon restart` — the same
974
- * command a human runs. It must be an independent, detached process: the
975
- * restart stops *this* process, so doing it in-process would kill us before
976
- * the successor is spawned. The child outlives us, tears the daemon down,
977
- * and brings a fresh one up. Mirrors `core/daemon/control.ts`'s own
978
- * source-vs-compiled-binary spawn recipe.
979
- */
980
- function spawnDaemonRestart(): void {
981
- const isBunBinary =
982
- (process.argv[1] ?? "").includes("~BUN") ||
983
- (process.argv[1] ?? "").includes("$bunfs");
984
- const cmd = process.execPath;
985
- const args = isBunBinary
986
- ? ["restart"]
987
- : isBunRuntime()
988
- ? [resolve(PKG_ROOT, "src", "cli.ts"), "restart"]
989
- : [
990
- resolve(PKG_ROOT, "node_modules", "tsx", "dist", "cli.mjs"),
991
- resolve(PKG_ROOT, "src", "cli.ts"),
992
- "restart",
993
- ];
994
- const cwd = isBunBinary ? dirname(process.execPath) : PKG_ROOT;
995
- const child = spawn(cmd, args, {
996
- cwd,
997
- detached: true,
998
- stdio: "ignore",
999
- env: { ...process.env },
1000
- windowsHide: true,
1001
- });
1002
- child.unref();
1003
- }
1004
-
1005
- /**
1006
- * Daemon-level control actions the app fires from Settings. Kept minimal and
1007
- * explicit — each maps to a well-understood operation the CLI already
1008
- * exposes, so there's no new privileged surface beyond "what a local admin
1009
- * could already do".
1010
- */
1011
- async function control(
1012
- action: string,
1013
- ): Promise<{ ok: boolean; message: string }> {
1014
- switch (action) {
1015
- case "restart":
1016
- spawnDaemonRestart();
1017
- return {
1018
- ok: true,
1019
- message: "Restarting Talon — back online in a few seconds.",
1020
- };
1021
- case "dream":
1022
- // Fire-and-forget: a dream run can take a while; the app just needs
1023
- // to know it started. forceDream throws if one is already running.
1024
- try {
1025
- void forceDream().catch((err) =>
1026
- logError("native", "Manual dream run failed", err),
1027
- );
1028
- return { ok: true, message: "Dream started — consolidating memory." };
1029
- } catch (err) {
1030
- return {
1031
- ok: false,
1032
- message: err instanceof Error ? err.message : String(err),
1033
- };
1034
- }
1035
- default:
1036
- return { ok: false, message: `Unknown control action: ${action}` };
1037
- }
1038
- }
1039
-
1040
- // ── Empty-chat sweeper ───────────────────────────────────────────────────
1041
- //
1042
- // "New chat" creates a real chat on the daemon the instant it's tapped, so
1043
- // opening one and backing out of it leaves an empty row in every client's
1044
- // list forever. The app deletes an untouched chat as soon as you leave it,
1045
- // which covers the normal case immediately; this is the backstop for the
1046
- // ones no client got to clean up — the app was killed, the network dropped,
1047
- // or the chat was created by something that never came back.
1048
- //
1049
- // Deliberately conservative: only chats that never carried a single message
1050
- // (see ChatEntry.used, which a reset does NOT clear), only after an hour,
1051
- // never one with a turn running or a message queued, and never one whose
1052
- // history says otherwise. Deleting via the same handler every client uses
1053
- // means the removal is broadcast, so open lists update in place.
1054
- const EMPTY_CHAT_MIN_AGE_MS = 60 * 60_000;
1055
- const EMPTY_CHAT_SWEEP_INTERVAL_MS = 30 * 60_000;
1056
- let emptyChatSweep: ReturnType<typeof setInterval> | null = null;
1057
-
1058
- function sweepEmptyChats(): void {
1059
- for (const entry of chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
1060
- if (isBusy(entry.id) || queuedByChat.has(entry.id)) continue;
1061
- if (getRecentHistory(entry.id, 1).length > 0) continue;
1062
- if (handlers.deleteChat(entry.id)) {
1063
- log("native", `Swept empty chat ${entry.id}`);
1064
- }
1065
- }
1066
- }
1067
-
1068
- // ── Bridge server wiring ─────────────────────────────────────────────────
1069
-
1070
- const handlers: BridgeServerHandlers = {
1071
- status,
1072
- listChats: () => chats.list().map(toClientChat),
1073
- createChat: (title) => {
1074
- const entry = chats.create(title);
1075
- const chat = toClientChat(entry);
1076
- broadcast({ kind: "chat_created", chat });
1077
- return chat;
1078
- },
1079
- renameChat: (id, title) => {
1080
- const entry = chats.rename(id, title);
1081
- if (!entry) return null;
1082
- const chat = toClientChat(entry);
1083
- broadcast({ kind: "chat_updated", chat });
1084
- return chat;
1085
- },
1086
- deleteChat: (id) => {
1087
- const ok = chats.remove(id);
1088
- if (ok) {
1089
- clearTurnMeta(id);
1090
- contextByChat.delete(id);
1091
- queuedByChat.delete(id);
1092
- broadcast({ kind: "chat_deleted", chatId: id });
1093
- }
1094
- return ok;
1095
- },
1096
- history: (id, opts) => {
1097
- // Opening a chat is the one moment we know a client wants its numbers:
1098
- // refresh the context readout on the full path (model fallback and
1099
- // all), which covers every chat the startup warm skipped or couldn't
1100
- // resolve. Fire-and-forget — the figure arrives as a chat_updated.
1101
- const entry = chats.get(id);
1102
- if (entry && opts?.before === undefined) {
1103
- void refreshContext(entry).catch(() => {});
1104
- }
1105
- const limit = Math.min(Math.max(opts?.limit ?? 200, 1), 500);
1106
- const rows =
1107
- opts?.before !== undefined
1108
- ? getHistoryBefore(id, opts.before, limit)
1109
- : getRecentHistory(id, limit);
1110
- return rows
1111
- .map((m) => {
1112
- const msg = historyToClientMessage(m, id);
1113
- // Re-hydrate an attached image: re-register its on-disk path into
1114
- // the media map and hand back a fresh /media URL so the image shows
1115
- // in reloaded history (survives restarts as long as the file exists).
1116
- if (m.mediaType === "photo" && m.filePath) {
1117
- const mediaId = registerMedia(m.filePath);
1118
- msg.imagePath = `/media?id=${encodeURIComponent(mediaId)}`;
1119
- }
1120
- // Re-hydrate turn meta (tool timeline + stats) for assistant rows.
1121
- if (msg.role === "assistant") {
1122
- const meta = getTurnMeta(id, msg.id);
1123
- if (meta) {
1124
- // Delivery tools are excluded at record time, but metas
1125
- // persisted by older daemons still carry them — filter on
1126
- // the way out so upgraded installs get clean history too.
1127
- const tools = meta.tools?.filter((t) => !isDeliveryTool(t.name));
1128
- if (tools?.length) msg.tools = tools;
1129
- if (meta.durationMs) msg.durationMs = meta.durationMs;
1130
- if (meta.tokensIn) msg.tokensIn = meta.tokensIn;
1131
- if (meta.tokensOut) msg.tokensOut = meta.tokensOut;
1132
- }
1133
- }
1134
- return msg;
1135
- })
1136
- .sort((a, b) => Number(a.id) - Number(b.id));
1137
- },
1138
- search: (query, chatId) => {
1139
- const targets = chatId
1140
- ? [chats.get(chatId)].filter((e): e is ChatEntry => e != null)
1141
- : chats.list();
1142
- const results: SearchResult[] = [];
1143
- for (const entry of targets) {
1144
- for (const m of searchHistoryMessages(entry.id, query, 10)) {
1145
- results.push({
1146
- chatId: entry.id,
1147
- chatTitle: entry.title,
1148
- message: historyToClientMessage(m, entry.id),
1149
- });
1150
- }
1151
- }
1152
- // Newest hits first, bounded across all chats.
1153
- results.sort((a, b) => b.message.ts - a.message.ts);
1154
- return results.slice(0, 50);
1155
- },
1156
- send: (id, text, opts) => {
1157
- const entry = chats.get(id) ?? chats.ensure(id);
1158
- // A turn is already running for this chat — don't interrupt it. Park the
1159
- // message as the single queued follow-up (synced to every client); it
1160
- // auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
1161
- // which `runTurn` sets synchronously, so even a rapid second /send from
1162
- // any client is caught here rather than starting a concurrent turn.
1163
- if (isBusy(entry.id)) {
1164
- setQueued(entry.id, {
1165
- text,
1166
- imagePath: opts?.imagePath,
1167
- attachmentPath: opts?.attachmentPath,
1168
- });
1169
- return;
1170
- }
1171
- startTurn(entry, text, opts);
1172
- },
1173
- queueMessage: (id, text) => {
1174
- // Edit/replace the queued follow-up (text-only). Empty clears it.
1175
- const entry = chats.get(id);
1176
- if (entry) setQueued(entry.id, { text });
1177
- },
1178
- upload: async (filename, _contentType, bytes) => {
1179
- const path = await saveUpload(filename, bytes);
1180
- const mediaId = registerMedia(path);
1181
- return { imagePath: `/media?id=${encodeURIComponent(mediaId)}`, path };
1182
- },
1183
- listModels,
1184
- setModel,
1185
- listBackends,
1186
- setBackend,
1187
- setEffort,
1188
- effortLevels,
1189
- interruptTurn: async (id) => {
1190
- const entry = chats.get(id);
1191
- if (!entry) return false;
1192
- // Only meaningful while a turn is actually running.
1193
- if (!isBusy(id)) return false;
1194
- let backend = null;
1195
- try {
1196
- backend = getBackendForChat(id);
1197
- } catch {
1198
- return false;
1199
- }
1200
- const interrupt = backend?.chat?.interruptChatTurn;
1201
- if (!interrupt) return false;
1202
- try {
1203
- return await interrupt(id);
1204
- } catch {
1205
- return false;
1206
- }
1207
- },
1208
- resetChat: (id) => {
1209
- const entry = chats.get(id);
1210
- if (!entry) return false;
1211
- // Full reset, matching /reset on the other frontends: session,
1212
- // history (the app re-fetches its transcript from us), pulse
1213
- // checkpoint, and any in-process backend memory. Warm the fresh
1214
- // session in the background — the bridge handler is sync.
1215
- resetSession(id);
1216
- clearHistory(id);
1217
- clearTurnMeta(id);
1218
- contextByChat.delete(id);
1219
- queuedByChat.delete(id);
1220
- resetPulseCheckpoint(id);
1221
- let backend = null;
1222
- try {
1223
- backend = getBackendForChat(id);
1224
- } catch {
1225
- // No pool binding — nothing to wipe or warm.
1226
- }
1227
- backend?.sessions?.resetChat?.(id);
1228
- void backend?.sessions?.warmSession?.(id)?.catch(() => {});
1229
- emitSystem(entry, "Session reset — starting a fresh conversation.");
1230
- broadcastChatUpdated(entry);
1231
- return true;
1232
- },
1233
- setPulse: (id, on) => {
1234
- const entry = chats.get(id);
1235
- if (!entry) return;
1236
- setChatPulse(id, on);
1237
- broadcastChatUpdated(entry);
1238
- },
1239
- getConfig: () => configSnapshot(config),
1240
- setConfig: (update) => {
1241
- const snap = applyConfigUpdate(config, update);
1242
- broadcast({ kind: "status", status: status() });
1243
- return snap;
1244
- },
1245
- listPlugins: () => pluginItems(config),
1246
- setPluginEnabled: (name, enabled) =>
1247
- togglePlugin(config, getPooledBackend(config.backend), name, enabled),
1248
- listSkills: () => skillItems(),
1249
- setSkillEnabled: (name, enabled) =>
1250
- toggleSkill(config, getPooledBackend(config.backend), name, enabled),
1251
- control,
1252
- logs: ({ lines, minLevel, component }) =>
1253
- readLogEntries(files.log, { limit: lines, minLevel, component }),
1254
- liveTurnEvents,
1255
- mediaPath: (id) => media.get(id) ?? null,
1256
- // Mesh routes are thin transport shims over the shared core service —
1257
- // storeLocation wakes any pending fresh-fix waiters inside the service.
1258
- registerDevice: (body) => mesh.register(body),
1259
- storeLocation: (body) => mesh.storeLocation(body),
1260
- listDevices: () => mesh.list(),
1261
- completeCommand: (body) => mesh.completeCommand(body),
1262
- acceptFileUpload: (token, body, fromDeviceId) =>
1263
- mesh.acceptFileUpload(token, body, fromDeviceId),
1264
- openFileDownload: (token, fromDeviceId) =>
1265
- mesh.openFileDownload(token, fromDeviceId),
1266
- openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
1267
- openNodeInstall: (token) => mesh.openNodeInstall(token),
1268
- openNodeBinary: (token) => mesh.openNodeBinary(token),
1269
- };
45
+ type BridgeListen = {
46
+ host: string;
47
+ port: number;
48
+ token: string | undefined;
49
+ tls: boolean;
50
+ };
1270
51
 
52
+ /** Where the bridge listens, and how it is secured, from `config.native`. */
53
+ function bridgeListen(config: TalonConfig): BridgeListen {
1271
54
  const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
1272
- const bridgeHost = nativeCfg.host ?? "127.0.0.1";
55
+ const host = nativeCfg.host ?? "127.0.0.1";
1273
56
  // Encrypted by default the moment the bridge leaves the machine; loopback
1274
57
  // stays plain HTTP unless explicitly opted in (`native.tls`).
1275
- const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
58
+ const tls = nativeCfg.tls ?? !isLoopbackHost(host);
1276
59
  // Never serve the agent API to the network unauthenticated: a non-loopback
1277
60
  // bind with no configured token gets a persistent auto-minted one instead.
1278
61
  // `?? ` alone would treat an empty string as a configured token and skip
@@ -1282,22 +65,61 @@ export function createNativeFrontend(
1282
65
  nativeCfg.token !== undefined && nativeCfg.token !== ""
1283
66
  ? nativeCfg.token
1284
67
  : undefined;
1285
- const bridgeToken =
68
+ const token =
1286
69
  configuredToken ??
1287
- (isLoopbackHost(bridgeHost) ? undefined : loadOrCreateBridgeToken());
70
+ (isLoopbackHost(host) ? undefined : loadOrCreateBridgeToken());
71
+ return { host, port: nativeCfg.port ?? 19880, token, tls };
72
+ }
73
+
74
+ /**
75
+ * Plug this bridge in as the mesh's transport: locates and device commands
76
+ * (from ANY frontend's mesh tool calls) leave as SSE events.
77
+ *
78
+ * A locate is a bare "who's there?" — no secret in the frame, and
79
+ * pre-command app builds rely on receiving it — so it still fans out.
80
+ * A command is the opposite: its params carry transfer tokens, exec
81
+ * command lines and (on the chunked fallback) file bodies, so it goes
82
+ * to the target device's own client(s) only.
83
+ */
84
+ function registerMeshTransport(
85
+ runtime: NativeRuntime,
86
+ server: BridgeServer,
87
+ ): () => void {
88
+ return runtime.mesh.registerTransport({
89
+ locate: (deviceId) => runtime.broadcast({ kind: "locate", deviceId }),
90
+ command: (command) =>
91
+ server.sendToDevice(command.deviceId, {
92
+ kind: "device_command",
93
+ id: command.id,
94
+ deviceId: command.deviceId,
95
+ name: command.name,
96
+ params: command.params,
97
+ }),
98
+ });
99
+ }
100
+
101
+ export function createNativeFrontend(
102
+ config: TalonConfig,
103
+ gateway: Gateway,
104
+ ): NativeFrontend {
105
+ const runtime = createNativeRuntime(config, gateway, (event) =>
106
+ server.broadcast(event),
107
+ );
108
+ const { chats, mesh } = runtime;
109
+ const listen = bridgeListen(config);
1288
110
  const server = new BridgeServer(
1289
111
  {
1290
- host: bridgeHost,
1291
- port: nativeCfg.port ?? 19880,
1292
- token: bridgeToken,
1293
- allowedOrigins: nativeCfg.allowedOrigins,
1294
- startedAt,
1295
- ...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
1296
- },
1297
- handlers,
112
+ host: listen.host,
113
+ port: listen.port,
114
+ token: listen.token,
115
+ allowedOrigins: config.native?.allowedOrigins,
116
+ startedAt: runtime.startedAt,
117
+ ...(listen.tls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
118
+ },
119
+ buildBridgeHandlers(runtime),
1298
120
  );
1299
-
1300
- // ── Frontend interface ─────────────────────────────────────────────────--
121
+ let unregisterMeshTransport: (() => void) | null = null;
122
+ let stopEmptyChatSweep: (() => void) | null = null;
1301
123
 
1302
124
  const context: ContextManager = {
1303
125
  acquire: (chatId: number, stringId?: string) =>
@@ -1312,39 +134,22 @@ export function createNativeFrontend(
1312
134
 
1313
135
  sendTyping: async (chatId: number) => {
1314
136
  const entry = chats.byNumeric(chatId);
1315
- if (entry) broadcast({ kind: "typing", chatId: entry.id, on: true });
137
+ if (entry)
138
+ runtime.broadcast({ kind: "typing", chatId: entry.id, on: true });
1316
139
  },
1317
140
 
1318
141
  // Used by cron / pulse / heartbeat to reach a chat outside a user turn.
1319
142
  sendMessage: async (chatId: number, text: string) => {
1320
143
  if (!text.trim()) return;
1321
144
  const entry = chats.byNumeric(chatId);
1322
- if (entry) emitAssistant(entry, text);
145
+ if (entry) emitAssistant(runtime, entry, text);
1323
146
  },
1324
147
 
1325
148
  getBridgePort: () => gateway.getPort(),
1326
149
 
1327
150
  async init() {
1328
151
  await mesh.load();
1329
- // Plug this bridge in as the mesh's transport: locates and device
1330
- // commands (from ANY frontend's mesh tool calls) leave as SSE events.
1331
- //
1332
- // A locate is a bare "who's there?" — no secret in the frame, and
1333
- // pre-command app builds rely on receiving it — so it still fans out.
1334
- // A command is the opposite: its params carry transfer tokens, exec
1335
- // command lines and (on the chunked fallback) file bodies, so it goes
1336
- // to the target device's own client(s) only.
1337
- unregisterMeshTransport = mesh.registerTransport({
1338
- locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
1339
- command: (command) =>
1340
- server.sendToDevice(command.deviceId, {
1341
- kind: "device_command",
1342
- id: command.id,
1343
- deviceId: command.deviceId,
1344
- name: command.name,
1345
- params: command.params,
1346
- }),
1347
- });
152
+ unregisterMeshTransport = registerMeshTransport(runtime, server);
1348
153
  // Mesh tool actions (list_devices / get_device_location) are shared
1349
154
  // gateway actions now — no native-only cases here.
1350
155
  gateway.registerFrontendHandler(
@@ -1352,44 +157,38 @@ export function createNativeFrontend(
1352
157
  createNativeActionHandler({
1353
158
  chats,
1354
159
  gateway,
1355
- emitAssistant,
1356
- emitPhoto,
1357
- broadcast,
160
+ emitAssistant: (entry, text, buttons) =>
161
+ emitAssistant(runtime, entry, text, buttons),
162
+ emitPhoto: (entry, filePath, caption) =>
163
+ emitPhoto(runtime, entry, filePath, caption),
164
+ broadcast: runtime.broadcast,
1358
165
  }),
1359
166
  );
1360
167
  const gatewayPort = await gateway.start(19876);
1361
168
  log("native", `Gateway on :${gatewayPort}`);
1362
169
  chats.restore();
1363
170
  // Non-blocking: a cold cache costs a missing chip, not a broken boot.
1364
- void warmContextCache().catch((err) =>
171
+ void warmContextCache(runtime).catch((err) =>
1365
172
  logError("native", "Context cache warm failed", err),
1366
173
  );
1367
- emptyChatSweep = setInterval(() => {
1368
- try {
1369
- sweepEmptyChats();
1370
- } catch (err) {
1371
- logError("native", "Empty-chat sweep failed", err);
1372
- }
1373
- }, EMPTY_CHAT_SWEEP_INTERVAL_MS);
1374
- // Housekeeping must never be the reason the process stays alive.
1375
- emptyChatSweep.unref?.();
174
+ stopEmptyChatSweep = startEmptyChatSweep(runtime);
1376
175
  await server.start();
1377
176
  const fingerprint = server.getFingerprint();
1378
177
  // Tell the mesh how this bridge is reachable — everything a generated
1379
178
  // node installer needs (make_node_install_link fails cleanly without it).
1380
179
  mesh.setBridgeInfo({
1381
180
  scheme: server.getScheme(),
1382
- host: bridgeHost,
181
+ host: listen.host,
1383
182
  port: server.getPort(),
1384
- ...(bridgeToken ? { token: bridgeToken } : {}),
183
+ ...(listen.token ? { token: listen.token } : {}),
1385
184
  ...(fingerprint ? { fingerprint } : {}),
1386
185
  });
1387
186
  await writeBridgeDiscovery({
1388
187
  port: server.getPort(),
1389
- token: bridgeToken,
188
+ token: listen.token,
1390
189
  scheme: server.getScheme(),
1391
190
  ...(fingerprint ? { fingerprint } : {}),
1392
- startedAt: Date.parse(startedAt),
191
+ startedAt: Date.parse(runtime.startedAt),
1393
192
  });
1394
193
  },
1395
194
 
@@ -1401,10 +200,8 @@ export function createNativeFrontend(
1401
200
  },
1402
201
 
1403
202
  async stop() {
1404
- if (emptyChatSweep) {
1405
- clearInterval(emptyChatSweep);
1406
- emptyChatSweep = null;
1407
- }
203
+ stopEmptyChatSweep?.();
204
+ stopEmptyChatSweep = null;
1408
205
  unregisterMeshTransport?.();
1409
206
  unregisterMeshTransport = null;
1410
207
  mesh.setBridgeInfo(null);