talon-agent 3.33.4 → 3.34.1

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 (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -20,134 +20,147 @@ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
20
20
  import type { VfsMount, VfsResult, VfsStat } from "../types.js";
21
21
  import { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "../types.js";
22
22
 
23
- export function createFileMount(options: {
23
+ type FileMountOptions = {
24
24
  root: string;
25
25
  description: string;
26
26
  writable: boolean;
27
- }): VfsMount {
28
- const root = resolve(options.root);
27
+ };
29
28
 
30
- /** Absolute path for `rel`, or undefined when it would escape the root. */
31
- function realPath(rel: string): string | undefined {
32
- const abs = resolve(root, ...rel.split("/"));
33
- const offset = relative(root, abs);
34
- if (
35
- offset.startsWith(`..${sep}`) ||
36
- offset === ".." ||
37
- isAbsolute(offset)
38
- ) {
39
- return undefined;
40
- }
41
- return abs;
29
+ /** Absolute path for `rel` under `root`, or undefined when it would escape. */
30
+ function realPath(root: string, rel: string): string | undefined {
31
+ const abs = resolve(root, ...rel.split("/"));
32
+ const offset = relative(root, abs);
33
+ if (offset.startsWith(`..${sep}`) || offset === ".." || isAbsolute(offset)) {
34
+ return undefined;
42
35
  }
36
+ return abs;
37
+ }
43
38
 
44
- function statAt(rel: string, abs: string): VfsResult<VfsStat> {
45
- try {
46
- const stats = statSync(abs);
47
- const name = rel === "" ? "" : rel.split("/").at(-1)!;
48
- return vfsOk({
49
- path: rel,
50
- name,
51
- kind: stats.isDirectory() ? ("dir" as const) : ("file" as const),
52
- ...(stats.isFile() ? { size: stats.size } : {}),
53
- modifiedAt: Math.floor(stats.mtimeMs),
54
- writable: options.writable,
55
- osPath: abs,
56
- });
57
- } catch {
58
- return vfsError("not-found");
59
- }
39
+ function statAt(
40
+ rel: string,
41
+ abs: string,
42
+ writable: boolean,
43
+ ): VfsResult<VfsStat> {
44
+ try {
45
+ const stats = statSync(abs);
46
+ const name = rel === "" ? "" : rel.split("/").at(-1)!;
47
+ return vfsOk({
48
+ path: rel,
49
+ name,
50
+ kind: stats.isDirectory() ? ("dir" as const) : ("file" as const),
51
+ ...(stats.isFile() ? { size: stats.size } : {}),
52
+ modifiedAt: Math.floor(stats.mtimeMs),
53
+ writable,
54
+ osPath: abs,
55
+ });
56
+ } catch {
57
+ return vfsError("not-found");
60
58
  }
59
+ }
61
60
 
62
- return {
63
- description: options.description,
64
- writable: options.writable,
65
- osRoot: root,
61
+ function statMount(
62
+ root: string,
63
+ writable: boolean,
64
+ rel: string,
65
+ ): VfsResult<VfsStat> {
66
+ const abs = realPath(root, rel);
67
+ if (!abs) return vfsError("invalid-path");
68
+ if (rel === "" && !existsSync(abs)) {
69
+ return vfsOk({ path: "", name: "", kind: "dir", writable, osPath: abs });
70
+ }
71
+ return statAt(rel, abs, writable);
72
+ }
66
73
 
67
- stat(rel) {
68
- const abs = realPath(rel);
69
- if (!abs) return vfsError("invalid-path");
70
- if (rel === "" && !existsSync(abs)) {
71
- return vfsOk({
72
- path: "",
73
- name: "",
74
- kind: "dir",
75
- writable: options.writable,
76
- osPath: abs,
77
- });
78
- }
79
- return statAt(rel, abs);
80
- },
74
+ function listMount(
75
+ root: string,
76
+ writable: boolean,
77
+ rel: string,
78
+ ): VfsResult<VfsStat[]> {
79
+ const abs = realPath(root, rel);
80
+ if (!abs) return vfsError("invalid-path");
81
+ if (rel === "" && !existsSync(abs)) return vfsOk([]);
82
+ let stats;
83
+ try {
84
+ stats = statSync(abs);
85
+ } catch {
86
+ return vfsError("not-found");
87
+ }
88
+ if (!stats.isDirectory()) return vfsError("not-a-directory");
89
+ const entries = readdirSync(abs, { withFileTypes: true })
90
+ .map((entry) => {
91
+ const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
92
+ const child = statAt(childRel, resolve(abs, entry.name), writable);
93
+ return child.ok ? child.value : undefined;
94
+ })
95
+ .filter((entry): entry is VfsStat => entry !== undefined)
96
+ .sort(
97
+ (a, b) =>
98
+ Number(a.kind === "file") - Number(b.kind === "file") ||
99
+ a.name.localeCompare(b.name),
100
+ );
101
+ return vfsOk(entries);
102
+ }
81
103
 
82
- list(rel) {
83
- const abs = realPath(rel);
84
- if (!abs) return vfsError("invalid-path");
85
- if (rel === "" && !existsSync(abs)) return vfsOk([]);
86
- let stats;
87
- try {
88
- stats = statSync(abs);
89
- } catch {
90
- return vfsError("not-found");
91
- }
92
- if (!stats.isDirectory()) return vfsError("not-a-directory");
93
- const entries = readdirSync(abs, { withFileTypes: true })
94
- .map((entry) => {
95
- const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
96
- const child = statAt(childRel, resolve(abs, entry.name));
97
- return child.ok ? child.value : undefined;
98
- })
99
- .filter((entry): entry is VfsStat => entry !== undefined)
100
- .sort(
101
- (a, b) =>
102
- Number(a.kind === "file") - Number(b.kind === "file") ||
103
- a.name.localeCompare(b.name),
104
- );
105
- return vfsOk(entries);
106
- },
104
+ function readMount(root: string, rel: string): VfsResult<string> {
105
+ const abs = realPath(root, rel);
106
+ if (!abs) return vfsError("invalid-path");
107
+ let stats;
108
+ try {
109
+ stats = statSync(abs);
110
+ } catch {
111
+ return vfsError("not-found");
112
+ }
113
+ if (stats.isDirectory()) return vfsError("is-a-directory");
114
+ if (stats.size > VFS_MAX_READ_BYTES) {
115
+ return vfsError(
116
+ "too-large",
117
+ `${stats.size} bytes (cap ${VFS_MAX_READ_BYTES}) — on disk at ${abs}`,
118
+ );
119
+ }
120
+ const buffer = readFileSync(abs);
121
+ if (buffer.includes(0)) {
122
+ return vfsError("binary-file", "Content contains null bytes");
123
+ }
124
+ return vfsOk(buffer.toString("utf-8"));
125
+ }
107
126
 
108
- read(rel) {
109
- const abs = realPath(rel);
110
- if (!abs) return vfsError("invalid-path");
111
- let stats;
112
- try {
113
- stats = statSync(abs);
114
- } catch {
115
- return vfsError("not-found");
116
- }
117
- if (stats.isDirectory()) return vfsError("is-a-directory");
118
- if (stats.size > VFS_MAX_READ_BYTES) {
119
- return vfsError(
120
- "too-large",
121
- `${stats.size} bytes (cap ${VFS_MAX_READ_BYTES}) — on disk at ${abs}`,
122
- );
123
- }
124
- const buffer = readFileSync(abs);
125
- if (buffer.includes(0)) {
126
- return vfsError("binary-file", "Content contains null bytes");
127
- }
128
- return vfsOk(buffer.toString("utf-8"));
129
- },
127
+ function writeMount(
128
+ root: string,
129
+ rel: string,
130
+ content: string,
131
+ ): VfsResult<VfsStat> {
132
+ if (rel === "") return vfsError("is-a-directory");
133
+ const abs = realPath(root, rel);
134
+ if (!abs) return vfsError("invalid-path");
135
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
136
+ return vfsError("is-a-directory");
137
+ }
138
+ try {
139
+ mkdirSync(dirname(abs), { recursive: true });
140
+ writeFileSync(abs, content, "utf-8");
141
+ } catch (err) {
142
+ return vfsError(
143
+ "io-error",
144
+ err instanceof Error ? err.message : String(err),
145
+ );
146
+ }
147
+ return statAt(rel, abs, true);
148
+ }
130
149
 
131
- ...(options.writable
150
+ export function createFileMount(options: FileMountOptions): VfsMount {
151
+ const root = resolve(options.root);
152
+ const { writable } = options;
153
+ return {
154
+ description: options.description,
155
+ writable,
156
+ osRoot: root,
157
+ stat: (rel) => statMount(root, writable, rel),
158
+ list: (rel) => listMount(root, writable, rel),
159
+ read: (rel) => readMount(root, rel),
160
+ ...(writable
132
161
  ? {
133
- write(rel: string, content: string): VfsResult<VfsStat> {
134
- if (rel === "") return vfsError("is-a-directory");
135
- const abs = realPath(rel);
136
- if (!abs) return vfsError("invalid-path");
137
- if (existsSync(abs) && statSync(abs).isDirectory()) {
138
- return vfsError("is-a-directory");
139
- }
140
- try {
141
- mkdirSync(dirname(abs), { recursive: true });
142
- writeFileSync(abs, content, "utf-8");
143
- } catch (err) {
144
- return vfsError(
145
- "io-error",
146
- err instanceof Error ? err.message : String(err),
147
- );
148
- }
149
- return statAt(rel, abs);
150
- },
162
+ write: (rel: string, content: string) =>
163
+ writeMount(root, rel, content),
151
164
  }
152
165
  : {}),
153
166
  };
@@ -26,9 +26,22 @@ import {
26
26
  type AgentEvent,
27
27
  type AgentResult,
28
28
  } from "../agent-runtime/events.js";
29
+ import { recordTurnActivity } from "../../util/watchdog.js";
29
30
 
30
31
  export type EventSink = (event: AgentEvent) => void | Promise<void>;
31
32
 
33
+ /**
34
+ * Wall-clock the shuttle observes while carrying one stream: when the
35
+ * first event arrived (`undefined` if the stream ended empty) and how many
36
+ * ms were spent awaiting the frontend's sink in total.
37
+ */
38
+ export type ShuttleTiming = {
39
+ firstEventAt?: number;
40
+ deliveryMs: number;
41
+ };
42
+
43
+ export const startShuttleTiming = (): ShuttleTiming => ({ deliveryMs: 0 });
44
+
32
45
  /**
33
46
  * Pump the stream to completion. Returns the `completed` event's
34
47
  * result (if the backend emitted one); throws `AgentRunError` when the
@@ -37,16 +50,34 @@ export type EventSink = (event: AgentEvent) => void | Promise<void>;
37
50
  export async function carryTurnEvents(
38
51
  stream: AsyncIterable<AgentEvent>,
39
52
  onEvent?: EventSink,
53
+ timing?: ShuttleTiming,
40
54
  ): Promise<AgentResult | undefined> {
41
55
  let agentResult: AgentResult | undefined;
56
+ const sink: EventSink | undefined =
57
+ onEvent && timing
58
+ ? async (event) => {
59
+ const t0 = Date.now();
60
+ try {
61
+ await onEvent(event);
62
+ } finally {
63
+ timing.deliveryMs += Date.now() - t0;
64
+ }
65
+ }
66
+ : onEvent;
42
67
  for await (const event of stream) {
68
+ // Every backend's events cross this loop exactly once, so this is the
69
+ // one place the watchdog learns a turn is alive (vs. wedged).
70
+ recordTurnActivity();
71
+ if (timing && timing.firstEventAt === undefined) {
72
+ timing.firstEventAt = Date.now();
73
+ }
43
74
  if (event.type === "completed") {
44
75
  agentResult = event.result;
45
76
  }
46
77
 
47
78
  if (event.type === "assistant_message" && event.deliveryAck) {
48
79
  try {
49
- await onEvent?.(event);
80
+ await sink?.(event);
50
81
  event.deliveryAck.resolve();
51
82
  } catch (err) {
52
83
  event.deliveryAck.reject(err);
@@ -54,7 +85,7 @@ export async function carryTurnEvents(
54
85
  continue;
55
86
  }
56
87
 
57
- await onEvent?.(event);
88
+ await sink?.(event);
58
89
  if (event.type === "error") {
59
90
  throw new AgentRunError(event.error);
60
91
  }
@@ -24,9 +24,11 @@ import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
24
24
  import { bus } from "../bus/index.js";
25
25
  import { taskTable, type TaskHandle } from "../tasks/index.js";
26
26
  import { log, logDebug, logWarn } from "../../util/log.js";
27
+ import { recordSessionTurnPhases } from "../../storage/sessions.js";
28
+ import type { TurnPhase } from "../../storage/session-record.js";
27
29
  import { TalonError } from "../errors.js";
28
30
  import { Loom } from "./loom.js";
29
- import { carryTurnEvents } from "./shuttle.js";
31
+ import { carryTurnEvents, startShuttleTiming } from "./shuttle.js";
30
32
  import type { Thread, ThreadSnapshot } from "./thread.js";
31
33
  import { startTypingLoop } from "./typing-loop.js";
32
34
  import { resolveWarp } from "./warp-resolver.js";
@@ -72,7 +74,7 @@ export class Weaver {
72
74
  // interrupt the same chat's currently running one.
73
75
  const chat = this.deps.getBackend(params.chatId).chat;
74
76
  const interrupt = chat?.interruptChatTurn?.bind(chat);
75
- const lifecycle = { started: false, killed: false };
77
+ const lifecycle = { started: false, killed: false, enqueuedAt: Date.now() };
76
78
  // Registered before enqueueing so a turn waiting in its chat's FIFO is
77
79
  // visible as `queued` in the task table, not invisible until it runs.
78
80
  const task = taskTable.enqueue({
@@ -109,7 +111,7 @@ export class Weaver {
109
111
  thread: Thread,
110
112
  params: ExecuteParams,
111
113
  task: TaskHandle,
112
- lifecycle: { started: boolean; killed: boolean },
114
+ lifecycle: { started: boolean; killed: boolean; enqueuedAt: number },
113
115
  ): Promise<ExecuteResult> {
114
116
  if (lifecycle.killed) {
115
117
  // Killed while queued — the turn never reaches the backend. The
@@ -122,7 +124,9 @@ export class Weaver {
122
124
  this.activeCount++;
123
125
  task.start();
124
126
  try {
125
- const result = await this.executeInner(thread, params, task);
127
+ const result = await this.executeInner(thread, params, task, {
128
+ queueWait: Date.now() - lifecycle.enqueuedAt,
129
+ });
126
130
  const usage = {
127
131
  inputTokens: result.inputTokens,
128
132
  outputTokens: result.outputTokens,
@@ -162,17 +166,20 @@ export class Weaver {
162
166
  thread: Thread,
163
167
  params: ExecuteParams,
164
168
  task: TaskHandle,
169
+ phases: Partial<Record<TurnPhase, number>>,
165
170
  ): Promise<ExecuteResult> {
166
171
  const { context } = this.deps;
167
172
  const backend = this.deps.getBackend(params.chatId);
168
173
  const reqId = randomBytes(4).toString("hex");
169
174
 
175
+ const warpStartedAt = Date.now();
170
176
  const warp = await resolveWarp(this.deps, {
171
177
  chatId: params.chatId,
172
178
  modelOverride: params.modelOverride,
173
179
  source: params.source,
174
180
  reqId,
175
181
  });
182
+ phases.warpResolve = Date.now() - warpStartedAt;
176
183
  if (!warp.ok) {
177
184
  // Refusals are delivered through the same event sink the backend
178
185
  // would use for output (as an `assistant_message` event, so the
@@ -246,7 +253,15 @@ export class Weaver {
246
253
  isGroup: params.isGroup,
247
254
  messageId: params.messageId,
248
255
  });
249
- const agentResult = await carryTurnEvents(stream, params.onEvent);
256
+ const timing = startShuttleTiming();
257
+ const streamStartedAt = Date.now();
258
+ const agentResult = await carryTurnEvents(stream, params.onEvent, timing);
259
+ phases.stream = Date.now() - streamStartedAt;
260
+ phases.delivery = timing.deliveryMs;
261
+ if (timing.firstEventAt !== undefined) {
262
+ phases.firstToken = timing.firstEventAt - streamStartedAt;
263
+ }
264
+ recordSessionTurnPhases(params.chatId, phases);
250
265
 
251
266
  // Completion signal — pulse (and any other liveness subscriber) keys
252
267
  // off this. Failures throw past it; refusals never get this far.
@@ -261,7 +276,8 @@ export class Weaver {
261
276
  logDebug(
262
277
  "dispatcher",
263
278
  `[${reqId}] completed in ${agentResult?.durationMs ?? 0}ms ` +
264
- `(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0})`,
279
+ `(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0}) ` +
280
+ formatPhases(phases),
265
281
  );
266
282
 
267
283
  return this.toExecuteResult(agentResult, params);
@@ -305,6 +321,21 @@ export class Weaver {
305
321
  }
306
322
  }
307
323
 
324
+ /** `queue=12ms warp=3ms ttft=1840ms stream=6200ms delivery=310ms` */
325
+ function formatPhases(phases: Partial<Record<TurnPhase, number>>): string {
326
+ const labels: Record<TurnPhase, string> = {
327
+ queueWait: "queue",
328
+ warpResolve: "warp",
329
+ firstToken: "ttft",
330
+ stream: "stream",
331
+ delivery: "delivery",
332
+ };
333
+ return (Object.keys(labels) as TurnPhase[])
334
+ .filter((phase) => phases[phase] !== undefined)
335
+ .map((phase) => `${labels[phase]}=${phases[phase]}ms`)
336
+ .join(" ");
337
+ }
338
+
308
339
  let weaver: Weaver | null = null;
309
340
 
310
341
  export function initWeaver(deps: WeaverDeps): Weaver {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `ai:*` — AI-generated buttons (send_message_with_buttons), forwarded to
3
+ * the agent verbatim with the routing prefix stripped.
4
+ */
5
+
6
+ import { execute } from "../../../../core/engine/dispatcher.js";
7
+ import { appendDailyLog } from "../../../../storage/daily-log.js";
8
+ import { logError } from "../../../../util/log.js";
9
+ import {
10
+ suppressMentions,
11
+ splitMessage,
12
+ DISCORD_MAX_TEXT,
13
+ } from "../../formatting.js";
14
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
15
+
16
+ export async function forwardToAgent(
17
+ interaction: ComponentInteraction,
18
+ { chatId, numericChatId }: ComponentContext,
19
+ ): Promise<boolean> {
20
+ // Acknowledge so Discord doesn't show "interaction failed"
21
+ try {
22
+ await interaction.deferUpdate();
23
+ } catch {
24
+ /* might already be acked */
25
+ }
26
+
27
+ const sender =
28
+ interaction.member?.user?.username ?? interaction.user.username ?? "User";
29
+ const isGroup = interaction.guildId !== null;
30
+ // Strip the `ai:` prefix so the agent sees the original callback_data it
31
+ // emitted in send_message_with_buttons, not our routing namespace.
32
+ const rawId = interaction.customId.startsWith("ai:")
33
+ ? interaction.customId.slice(3)
34
+ : interaction.customId;
35
+ const prompt = `[Button pressed] User clicked component with custom_id: "${rawId}"`;
36
+ const chatTitle = isGroup
37
+ ? `${interaction.guild?.name ?? interaction.guildId} #${(interaction.channel as { name?: string } | null)?.name ?? interaction.channelId}`
38
+ : undefined;
39
+
40
+ appendDailyLog(sender, `Button: ${rawId}`, { chatTitle });
41
+
42
+ const onTextBlock = async (text: string) => {
43
+ if (!text.trim()) return;
44
+ if (!interaction.channel?.isSendable()) return;
45
+ const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
46
+ for (const c of chunks) {
47
+ try {
48
+ await interaction.channel.send({
49
+ content: c,
50
+ allowedMentions: { parse: [] },
51
+ });
52
+ } catch (err) {
53
+ logError(
54
+ "discord",
55
+ `forwardToAgent send failed: ${err instanceof Error ? err.message : err}`,
56
+ );
57
+ }
58
+ }
59
+ };
60
+
61
+ try {
62
+ await execute({
63
+ chatId,
64
+ numericChatId,
65
+ prompt,
66
+ senderName: sender,
67
+ isGroup,
68
+ source: "message",
69
+ onEvent: async (event) => {
70
+ if (event.type === "assistant_message") {
71
+ await onTextBlock(event.text);
72
+ }
73
+ },
74
+ });
75
+ } catch (err) {
76
+ logError(
77
+ "discord",
78
+ `Component → agent forward failed: ${err instanceof Error ? err.message : err}`,
79
+ );
80
+ }
81
+ return true;
82
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `model:backend-select` — the /model backend switch.
3
+ *
4
+ * Rebinding drops this chat's session (it isn't portable across backends)
5
+ * but keeps each backend's remembered model pick, so switching back
6
+ * restores what was selected there before.
7
+ */
8
+
9
+ import { type StringSelectMenuInteraction, MessageFlags } from "discord.js";
10
+ import {
11
+ buildModelPickerView,
12
+ MODEL_NAV_PREFIX,
13
+ MODEL_PAGE_SIZE,
14
+ } from "../../model-picker.js";
15
+ import { setChatBackend } from "../../../../storage/chat-settings.js";
16
+ import {
17
+ getBackendIdForChat,
18
+ resolveChatBackend,
19
+ listAvailableBackends,
20
+ rebindChat,
21
+ } from "../../../../core/engine/backend-controller/index.js";
22
+ import { resetSession } from "../../../../storage/sessions.js";
23
+ import { clearHistory } from "../../../../storage/history.js";
24
+ import { resetPulseCheckpoint } from "../../../../core/background/pulse.js";
25
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
26
+ import { logError } from "../../../../util/log.js";
27
+ import { safeSlice } from "../../formatting.js";
28
+ import type { ComponentContext } from "./types.js";
29
+
30
+ export async function handleBackendSelect(
31
+ interaction: StringSelectMenuInteraction,
32
+ { config, gateway, chatId }: ComponentContext,
33
+ ): Promise<void> {
34
+ const backendId = interaction.values[0] ?? "";
35
+ const available = listAvailableBackends(config);
36
+ if (!available.some((b) => b.id === backendId)) {
37
+ await interaction.reply({
38
+ content: "Backend not available.",
39
+ flags: MessageFlags.Ephemeral,
40
+ });
41
+ return;
42
+ }
43
+
44
+ // Booting a cold backend takes seconds — Kilo and OpenCode spawn a server
45
+ // and sweep MCP registrations — and Discord drops an interaction that
46
+ // isn't acknowledged within three. Ack first, edit the panel after.
47
+ try {
48
+ await interaction.deferUpdate();
49
+ } catch {
50
+ /* already acknowledged */
51
+ }
52
+ const fail = async (text: string): Promise<void> => {
53
+ try {
54
+ await interaction.followUp({
55
+ content: text,
56
+ flags: MessageFlags.Ephemeral,
57
+ });
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ };
62
+
63
+ // Resolve the outgoing backend before rebinding — afterwards the chat
64
+ // already points at the new one and the old instance is unreachable.
65
+ const previousId = getBackendIdForChat(chatId);
66
+ const previous = resolveChatBackend(chatId, gateway?.backend);
67
+ const alreadyThere = previousId === backendId;
68
+ const result = alreadyThere
69
+ ? { ok: true as const }
70
+ : await rebindChat(chatId, backendId, config);
71
+ if (!result.ok) {
72
+ await fail(result.error?.slice(0, 200) ?? "Rebind failed.");
73
+ return;
74
+ }
75
+ setChatBackend(chatId, backendId);
76
+
77
+ // The pool accepts a backend whose SDK is installed, but the SDK may only
78
+ // be a client for a CLI that isn't (OpenCode and Kilo spawn `opencode` /
79
+ // `kilo`). That surfaces here, on the first real call. Reading the
80
+ // catalog is the cheapest way to find out, and it is needed for the panel
81
+ // anyway — so treat a throw as "this backend can't serve" and put the
82
+ // chat back where it was rather than stranding it somewhere unusable.
83
+ let next = resolveChatBackend(chatId, gateway?.backend);
84
+ let newModel: string | undefined;
85
+ let pres;
86
+ try {
87
+ newModel =
88
+ (await resolveActiveModelForChat(chatId, next, backendId, config))
89
+ .model ?? undefined;
90
+ if (next?.models?.getSettingsPresentation) {
91
+ pres = await next.models.getSettingsPresentation(newModel ?? "", {
92
+ callbackPrefix: "model:",
93
+ navCallbackPrefix: MODEL_NAV_PREFIX,
94
+ pageSize: MODEL_PAGE_SIZE,
95
+ });
96
+ }
97
+ } catch (err) {
98
+ const reason = err instanceof Error ? err.message : String(err);
99
+ logError("discord", `Backend switch to ${backendId} failed`, err);
100
+ if (!alreadyThere) {
101
+ await rebindChat(chatId, previousId, config).catch(() => undefined);
102
+ setChatBackend(chatId, previousId);
103
+ }
104
+ await fail(
105
+ `⚠️ \`${backendId}\` could not start — staying on \`${previousId}\`.\n` +
106
+ `\`\`\`\n${safeSlice(reason, 300)}\n\`\`\``,
107
+ );
108
+ return;
109
+ }
110
+
111
+ // Only now is the switch known to hold. Session state doesn't port across
112
+ // backends, so it goes — but each backend's remembered model pick stays.
113
+ // A re-pick of the backend already in use clears nothing: the retry after
114
+ // a timed-out interaction must not cost the session a second time.
115
+ if (!alreadyThere) {
116
+ resetSession(chatId);
117
+ clearHistory(chatId);
118
+ resetPulseCheckpoint(chatId);
119
+ previous?.sessions?.resetChat?.(chatId);
120
+ }
121
+
122
+ next = resolveChatBackend(chatId, gateway?.backend);
123
+ // Warming is fire-and-forget: it can take seconds on OpenCode/Kilo and
124
+ // the interaction still has a panel to redraw.
125
+ if (next && next !== previous) {
126
+ void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch(
127
+ () => undefined,
128
+ );
129
+ }
130
+
131
+ try {
132
+ await interaction.editReply(
133
+ pres
134
+ ? {
135
+ ...buildModelPickerView(
136
+ pres,
137
+ newModel ?? "_No model selected_",
138
+ backendId,
139
+ ),
140
+ }
141
+ : {
142
+ content: `**Backend:** \`${backendId}\` · model \`${newModel ?? "none"}\``,
143
+ components: [],
144
+ },
145
+ );
146
+ } catch {
147
+ /* ignore */
148
+ }
149
+ }