talon-agent 1.34.0 → 1.35.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.
@@ -1,18 +1,43 @@
1
+ /**
2
+ * Weaver — the turn orchestrator. `runTurn` serializes each turn onto
3
+ * its chat's Thread (per-chat FIFO, cross-chat parallel) and drives the
4
+ * turn lifecycle by composing the weaver's single-purpose collaborators:
5
+ *
6
+ * - `resolveWarp` (warp-resolver.ts) — model/backend binding + the
7
+ * null-model guard and per-run override fallback;
8
+ * - `startTypingLoop` (typing-loop.ts) — keeps the frontend's typing
9
+ * indicator alive for the duration of the turn;
10
+ * - `prefetchMemory` (memory-prefetch.ts) — optional fail-closed
11
+ * palace pre-retrieval for live user messages;
12
+ * - `carryTurnEvents` (shuttle.ts) — pumps the backend's AgentEvent
13
+ * stream into the frontend sink, settles delivery acks, captures
14
+ * the result and rethrows error terminators.
15
+ *
16
+ * The Weaver itself only sequences those stages and brackets them with
17
+ * the Thread's execution context — it holds no per-chat state (that's
18
+ * the Thread's) and no policy of its own beyond ordering.
19
+ */
20
+
1
21
  import { randomBytes } from "node:crypto";
2
- import type {
3
- Backend,
4
- RetrievedMemory,
5
- } from "../agent-runtime/capabilities.js";
6
- import { AgentRunError, type AgentResult } from "../agent-runtime/events.js";
22
+ import type { Backend } from "../agent-runtime/capabilities.js";
23
+ import type { AgentResult } from "../agent-runtime/events.js";
7
24
  import type { ModelRef } from "../agent-runtime/model-ref.js";
8
- import { maybeStartDream } from "../background/dream.js";
9
25
  import type { MemoryRetriever } from "../memory/retrieval.js";
10
26
  import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
11
27
  import { log, logDebug, logWarn } from "../../util/log.js";
12
28
  import { Loom } from "./loom.js";
29
+ import { prefetchMemory } from "./memory-prefetch.js";
30
+ import { carryTurnEvents } from "./shuttle.js";
13
31
  import type { Thread, ThreadSnapshot } from "./thread.js";
32
+ import { startTypingLoop } from "./typing-loop.js";
33
+ import { resolveWarp } from "./warp-resolver.js";
14
34
 
15
35
  export type WeaverDeps = {
36
+ /**
37
+ * Read fresh per call so backend swaps (chat-role rebinds or
38
+ * per-chat overrides via the controller) take effect on the next
39
+ * query without a dispatcher re-init.
40
+ */
16
41
  getBackend: (chatId?: string) => Backend;
17
42
  resolveActiveModel: (chatId: string) => Promise<{
18
43
  model: string | null;
@@ -26,6 +51,14 @@ export type WeaverDeps = {
26
51
  context: ContextManager;
27
52
  sendTyping: (chatId: number, stringId?: string) => Promise<void>;
28
53
  onActivity: () => void;
54
+ /**
55
+ * Optional fire-and-forget hook invoked at the start of every turn,
56
+ * after the warp is bound and before the backend is called. The
57
+ * composition root wires background work here (e.g. dream memory
58
+ * consolidation) so the Weaver stays ignorant of those subsystems.
59
+ * Must not throw and must not block — it is called synchronously.
60
+ */
61
+ onTurnStart?: () => void;
29
62
  /**
30
63
  * Optional memory pre-retrieval (Phase B). Called for `source: "message"`
31
64
  * turns after model/backend resolution and context acquisition, before
@@ -38,6 +71,7 @@ export type WeaverDeps = {
38
71
  export class Weaver {
39
72
  readonly loom: Loom;
40
73
  private readonly deps: WeaverDeps;
74
+ private activeCount = 0;
41
75
 
42
76
  constructor(deps: WeaverDeps, loom = new Loom()) {
43
77
  this.deps = deps;
@@ -49,6 +83,7 @@ export class Weaver {
49
83
  return thread.enqueue(() => this.run(thread, params));
50
84
  }
51
85
 
86
+ /** Number of turns currently running (not queued) across all chats. */
52
87
  getActiveCount(): number {
53
88
  return this.activeCount;
54
89
  }
@@ -59,14 +94,9 @@ export class Weaver {
59
94
  * frontends. Reading is side-effect free.
60
95
  */
61
96
  snapshot(): ThreadSnapshot[] {
62
- return this.loom
63
- .chatIds()
64
- .map((id) => this.loom.get(id)?.describe())
65
- .filter((s): s is ThreadSnapshot => s !== undefined);
97
+ return this.loom.snapshot();
66
98
  }
67
99
 
68
- private activeCount = 0;
69
-
70
100
  private async run(
71
101
  thread: Thread,
72
102
  params: ExecuteParams,
@@ -83,95 +113,32 @@ export class Weaver {
83
113
  thread: Thread,
84
114
  params: ExecuteParams,
85
115
  ): Promise<ExecuteResult> {
86
- const {
87
- getBackend,
88
- resolveActiveModel,
89
- resolveModelOverride,
90
- context,
91
- sendTyping,
92
- onActivity,
93
- retrieveMemory,
94
- } = this.deps;
95
- // Read the backend fresh per call so backend swaps (chat-role
96
- // rebinds or per-chat overrides via the controller) take effect on
97
- // the next query without a dispatcher re-init.
98
- const backend = getBackend(params.chatId);
116
+ const { context, onActivity, retrieveMemory } = this.deps;
117
+ const backend = this.deps.getBackend(params.chatId);
99
118
  const reqId = randomBytes(4).toString("hex");
100
119
 
101
- // Send-time null-model guard. When the active-model resolver
102
- // returns no usable model (catalog-driven backend with no per-chat
103
- // pick and no operator default), refuse to call the backend — it
104
- // would either error opaquely or run on the wrong default. Reply
105
- // with a clear "use /model to pick one" message routed through the
106
- // same event sink the backend would use for output (as an
107
- // `assistant_message` event, so the frontend delivers it normally).
108
- const {
109
- model: resolvedModel,
110
- ref: resolvedRef,
111
- backendId,
112
- } = await resolveActiveModel(params.chatId);
113
- if (resolvedModel === null || resolvedRef === null) {
114
- const message =
115
- `No model selected for backend \`${backendId}\`. ` +
116
- `Use /model to pick one — or set ` +
117
- `\`backendDefaults.${backendId}\` in talon.json to apply a ` +
118
- `default for all chats on this backend.`;
119
- logWarn(
120
- "dispatcher",
121
- `[${reqId}] refusing query: no model resolved (chat=${params.chatId}, backend=${backendId})`,
122
- );
120
+ const warp = await resolveWarp(this.deps, {
121
+ chatId: params.chatId,
122
+ modelOverride: params.modelOverride,
123
+ source: params.source,
124
+ reqId,
125
+ });
126
+ if (!warp.ok) {
127
+ // Refusals are delivered through the same event sink the backend
128
+ // would use for output (as an `assistant_message` event, so the
129
+ // frontend delivers it normally).
123
130
  try {
124
- await params.onEvent?.({ type: "assistant_message", text: message });
131
+ await params.onEvent?.({
132
+ type: "assistant_message",
133
+ text: warp.message,
134
+ });
125
135
  } catch (err) {
126
136
  logWarn(
127
137
  "dispatcher",
128
138
  `onEvent(no-model) threw: ${err instanceof Error ? err.message : String(err)}`,
129
139
  );
130
140
  }
131
- return {
132
- text: message,
133
- durationMs: 0,
134
- inputTokens: 0,
135
- outputTokens: 0,
136
- cacheRead: 0,
137
- cacheWrite: 0,
138
- bridgeMessageCount: context.getMessageCount(
139
- params.numericChatId,
140
- params.chatId,
141
- ),
142
- };
143
- }
144
-
145
- // Per-run model override (triggers/cron). Resolve against the chat's
146
- // backend; on success swap the ref for this turn only, on failure fall
147
- // back to the chat model so a stale override never kills the run. The
148
- // override is restricted to the chat's own backend, so the session still
149
- // resumes — only the model changes.
150
- let runRef = resolvedRef;
151
- if (params.modelOverride && resolveModelOverride) {
152
- try {
153
- const overrideRef = await resolveModelOverride(
154
- params.chatId,
155
- params.modelOverride,
156
- );
157
- if (overrideRef) {
158
- runRef = overrideRef;
159
- logDebug(
160
- "dispatcher",
161
- `[${reqId}] model override → ${params.modelOverride} (${params.source})`,
162
- );
163
- } else {
164
- logWarn(
165
- "dispatcher",
166
- `[${reqId}] model override "${params.modelOverride}" not resolvable on backend ${backendId}; using chat model`,
167
- );
168
- }
169
- } catch (err) {
170
- logWarn(
171
- "dispatcher",
172
- `[${reqId}] model override resolution threw: ${err instanceof Error ? err.message : String(err)}; using chat model`,
173
- );
174
- }
141
+ return this.emptyResult(warp.message, params);
175
142
  }
176
143
 
177
144
  // Bind the warp — record the model/backend actually resolved for this turn
@@ -179,158 +146,105 @@ export class Weaver {
179
146
  // last turn (per-chat rebind, per-run override, or config drift) is logged
180
147
  // rather than passing silently.
181
148
  const { drifted, previous } = thread.bindWarp({
182
- model: runRef.id,
183
- backendId,
184
- overridden: runRef !== resolvedRef,
149
+ model: warp.ref.id,
150
+ backendId: warp.backendId,
151
+ overridden: warp.overridden,
185
152
  boundAt: Date.now(),
186
153
  });
187
154
  if (drifted && previous) {
188
155
  logDebug(
189
156
  "dispatcher",
190
- `[${reqId}] warp drift chat=${params.chatId}: ${previous.backendId}/${previous.model} → ${backendId}/${runRef.id}`,
157
+ `[${reqId}] warp drift chat=${params.chatId}: ${previous.backendId}/${previous.model} → ${warp.backendId}/${warp.ref.id}`,
191
158
  );
192
159
  }
193
160
 
194
- // Dream check — fire-and-forget background memory consolidation if due
195
- maybeStartDream();
161
+ this.deps.onTurnStart?.();
196
162
 
197
163
  logDebug(
198
164
  "dispatcher",
199
165
  `[${reqId}] ${params.source} chat=${params.chatId} started (active=${this.activeCount})`,
200
166
  );
201
167
  context.acquire(params.numericChatId, params.chatId);
202
-
203
- let typingTimer: ReturnType<typeof setInterval> | undefined;
168
+ const stopTyping = startTypingLoop(
169
+ this.deps.sendTyping,
170
+ params.numericChatId,
171
+ params.chatId,
172
+ );
204
173
  try {
205
- await sendTyping(params.numericChatId, params.chatId).catch(
206
- (err: unknown) => {
207
- logWarn(
208
- "dispatcher",
209
- `sendTyping failed: ${err instanceof Error ? err.message : String(err)}`,
210
- );
211
- },
212
- );
213
- typingTimer = setInterval(() => {
214
- sendTyping(params.numericChatId, params.chatId).catch(
215
- (err: unknown) => {
216
- logWarn(
217
- "dispatcher",
218
- `sendTyping interval failed: ${err instanceof Error ? err.message : String(err)}`,
219
- );
220
- },
221
- );
222
- }, 4000);
223
-
224
- // Consume the backend's native `AgentEvent` stream and forward
225
- // every event straight to the frontend's `onEvent` sink (no
226
- // callback bridge). Capture the `completed` event's `AgentResult`
227
- // for the dispatcher's return value, and rethrow an `error`
228
- // terminator as `AgentRunError` so callers' catch paths keep
229
- // working. Events are awaited in stream order so a consumer that
230
- // needs serial delivery gets it.
231
174
  if (!backend.chat) {
232
175
  throw new Error(
233
176
  `Backend "${backend.id}" has no chat capability — cannot run a turn.`,
234
177
  );
235
178
  }
236
179
 
237
- // Phase B memory pre-retrieval: only for live user messages, only when
238
- // a retriever dep is wired, and strictly fail-closed — a broken palace
239
- // must never block chat delivery. The result is dynamic turn context
240
- // passed through ChatRunParams; it never touches the frozen prompt.
241
- let retrievedMemory: RetrievedMemory | undefined;
242
- if (retrieveMemory && params.source === "message") {
243
- try {
244
- retrievedMemory = await retrieveMemory({
245
- runKind: "chat",
246
- chatId: params.chatId,
247
- text: params.prompt,
248
- senderName: params.senderName,
249
- isGroup: params.isGroup,
250
- });
251
- if (retrievedMemory) {
252
- logDebug(
253
- "dispatcher",
254
- `[${reqId}] memory pre-retrieval: ${retrievedMemory.items.length} item(s), ` +
255
- `${retrievedMemory.items.reduce((n, i) => n + i.text.length, 0)} chars`,
256
- );
257
- }
258
- } catch (err) {
259
- logWarn(
260
- "dispatcher",
261
- `[${reqId}] memory pre-retrieval failed (running turn without it): ` +
262
- (err instanceof Error ? err.message : String(err)),
263
- );
264
- retrievedMemory = undefined;
265
- }
266
- }
180
+ const retrievedMemory =
181
+ retrieveMemory && params.source === "message"
182
+ ? await prefetchMemory(retrieveMemory, {
183
+ chatId: params.chatId,
184
+ text: params.prompt,
185
+ senderName: params.senderName,
186
+ isGroup: params.isGroup,
187
+ reqId,
188
+ })
189
+ : undefined;
267
190
 
268
191
  const stream = backend.chat.runChatTurn({
269
192
  chatId: params.chatId,
270
- model: runRef,
193
+ model: warp.ref,
271
194
  text: params.prompt,
272
195
  senderName: params.senderName,
273
196
  isGroup: params.isGroup,
274
197
  messageId: params.messageId,
275
198
  retrievedMemory,
276
199
  });
277
- let agentResult: AgentResult | undefined;
278
- for await (const event of stream) {
279
- if (event.type === "completed") {
280
- agentResult = event.result;
281
- }
282
-
283
- // The dispatcher owns `assistant_message.deliveryAck` settlement
284
- // so it is ALWAYS resolved — even when no `onEvent` sink is
285
- // supplied, or the sink ignores the event. Otherwise the
286
- // callback-shaped backend (handler-to-events) blocks forever
287
- // awaiting delivery confirmation. The frontend's job is just to
288
- // deliver and throw on failure; the dispatcher maps that onto the
289
- // ack (resolve on success → block delivered; reject on throw →
290
- // backend retries, e.g. Codex oversized-message path).
291
- if (event.type === "assistant_message" && event.deliveryAck) {
292
- try {
293
- await params.onEvent?.(event);
294
- event.deliveryAck.resolve();
295
- } catch (err) {
296
- event.deliveryAck.reject(err);
297
- }
298
- continue;
299
- }
300
-
301
- await params.onEvent?.(event);
302
- if (event.type === "error") {
303
- throw new AgentRunError(event.error);
304
- }
305
- }
306
- const result = {
307
- text: agentResult?.text ?? "",
308
- durationMs: agentResult?.durationMs ?? 0,
309
- inputTokens: agentResult?.usage.inputTokens ?? 0,
310
- outputTokens: agentResult?.usage.outputTokens ?? 0,
311
- cacheRead: agentResult?.usage.cacheRead ?? 0,
312
- cacheWrite: agentResult?.usage.cacheWrite ?? 0,
313
- };
200
+ const agentResult = await carryTurnEvents(stream, params.onEvent);
314
201
 
315
202
  onActivity();
316
-
317
203
  logDebug(
318
204
  "dispatcher",
319
- `[${reqId}] completed in ${result.durationMs}ms (in=${result.inputTokens} out=${result.outputTokens})`,
205
+ `[${reqId}] completed in ${agentResult?.durationMs ?? 0}ms ` +
206
+ `(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0})`,
320
207
  );
321
208
 
322
- return {
323
- ...result,
324
- bridgeMessageCount: context.getMessageCount(
325
- params.numericChatId,
326
- params.chatId,
327
- ),
328
- };
209
+ return this.toExecuteResult(agentResult, params);
329
210
  } finally {
330
- clearInterval(typingTimer);
211
+ stopTyping();
331
212
  context.release(params.numericChatId, params.chatId);
332
213
  }
333
214
  }
215
+
216
+ private toExecuteResult(
217
+ result: AgentResult | undefined,
218
+ params: ExecuteParams,
219
+ ): ExecuteResult {
220
+ return {
221
+ text: result?.text ?? "",
222
+ durationMs: result?.durationMs ?? 0,
223
+ inputTokens: result?.usage.inputTokens ?? 0,
224
+ outputTokens: result?.usage.outputTokens ?? 0,
225
+ cacheRead: result?.usage.cacheRead ?? 0,
226
+ cacheWrite: result?.usage.cacheWrite ?? 0,
227
+ bridgeMessageCount: this.deps.context.getMessageCount(
228
+ params.numericChatId,
229
+ params.chatId,
230
+ ),
231
+ };
232
+ }
233
+
234
+ private emptyResult(text: string, params: ExecuteParams): ExecuteResult {
235
+ return {
236
+ text,
237
+ durationMs: 0,
238
+ inputTokens: 0,
239
+ outputTokens: 0,
240
+ cacheRead: 0,
241
+ cacheWrite: 0,
242
+ bridgeMessageCount: this.deps.context.getMessageCount(
243
+ params.numericChatId,
244
+ params.chatId,
245
+ ),
246
+ };
247
+ }
334
248
  }
335
249
 
336
250
  let weaver: Weaver | null = null;
@@ -341,11 +255,6 @@ export function initWeaver(deps: WeaverDeps): Weaver {
341
255
  return weaver;
342
256
  }
343
257
 
344
- export function getWeaver(): Weaver {
345
- if (!weaver) throw new Error("Weaver not initialized");
346
- return weaver;
347
- }
348
-
349
258
  /**
350
259
  * The active Weaver's Loom, or `null` before the dispatcher is wired. The
351
260
  * gateway delegates its per-chat context bookkeeping here so the Loom is the
@@ -52,6 +52,20 @@ function toButtons(rows: unknown): ClientButton[][] | undefined {
52
52
  return mapped.length ? mapped : undefined;
53
53
  }
54
54
 
55
+ /** Actions this handler owns. Everything else must return null so the
56
+ * gateway can try plugin + shared actions (history, web, cron, mesh…) —
57
+ * failing early here would swallow actions that aren't native's to answer. */
58
+ const NATIVE_ACTIONS = new Set([
59
+ "send_message",
60
+ "send_message_with_buttons",
61
+ "send_photo",
62
+ "react",
63
+ "edit_message",
64
+ "delete_message",
65
+ "get_chat_info",
66
+ "send_chat_action",
67
+ ]);
68
+
55
69
  export function createNativeActionHandler(
56
70
  deps: NativeActionDeps,
57
71
  ): FrontendActionHandler {
@@ -59,6 +73,7 @@ export function createNativeActionHandler(
59
73
 
60
74
  return async (body, chatId): Promise<ActionResult | null> => {
61
75
  const action = typeof body.action === "string" ? body.action : "";
76
+ if (!NATIVE_ACTIONS.has(action)) return null;
62
77
  const entry = chats.byNumeric(chatId);
63
78
  if (!entry) return { ok: false, error: "No active native chat" };
64
79
 
@@ -126,7 +141,8 @@ export function createNativeActionHandler(
126
141
  return { ok: true };
127
142
 
128
143
  default:
129
- // Let the gateway try plugin + shared actions (history, web, cron…).
144
+ // Unreachable NATIVE_ACTIONS gates the switch but keeps the
145
+ // contract explicit if the set and the cases ever drift.
130
146
  return null;
131
147
  }
132
148
  };
@@ -30,6 +30,7 @@ import { resolveActiveModelForChat } from "../../core/models/active-model.js";
30
30
  import { forceDream } from "../../core/background/dream.js";
31
31
  import { execute } from "../../core/engine/dispatcher.js";
32
32
  import { toolInputToRecord } from "../../core/agent-runtime/events.js";
33
+ import { isDeliveryTool } from "../../core/tools/index.js";
33
34
  import {
34
35
  pushMessage,
35
36
  getRecentHistory,
@@ -65,6 +66,7 @@ import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
65
66
  import { extractSessionName } from "../../backend/shared/session-name.js";
66
67
  import { BridgeServer, type BridgeServerHandlers } from "./server.js";
67
68
  import { createNativeActionHandler } from "./actions.js";
69
+ import { getMeshService } from "../../core/mesh/index.js";
68
70
  import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
69
71
  import { readLogEntries } from "./logs.js";
70
72
  import {
@@ -147,6 +149,13 @@ export function createNativeFrontend(
147
149
  const startedAt = new Date().toISOString();
148
150
  const botName = config.botDisplayName || "Talon";
149
151
  const chats = new NativeChats();
152
+ // Daemon-wide mesh service (core/mesh). This frontend is its transport:
153
+ // companions register/report/answer through the bridge routes below, and
154
+ // the SSE transport (wired in init) pushes locate requests and device
155
+ // commands out. The model's mesh tools are served by the shared gateway
156
+ // actions, so they work from every frontend.
157
+ const mesh = getMeshService();
158
+ let unregisterMeshTransport: (() => void) | null = null;
150
159
 
151
160
  // Monotonic message-id minter. Seeded from the wall clock so ids stay
152
161
  // unique and ascending across restarts (history rows persist their ids).
@@ -576,6 +585,12 @@ export function createNativeFrontend(
576
585
  broadcast({ kind: "delta", chatId: entry.id, text: event.text });
577
586
  break;
578
587
  case "tool_call": {
588
+ // Delivery plumbing (end_turn / send_message / react) never
589
+ // enters the tool timeline — its effect arrives as the
590
+ // `message`/reaction itself. Skipping here keeps the live
591
+ // stream, the mid-turn replay to late joiners, and the
592
+ // persisted turn meta consistent from one choke point.
593
+ if (isDeliveryTool(event.name)) break;
579
594
  const input = toolInputToRecord(event.name, event.input);
580
595
  turnTools.set(event.id, {
581
596
  call: { id: event.id, name: event.name, input },
@@ -592,6 +607,7 @@ export function createNativeFrontend(
592
607
  break;
593
608
  }
594
609
  case "tool_result": {
610
+ if (isDeliveryTool(event.name)) break;
595
611
  const output = summarizeToolResult(event.result);
596
612
  const live = turnTools.get(event.id);
597
613
  if (live) {
@@ -724,6 +740,7 @@ export function createNativeFrontend(
724
740
  return {
725
741
  app: "talon-bridge",
726
742
  protocol: BRIDGE_PROTOCOL_VERSION,
743
+ capabilities: ["mesh", "mesh-commands"],
727
744
  botName,
728
745
  backend: config.backend,
729
746
  model: resolveModel(config.model)?.displayName ?? config.model,
@@ -1027,7 +1044,11 @@ export function createNativeFrontend(
1027
1044
  if (msg.role === "assistant") {
1028
1045
  const meta = getTurnMeta(id, msg.id);
1029
1046
  if (meta) {
1030
- if (meta.tools?.length) msg.tools = meta.tools;
1047
+ // Delivery tools are excluded at record time, but metas
1048
+ // persisted by older daemons still carry them — filter on
1049
+ // the way out so upgraded installs get clean history too.
1050
+ const tools = meta.tools?.filter((t) => !isDeliveryTool(t.name));
1051
+ if (tools?.length) msg.tools = tools;
1031
1052
  if (meta.durationMs) msg.durationMs = meta.durationMs;
1032
1053
  if (meta.tokensIn) msg.tokensIn = meta.tokensIn;
1033
1054
  if (meta.tokensOut) msg.tokensOut = meta.tokensOut;
@@ -1149,6 +1170,12 @@ export function createNativeFrontend(
1149
1170
  readLogEntries(files.log, { limit: lines, minLevel, component }),
1150
1171
  liveTurnEvents,
1151
1172
  mediaPath: (id) => media.get(id) ?? null,
1173
+ // Mesh routes are thin transport shims over the shared core service —
1174
+ // storeLocation wakes any pending fresh-fix waiters inside the service.
1175
+ registerDevice: (body) => mesh.register(body),
1176
+ storeLocation: (body) => mesh.storeLocation(body),
1177
+ listDevices: () => mesh.list(),
1178
+ completeCommand: (body) => mesh.completeCommand(body),
1152
1179
  };
1153
1180
 
1154
1181
  const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
@@ -1190,6 +1217,24 @@ export function createNativeFrontend(
1190
1217
  getBridgePort: () => gateway.getPort(),
1191
1218
 
1192
1219
  async init() {
1220
+ await mesh.load();
1221
+ // Plug this bridge in as the mesh's transport: locates and device
1222
+ // commands (from ANY frontend's mesh tool calls) fan out to every
1223
+ // connected companion client as SSE events; each client filters by
1224
+ // its own device id.
1225
+ unregisterMeshTransport = mesh.registerTransport({
1226
+ locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
1227
+ command: (command) =>
1228
+ broadcast({
1229
+ kind: "device_command",
1230
+ id: command.id,
1231
+ deviceId: command.deviceId,
1232
+ name: command.name,
1233
+ params: command.params,
1234
+ }),
1235
+ });
1236
+ // Mesh tool actions (list_devices / get_device_location) are shared
1237
+ // gateway actions now — no native-only cases here.
1193
1238
  gateway.registerFrontendHandler(
1194
1239
  "native",
1195
1240
  createNativeActionHandler({
@@ -1219,6 +1264,8 @@ export function createNativeFrontend(
1219
1264
  },
1220
1265
 
1221
1266
  async stop() {
1267
+ unregisterMeshTransport?.();
1268
+ unregisterMeshTransport = null;
1222
1269
  await removeBridgeDiscovery();
1223
1270
  await server.stop();
1224
1271
  await gateway.stop();
@@ -35,6 +35,11 @@ export type ClientButton = { text: string; url?: string; data?: string };
35
35
  * A tool invocation that ran during an assistant turn, persisted so history
36
36
  * shows what the model did even after a reload/restart (additive in v1 —
37
37
  * older clients simply ignore the field).
38
+ *
39
+ * Reply-delivery tools (`end_turn` / `send_message` / `react`) never appear
40
+ * here or in `tool` events — the daemon classifies and excludes them at the
41
+ * source, because their effect already reaches clients as the `message` /
42
+ * `reaction` itself. Clients render tool timelines as-is, no name filtering.
38
43
  */
39
44
  export type ClientToolCall = {
40
45
  id: string;
@@ -134,6 +139,8 @@ export type ClientChat = {
134
139
  export type BridgeStatus = {
135
140
  app: "talon-bridge";
136
141
  protocol: number;
142
+ /** Additive bridge features supported by this daemon. */
143
+ capabilities?: string[];
137
144
  botName: string;
138
145
  backend: string;
139
146
  /** Display name of the global default model. */
@@ -190,6 +197,18 @@ export type BackendOption = {
190
197
  label: string;
191
198
  };
192
199
 
200
+ // Mesh device shapes are canonical in core (the mesh is daemon-wide state,
201
+ // readable from every frontend); re-exported here so bridge clients keep
202
+ // depending on the protocol module alone.
203
+ export type {
204
+ DeviceCommand,
205
+ DeviceCommandResult,
206
+ DeviceInfo,
207
+ DeviceLocation,
208
+ DevicePlatform,
209
+ } from "../../core/mesh/types.js";
210
+ import type { DeviceCommand as MeshDeviceCommand } from "../../core/mesh/types.js";
211
+
193
212
  /**
194
213
  * Server → client events, delivered as SSE `data:` lines (one JSON object
195
214
  * each). The client switches on `kind`. Streaming text arrives as ephemeral
@@ -228,6 +247,20 @@ export type BridgeEvent =
228
247
  durationMs?: number;
229
248
  usage?: { input: number; output: number };
230
249
  }
250
+ | { kind: "locate"; deviceId?: string }
251
+ /**
252
+ * On-demand device command (ring, open_url, clipboard_*, status, …). The
253
+ * target device executes it and answers via POST /devices/command-result
254
+ * with the same `id` as `commandId`. Additive in v1 — app builds that
255
+ * predate the command channel simply ignore the event.
256
+ */
257
+ | {
258
+ kind: "device_command";
259
+ id: string;
260
+ deviceId: string;
261
+ name: string;
262
+ params: MeshDeviceCommand["params"];
263
+ }
231
264
  | { kind: "error"; chatId?: string; message: string };
232
265
 
233
266
  // ── Mappers (Talon internals → wire types) ───────────────────────────────────
@@ -251,3 +284,5 @@ export function historyToClientMessage(
251
284
  ts: m.timestamp,
252
285
  };
253
286
  }
287
+
288
+ export { toDeviceInfo, toDeviceLocation } from "../../core/mesh/types.js";