talon-agent 3.8.1 → 3.9.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/README.md +1 -1
- package/package.json +1 -2
- package/src/backend/claude-sdk/handler.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -1
- package/src/backend/kilo/handler/index.ts +1 -2
- package/src/backend/kilo/handler/state.ts +3 -8
- package/src/backend/kilo/index.ts +2 -28
- package/src/backend/openai-agents/handler/message.ts +1 -1
- package/src/backend/openai-agents/session.ts +1 -1
- package/src/backend/opencode/handler/index.ts +1 -2
- package/src/backend/opencode/handler/state.ts +3 -8
- package/src/backend/opencode/index.ts +0 -7
- package/src/backend/shared/delivery.ts +1 -1
- package/src/backend/shared/handle-retry.ts +1 -1
- package/src/backend/shared/index.ts +3 -2
- package/src/backend/shared/turn-interrupt.ts +1 -1
- package/src/cli/index.ts +0 -8
- package/src/core/bus/index.ts +1 -9
- package/src/core/mesh/registry.ts +106 -4
- package/src/core/mesh/service.ts +22 -7
- package/src/core/mesh/transfers.ts +33 -4
- package/src/core/plugin/index.ts +0 -9
- package/src/core/prompt/index.ts +1 -16
- package/src/core/vfs/index.ts +2 -21
- package/src/core/weaver/index.ts +5 -9
- package/src/frontend/discord/actions/messaging.ts +73 -13
- package/src/frontend/discord/actions/shared.ts +21 -3
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/errors.ts +16 -5
- package/src/frontend/discord/formatting.ts +19 -4
- package/src/frontend/native/index.ts +22 -8
- package/src/frontend/native/protocol.ts +6 -0
- package/src/frontend/native/server.ts +171 -12
- package/src/frontend/shared/format.ts +6 -0
- package/src/frontend/telegram/actions/messaging.ts +120 -24
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/formatting.ts +64 -19
- package/src/frontend/terminal/commands.ts +46 -8
- package/src/frontend/terminal/input.ts +110 -12
- package/src/frontend/terminal/renderer.ts +2 -0
- package/src/{util → storage}/metrics.ts +1 -1
- package/src/storage/scheduled-store.ts +6 -3
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/backend/codex/index.ts +0 -33
- package/src/backend/openai-agents/index.ts +0 -48
- package/src/cli/plugin-entries.ts +0 -104
- package/src/core/agent-runtime/index.ts +0 -62
- package/src/core/background/index.ts +0 -21
- package/src/core/engine/index.ts +0 -20
- package/src/core/models/index.ts +0 -17
- package/src/core/soul/index.ts +0 -140
- package/src/core/tools/mcp-server.ts +0 -111
- /package/src/{backend/shared → util}/session-name.ts +0 -0
package/src/core/vfs/index.ts
CHANGED
|
@@ -35,27 +35,8 @@ import { createProcMount } from "./mounts/proc.js";
|
|
|
35
35
|
import { Vfs } from "./vfs.js";
|
|
36
36
|
|
|
37
37
|
export { Vfs } from "./vfs.js";
|
|
38
|
-
|
|
39
|
-
export {
|
|
40
|
-
export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
|
|
41
|
-
export { syncNamespaceDir, type NsDirSync } from "./nsdir.js";
|
|
42
|
-
export {
|
|
43
|
-
isNamespaceFsMounted,
|
|
44
|
-
mountNamespaceFs,
|
|
45
|
-
namespaceFsStatus,
|
|
46
|
-
serveNamespaceRequest,
|
|
47
|
-
unmountNamespaceFs,
|
|
48
|
-
type FuseStatus,
|
|
49
|
-
type MountOptions,
|
|
50
|
-
} from "./fusefs.js";
|
|
51
|
-
export type {
|
|
52
|
-
VfsErrorCode,
|
|
53
|
-
VfsMount,
|
|
54
|
-
VfsNodeKind,
|
|
55
|
-
VfsResult,
|
|
56
|
-
VfsStat,
|
|
57
|
-
} from "./types.js";
|
|
58
|
-
export { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "./types.js";
|
|
38
|
+
|
|
39
|
+
export { mountNamespaceFs, unmountNamespaceFs } from "./fusefs.js";
|
|
59
40
|
|
|
60
41
|
function pluginViews(): PluginView[] {
|
|
61
42
|
const modules: PluginView[] = registry.all.map(({ plugin, path }) => ({
|
package/src/core/weaver/index.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
export { Thread, type
|
|
2
|
-
|
|
1
|
+
export { Thread, type ThreadSnapshot } from "./thread.js";
|
|
2
|
+
|
|
3
3
|
export { Loom, type ContextRegistry } from "./loom.js";
|
|
4
|
-
export { carryTurnEvents
|
|
5
|
-
export { startTypingLoop
|
|
6
|
-
export {
|
|
7
|
-
resolveWarp,
|
|
8
|
-
type WarpResolution,
|
|
9
|
-
type WarpResolverDeps,
|
|
10
|
-
} from "./warp-resolver.js";
|
|
4
|
+
export { carryTurnEvents } from "./shuttle.js";
|
|
5
|
+
export { startTypingLoop } from "./typing-loop.js";
|
|
6
|
+
export { resolveWarp } from "./warp-resolver.js";
|
|
11
7
|
export {
|
|
12
8
|
Weaver,
|
|
13
9
|
getActiveLoom,
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
import type { Client } from "discord.js";
|
|
7
7
|
import { withRetry } from "../../../core/engine/gateway.js";
|
|
8
8
|
import { sendChunked } from "../handlers/index.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
suppressMentions,
|
|
11
|
+
splitMessage,
|
|
12
|
+
DISCORD_MAX_TEXT,
|
|
13
|
+
} from "../formatting.js";
|
|
10
14
|
import { log, logError } from "../../../util/log.js";
|
|
11
15
|
import {
|
|
12
16
|
saveScheduled,
|
|
@@ -24,6 +28,41 @@ import type { DiscordActionHandlers } from "./types.js";
|
|
|
24
28
|
/** Longest schedulable delay: 24h. Timers re-arm from the store on boot. */
|
|
25
29
|
const MAX_DELAY_SEC = 24 * 60 * 60;
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Deliver one scheduled entry, replaying whatever it was scheduled with.
|
|
33
|
+
*
|
|
34
|
+
* The `send` tool documents that "buttons and reply threading survive the
|
|
35
|
+
* delay — the schedule handler replays them at fire time". Discord's path
|
|
36
|
+
* used to call `sendChunked(c, entry.text)` and drop both, so a scheduled
|
|
37
|
+
* message with buttons arrived bare while the caller had been told ok:true.
|
|
38
|
+
*/
|
|
39
|
+
async function fireScheduled(
|
|
40
|
+
channel: Awaited<ReturnType<typeof resolveChannel>>,
|
|
41
|
+
entry: ScheduledMessage,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!channel) return;
|
|
44
|
+
const replyTo =
|
|
45
|
+
entry.replyTo !== undefined ? String(entry.replyTo) : undefined;
|
|
46
|
+
if (!entry.rows || entry.rows.length === 0) {
|
|
47
|
+
await sendChunked(channel, entry.text, replyTo);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const components = buildButtonRows(entry.rows);
|
|
51
|
+
const chunks = splitMessage(suppressMentions(entry.text), DISCORD_MAX_TEXT);
|
|
52
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
53
|
+
if (!channel.isSendable()) return;
|
|
54
|
+
const last = i === chunks.length - 1;
|
|
55
|
+
await channel.send({
|
|
56
|
+
content: chunks[i],
|
|
57
|
+
...(last ? { components } : {}),
|
|
58
|
+
allowedMentions: { parse: [] },
|
|
59
|
+
...(i === 0 && replyTo
|
|
60
|
+
? { reply: { messageReference: replyTo, failIfNotExists: false } }
|
|
61
|
+
: {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
/** Arm a timer for a stored entry; fires, then cleans up store + map. */
|
|
28
67
|
function armScheduled(
|
|
29
68
|
client: Client,
|
|
@@ -34,7 +73,7 @@ function armScheduled(
|
|
|
34
73
|
const timer = setTimeout(async () => {
|
|
35
74
|
try {
|
|
36
75
|
const c = await resolveChannel(client, Number(entry.chatId));
|
|
37
|
-
if (c) await
|
|
76
|
+
if (c) await fireScheduled(c, entry);
|
|
38
77
|
} catch (err) {
|
|
39
78
|
logError(
|
|
40
79
|
"discord",
|
|
@@ -189,25 +228,44 @@ export const messagingHandlers: DiscordActionHandlers = {
|
|
|
189
228
|
}>
|
|
190
229
|
>;
|
|
191
230
|
gateway.incrementMessages(chatId);
|
|
192
|
-
const components = buildButtonRows(rows);
|
|
193
|
-
const safe = suppressMentions(text).slice(0, DISCORD_MAX_TEXT);
|
|
194
231
|
if (!channel!.isSendable())
|
|
195
232
|
return { ok: false, error: "Channel not sendable" };
|
|
196
233
|
return tryAction("send_message_with_buttons", async () => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
234
|
+
// buildButtonRows is inside tryAction on purpose: it can still reject a
|
|
235
|
+
// row, and escaping here would bypass the action's error mapping and
|
|
236
|
+
// lose the text as well as the buttons.
|
|
237
|
+
const components = buildButtonRows(rows);
|
|
238
|
+
// Chunk rather than slice. `.slice(0, 2000)` dropped everything past
|
|
239
|
+
// the limit mid-word and still answered ok:true, so the model never
|
|
240
|
+
// learned its reply had been cut — while the SAME text without buttons
|
|
241
|
+
// went through sendChunked and arrived whole.
|
|
242
|
+
const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
|
|
243
|
+
const ids: string[] = [];
|
|
244
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
245
|
+
const last = i === chunks.length - 1;
|
|
246
|
+
const sent = await withRetry(
|
|
247
|
+
() =>
|
|
248
|
+
channel!.send({
|
|
249
|
+
content: chunks[i],
|
|
250
|
+
// Buttons belong on the final chunk: they act on the whole
|
|
251
|
+
// message, and Discord would render a set per chunk otherwise.
|
|
252
|
+
...(last ? { components } : {}),
|
|
253
|
+
allowedMentions: { parse: [] },
|
|
254
|
+
}) as Promise<{ id: string }>,
|
|
255
|
+
);
|
|
256
|
+
ids.push(sent.id);
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, message_id: ids[0], message_ids: ids };
|
|
206
259
|
});
|
|
207
260
|
},
|
|
208
261
|
|
|
209
262
|
schedule_message: (body, chatId, { client, scheduledMessages }) => {
|
|
210
263
|
const text = String(body.text ?? "");
|
|
264
|
+
const rows = body.rows as ScheduledMessage["rows"];
|
|
265
|
+
const replyTo =
|
|
266
|
+
typeof body.reply_to_message_id === "string"
|
|
267
|
+
? body.reply_to_message_id
|
|
268
|
+
: undefined;
|
|
211
269
|
// NaN (e.g. delay_seconds: "5m") must fall back to the default, not
|
|
212
270
|
// propagate: setTimeout(fn, NaN) fires immediately.
|
|
213
271
|
const requested = Number(body.delay_seconds ?? 60);
|
|
@@ -222,6 +280,8 @@ export const messagingHandlers: DiscordActionHandlers = {
|
|
|
222
280
|
text,
|
|
223
281
|
fireAt: Date.now() + delaySec * 1000,
|
|
224
282
|
createdAt: Date.now(),
|
|
283
|
+
replyTo,
|
|
284
|
+
rows,
|
|
225
285
|
};
|
|
226
286
|
// Persist before arming: if we crash between the two, the restore
|
|
227
287
|
// path delivers it; the reverse order could lose it forever.
|
|
@@ -70,11 +70,21 @@ export function buildButtonRows(
|
|
|
70
70
|
>,
|
|
71
71
|
): ActionRowBuilder<ButtonBuilder>[] {
|
|
72
72
|
const out: ActionRowBuilder<ButtonBuilder>[] = [];
|
|
73
|
+
// custom_id must be unique within a message or Discord rejects the send.
|
|
74
|
+
// Two buttons with the same label — or none at all, which both collapse to
|
|
75
|
+
// the bare `ai:` prefix — used to produce a duplicate pair.
|
|
76
|
+
const usedIds = new Set<string>();
|
|
73
77
|
for (const row of rows.slice(0, 5)) {
|
|
74
78
|
const arb = new ActionRowBuilder<ButtonBuilder>();
|
|
75
79
|
for (const btn of row.slice(0, 5)) {
|
|
76
80
|
const b = new ButtonBuilder().setLabel(safeSlice(btn.text || "•", 80));
|
|
77
|
-
|
|
81
|
+
// Only http(s) can be a Link button. discord.js throws on anything
|
|
82
|
+
// else — a bare domain ("docs.example.com", a common model output) or
|
|
83
|
+
// a javascript: URL — and the throw escapes before the send, so ONE
|
|
84
|
+
// malformed URL discarded the reply text and every other button too.
|
|
85
|
+
// Downgrade to a plain non-link button instead: the label still says
|
|
86
|
+
// what it meant, and the rest of the message survives.
|
|
87
|
+
if (btn.url !== undefined && /^https?:\/\//i.test(btn.url)) {
|
|
78
88
|
b.setStyle(ButtonStyle.Link).setURL(btn.url);
|
|
79
89
|
} else {
|
|
80
90
|
const styleMap: Record<string, ButtonStyle> = {
|
|
@@ -88,13 +98,21 @@ export function buildButtonRows(
|
|
|
88
98
|
// Namespace AI-generated custom_ids under `ai:` so the callback router
|
|
89
99
|
// never confuses them with system custom_ids like `settings:done`.
|
|
90
100
|
// The router strips the prefix before forwarding to the agent.
|
|
101
|
+
//
|
|
102
|
+
// Budget is 100 UTF-16 units total (Discord's cap), so the raw part
|
|
103
|
+
// gets 100 minus the 3-unit prefix minus room for a de-dupe suffix.
|
|
91
104
|
const raw = btn.callback_data || btn.text || "";
|
|
92
|
-
|
|
105
|
+
let id = `ai:${safeSlice(raw, 93)}`;
|
|
106
|
+
for (let n = 2; usedIds.has(id); n++)
|
|
107
|
+
id = `ai:${safeSlice(raw, 93)}#${n}`;
|
|
108
|
+
usedIds.add(id);
|
|
93
109
|
b.setStyle(style).setCustomId(id);
|
|
94
110
|
}
|
|
95
111
|
arb.addComponents(b);
|
|
96
112
|
}
|
|
97
|
-
|
|
113
|
+
// Discord requires at least one component per action row; an empty row
|
|
114
|
+
// (from an empty inner array) fails the whole message.
|
|
115
|
+
if (arb.components.length > 0) out.push(arb);
|
|
98
116
|
}
|
|
99
117
|
return out;
|
|
100
118
|
}
|
|
@@ -9,7 +9,7 @@ import type { Gateway } from "../../../core/engine/gateway.js";
|
|
|
9
9
|
import { respawnSelf } from "../../../util/respawn.js";
|
|
10
10
|
import { forceDream } from "../../../core/background/dream.js";
|
|
11
11
|
import { formatDuration, renderMetricsMessages } from "../helpers.js";
|
|
12
|
-
import { getMetrics, getTodayMetrics } from "../../../
|
|
12
|
+
import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
|
|
13
13
|
import { handleAdminSubcommand } from "../admin.js";
|
|
14
14
|
import { isAdmin } from "../handlers/index.js";
|
|
15
15
|
import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
|
|
@@ -18,13 +18,24 @@ type DiscordAPIErrorLike = {
|
|
|
18
18
|
rawError?: { code?: number };
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Extract numeric JSON error code, if this looks like a DiscordAPIError.
|
|
23
|
+
*
|
|
24
|
+
* Walks the `cause` chain, which is what makes this work at all on the
|
|
25
|
+
* primary send paths. Those run inside `withRetry`, which rethrows
|
|
26
|
+
* `classify(err)` — a TalonError carrying only the message, with the
|
|
27
|
+
* DiscordAPIError demoted to `cause`. A shallow look therefore found no
|
|
28
|
+
* code and every curated message below was dead on `send_message`,
|
|
29
|
+
* `reply_to`, `send_message_with_buttons`, the media sends, and
|
|
30
|
+
* forward/copy: the agent got a bare "Missing Permissions" instead of
|
|
31
|
+
* being told which permission and on what.
|
|
32
|
+
*/
|
|
33
|
+
function discordErrorCode(err: unknown, depth = 0): number | null {
|
|
34
|
+
if (depth > 5 || !err || typeof err !== "object") return null;
|
|
35
|
+
const e = err as DiscordAPIErrorLike & { cause?: unknown };
|
|
25
36
|
if (typeof e.code === "number") return e.code;
|
|
26
37
|
if (typeof e.rawError?.code === "number") return e.rawError.code;
|
|
27
|
-
return
|
|
38
|
+
return discordErrorCode(e.cause, depth + 1);
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
/**
|
|
@@ -58,12 +58,27 @@ export function suppressMentions(text: string): string {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Slice a string
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* Slice a string so it satisfies a Discord length limit, without splitting a
|
|
62
|
+
* surrogate pair. Used for `customId`, `label`, `value`, etc.
|
|
63
|
+
*
|
|
64
|
+
* `max` counts UTF-16 code units, because that is what Discord (and
|
|
65
|
+
* discord.js's validators, which call `String.length`) measure. Counting
|
|
66
|
+
* code points instead — `Array.from(text).slice(0, max)` — silently
|
|
67
|
+
* overshoots for any astral character: 80 code points of emoji is 160 code
|
|
68
|
+
* units, so the guard passed and discord.js then threw
|
|
69
|
+
* `ExpectedConstraintError: Invalid string length`, taking the whole message
|
|
70
|
+
* with it. The guard worked precisely when it wasn't needed and failed when
|
|
71
|
+
* it was.
|
|
64
72
|
*/
|
|
65
73
|
export function safeSlice(text: string, max: number): string {
|
|
66
|
-
|
|
74
|
+
if (text.length <= max) return text;
|
|
75
|
+
let out = "";
|
|
76
|
+
// Iterating a string yields whole code points, so a pair is never split.
|
|
77
|
+
for (const char of text) {
|
|
78
|
+
if (out.length + char.length > max) break;
|
|
79
|
+
out += char;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/** Simple HTML-style escape used when displaying raw text inside code blocks. */
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
} from "../../core/engine/backend-controller/index.js";
|
|
70
70
|
import { getActiveReasoningLevels } from "../shared/reasoning-levels.js";
|
|
71
71
|
import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
|
|
72
|
-
import { extractSessionName } from "../../
|
|
72
|
+
import { extractSessionName } from "../../util/session-name.js";
|
|
73
73
|
import { BridgeServer, type BridgeServerHandlers } from "./server.js";
|
|
74
74
|
import { createNativeActionHandler } from "./actions.js";
|
|
75
75
|
import { getMeshService } from "../../core/mesh/index.js";
|
|
@@ -1192,8 +1192,10 @@ export function createNativeFrontend(
|
|
|
1192
1192
|
storeLocation: (body) => mesh.storeLocation(body),
|
|
1193
1193
|
listDevices: () => mesh.list(),
|
|
1194
1194
|
completeCommand: (body) => mesh.completeCommand(body),
|
|
1195
|
-
acceptFileUpload: (token, body) =>
|
|
1196
|
-
|
|
1195
|
+
acceptFileUpload: (token, body, fromDeviceId) =>
|
|
1196
|
+
mesh.acceptFileUpload(token, body, fromDeviceId),
|
|
1197
|
+
openFileDownload: (token, fromDeviceId) =>
|
|
1198
|
+
mesh.openFileDownload(token, fromDeviceId),
|
|
1197
1199
|
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
1198
1200
|
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
1199
1201
|
};
|
|
@@ -1205,14 +1207,22 @@ export function createNativeFrontend(
|
|
|
1205
1207
|
const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
|
|
1206
1208
|
// Never serve the agent API to the network unauthenticated: a non-loopback
|
|
1207
1209
|
// bind with no configured token gets a persistent auto-minted one instead.
|
|
1210
|
+
// `?? ` alone would treat an empty string as a configured token and skip
|
|
1211
|
+
// the mint, serving the LAN unauthenticated while looking configured —
|
|
1212
|
+
// easy to hit from `"token": "${BRIDGE_TOKEN}"` with the var unset.
|
|
1213
|
+
const configuredToken =
|
|
1214
|
+
nativeCfg.token !== undefined && nativeCfg.token !== ""
|
|
1215
|
+
? nativeCfg.token
|
|
1216
|
+
: undefined;
|
|
1208
1217
|
const bridgeToken =
|
|
1209
|
-
|
|
1218
|
+
configuredToken ??
|
|
1210
1219
|
(isLoopbackHost(bridgeHost) ? undefined : loadOrCreateBridgeToken());
|
|
1211
1220
|
const server = new BridgeServer(
|
|
1212
1221
|
{
|
|
1213
1222
|
host: bridgeHost,
|
|
1214
1223
|
port: nativeCfg.port ?? 19880,
|
|
1215
1224
|
token: bridgeToken,
|
|
1225
|
+
allowedOrigins: nativeCfg.allowedOrigins,
|
|
1216
1226
|
startedAt,
|
|
1217
1227
|
...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
|
|
1218
1228
|
},
|
|
@@ -1249,13 +1259,17 @@ export function createNativeFrontend(
|
|
|
1249
1259
|
async init() {
|
|
1250
1260
|
await mesh.load();
|
|
1251
1261
|
// Plug this bridge in as the mesh's transport: locates and device
|
|
1252
|
-
// commands (from ANY frontend's mesh tool calls)
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1262
|
+
// commands (from ANY frontend's mesh tool calls) leave as SSE events.
|
|
1263
|
+
//
|
|
1264
|
+
// A locate is a bare "who's there?" — no secret in the frame, and
|
|
1265
|
+
// pre-command app builds rely on receiving it — so it still fans out.
|
|
1266
|
+
// A command is the opposite: its params carry transfer tokens, exec
|
|
1267
|
+
// command lines and (on the chunked fallback) file bodies, so it goes
|
|
1268
|
+
// to the target device's own client(s) only.
|
|
1255
1269
|
unregisterMeshTransport = mesh.registerTransport({
|
|
1256
1270
|
locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
|
|
1257
1271
|
command: (command) =>
|
|
1258
|
-
|
|
1272
|
+
server.sendToDevice(command.deviceId, {
|
|
1259
1273
|
kind: "device_command",
|
|
1260
1274
|
id: command.id,
|
|
1261
1275
|
deviceId: command.deviceId,
|
|
@@ -286,6 +286,12 @@ export type BridgeEvent =
|
|
|
286
286
|
* target device executes it and answers via POST /devices/command-result
|
|
287
287
|
* with the same `id` as `commandId`. Additive in v1 — app builds that
|
|
288
288
|
* predate the command channel simply ignore the event.
|
|
289
|
+
*
|
|
290
|
+
* Addressed, not broadcast: params carry one-time transfer tokens, exec
|
|
291
|
+
* command lines and (on the chunked fallback) file bodies, so the frame
|
|
292
|
+
* goes only to the SSE client(s) that claimed this `deviceId` via
|
|
293
|
+
* `GET /events?deviceId=…`. Clients that claim no id still receive
|
|
294
|
+
* commands for devices nobody claimed, so pre-claim builds keep working.
|
|
289
295
|
*/
|
|
290
296
|
| {
|
|
291
297
|
kind: "device_command";
|