talon-agent 5.18.2 → 5.19.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 (190) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/stream.ts +2 -2
  8. package/src/backend/codex/auth.ts +1 -1
  9. package/src/backend/codex/handler/message.ts +11 -11
  10. package/src/backend/codex/init.ts +4 -9
  11. package/src/backend/codex/mcp-config.ts +1 -2
  12. package/src/backend/codex/oauth-incompat.ts +8 -4
  13. package/src/backend/codex/one-shot.ts +1 -1
  14. package/src/backend/openai-agents/builtins.ts +55 -27
  15. package/src/backend/openai-agents/factory.ts +3 -3
  16. package/src/backend/openai-agents/handler/message.ts +3 -5
  17. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  18. package/src/backend/remote-server/chat-turn.ts +6 -6
  19. package/src/backend/remote-server/events.ts +1 -5
  20. package/src/backend/remote-server/index.ts +0 -1
  21. package/src/backend/remote-server/messages.ts +3 -7
  22. package/src/backend/remote-server/one-shot.ts +1 -3
  23. package/src/backend/remote-server/session-helpers.ts +1 -4
  24. package/src/backend/remote-server/sse-stream.ts +8 -9
  25. package/src/backend/runtime/metrics.ts +7 -13
  26. package/src/backend/runtime/sleep.ts +1 -2
  27. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  28. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  29. package/src/bootstrap.ts +9 -1
  30. package/src/cli/doctor.ts +3 -0
  31. package/src/cli/index.ts +8 -10
  32. package/src/cli/logs.ts +148 -9
  33. package/src/cli/setup.ts +9 -11
  34. package/src/cli/status.ts +29 -0
  35. package/src/core/agent-runtime/README.md +5 -19
  36. package/src/core/agent-runtime/events.ts +3 -39
  37. package/src/core/agent-runtime/model-ref.ts +0 -8
  38. package/src/core/agents/registry.ts +49 -2
  39. package/src/core/auth/expiry-monitor.ts +9 -1
  40. package/src/core/auth/login-flow.ts +9 -1
  41. package/src/core/auth/status.ts +31 -3
  42. package/src/core/background/cron/scheduler.ts +25 -5
  43. package/src/core/background/dream/index.ts +29 -10
  44. package/src/core/background/failure-backoff.ts +30 -0
  45. package/src/core/background/heartbeat/agent.ts +2 -27
  46. package/src/core/background/heartbeat/index.ts +0 -2
  47. package/src/core/background/heartbeat/scheduler.ts +21 -13
  48. package/src/core/background/heartbeat/state.ts +10 -2
  49. package/src/core/background/isolated-agent.ts +6 -2
  50. package/src/core/background/pulse/pulse.ts +9 -0
  51. package/src/core/background/triggers/exit.ts +54 -0
  52. package/src/core/background/triggers/index.ts +1 -3
  53. package/src/core/background/triggers/resume.ts +2 -4
  54. package/src/core/backup/archive/tar.ts +14 -4
  55. package/src/core/backup/restore.ts +21 -13
  56. package/src/core/backup/scheduler.ts +28 -9
  57. package/src/core/backup/snapshot.ts +43 -10
  58. package/src/core/backup/store.ts +7 -19
  59. package/src/core/backup/targets.ts +69 -17
  60. package/src/core/config/index.ts +14 -0
  61. package/src/core/daemon/crash-marker.ts +141 -0
  62. package/src/core/daemon/crash.ts +9 -2
  63. package/src/core/daemon/handoff.ts +15 -0
  64. package/src/core/daemon/health-alerts.ts +297 -0
  65. package/src/core/daemon/log-reader.ts +289 -0
  66. package/src/core/doctor/index.ts +18 -2
  67. package/src/core/doctor/logs.ts +124 -0
  68. package/src/core/doctor/types.ts +1 -1
  69. package/src/core/engine/backend-controller/index.ts +1 -13
  70. package/src/core/engine/backend-router/router.ts +1 -1
  71. package/src/core/engine/dispatcher.ts +55 -2
  72. package/src/core/engine/fault-text.ts +40 -0
  73. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  74. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  75. package/src/core/engine/gateway-actions/history.ts +2 -4
  76. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  77. package/src/core/engine/gateway.ts +60 -1
  78. package/src/core/engine/turn-health.ts +222 -0
  79. package/src/core/errors.ts +2 -2
  80. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  81. package/src/core/frontend-runtime/alerts.ts +130 -0
  82. package/src/core/mcp-hub/children.ts +78 -29
  83. package/src/core/mcp-hub/index.ts +21 -18
  84. package/src/core/mcp-hub/proxy-server.ts +8 -4
  85. package/src/core/mcp-hub/talon-server.ts +5 -12
  86. package/src/core/mesh/credentials/store.ts +16 -1
  87. package/src/core/mesh/devices/service.ts +26 -15
  88. package/src/core/mesh/devices/teleport.ts +14 -2
  89. package/src/core/mesh/links/node-binaries.ts +13 -6
  90. package/src/core/mesh/persist.ts +22 -10
  91. package/src/core/mesh/transfers/device-files.ts +5 -23
  92. package/src/core/mesh/transfers/transfers.ts +16 -3
  93. package/src/core/models/active-model.ts +2 -55
  94. package/src/core/plugin/actions.ts +19 -20
  95. package/src/core/plugin/builtins.ts +80 -90
  96. package/src/core/plugin/index.ts +1 -4
  97. package/src/core/plugin/loader.ts +25 -33
  98. package/src/core/plugin/mcp.ts +3 -5
  99. package/src/core/plugin/registry.ts +19 -35
  100. package/src/core/plugin/types.ts +2 -5
  101. package/src/core/prompt/assemble.ts +15 -3
  102. package/src/core/scripts/lua.ts +6 -2
  103. package/src/core/tasks/table.ts +8 -2
  104. package/src/core/tools/bridge.ts +2 -4
  105. package/src/core/tools/chat/cross-send.ts +1 -1
  106. package/src/core/tools/chat/messaging.ts +1 -1
  107. package/src/core/tools/index.ts +2 -2
  108. package/src/core/tools/mcp-env.ts +2 -59
  109. package/src/core/tools/ops/agents.ts +22 -1
  110. package/src/core/tools/schemas.ts +4 -9
  111. package/src/core/vfs/fusefs.ts +0 -5
  112. package/src/core/vfs/index.ts +9 -2
  113. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  114. package/src/core/vfs/mounts/proc.ts +17 -1
  115. package/src/core/vfs/workspace.ts +7 -3
  116. package/src/core/weaver/shuttle.ts +8 -1
  117. package/src/core/weaver/turn-log.ts +320 -0
  118. package/src/core/weaver/weaver.ts +48 -5
  119. package/src/frontend/discord/actions/index.ts +8 -1
  120. package/src/frontend/discord/diagnostics.ts +82 -6
  121. package/src/frontend/discord/handlers/index.ts +0 -2
  122. package/src/frontend/discord/middleware.ts +7 -13
  123. package/src/frontend/discord/runtime.ts +1 -3
  124. package/src/frontend/health/delivery.ts +115 -0
  125. package/src/frontend/health/outage.ts +116 -0
  126. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  127. package/src/frontend/native/bridge/server.ts +106 -21
  128. package/src/frontend/native/index.ts +1 -1
  129. package/src/frontend/native/media/media.ts +5 -1
  130. package/src/frontend/native/runtime.ts +12 -7
  131. package/src/frontend/native/surface/handlers.ts +1 -1
  132. package/src/frontend/native/surface/memory.ts +1 -1
  133. package/src/frontend/native/surface/models.ts +3 -3
  134. package/src/frontend/native/surface/settings.ts +20 -8
  135. package/src/frontend/native/turn/context.ts +6 -8
  136. package/src/frontend/native/turn/turn-meta.ts +2 -5
  137. package/src/frontend/native/turn/turn.ts +8 -10
  138. package/src/frontend/presentation/format.ts +2 -4
  139. package/src/frontend/presentation/session-status.ts +2 -6
  140. package/src/frontend/teams/actions.ts +8 -1
  141. package/src/frontend/teams/graph.ts +0 -1
  142. package/src/frontend/teams/index.ts +1 -4
  143. package/src/frontend/teams/poll.ts +40 -2
  144. package/src/frontend/teams/runtime.ts +14 -5
  145. package/src/frontend/telegram/actions/index.ts +4 -1
  146. package/src/frontend/telegram/actions/send.ts +8 -0
  147. package/src/frontend/telegram/handlers/context.ts +13 -2
  148. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  149. package/src/frontend/telegram/handlers/index.ts +0 -2
  150. package/src/frontend/telegram/index.ts +35 -9
  151. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  152. package/src/frontend/telegram/userbot.ts +100 -36
  153. package/src/frontend/terminal/builtins/session.ts +2 -2
  154. package/src/frontend/terminal/index.ts +1 -3
  155. package/src/frontend/terminal/renderer.ts +2 -18
  156. package/src/frontend/whatsapp/actions/index.ts +12 -1
  157. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  158. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  159. package/src/frontend/whatsapp/connection/health.ts +89 -0
  160. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  161. package/src/frontend/whatsapp/runtime.ts +11 -5
  162. package/src/native/blake3.ts +28 -2
  163. package/src/native/fusefs.ts +23 -5
  164. package/src/native/registry.ts +1 -1
  165. package/src/native/warden.ts +33 -5
  166. package/src/plugins/github/index.ts +0 -1
  167. package/src/plugins/mempalace/index.ts +9 -3
  168. package/src/plugins/playwright/index.ts +2 -4
  169. package/src/plugins/playwright/provision.ts +12 -4
  170. package/src/storage/chat-settings.ts +6 -1
  171. package/src/storage/cron.ts +29 -4
  172. package/src/storage/daily-log.ts +43 -47
  173. package/src/storage/db.ts +61 -33
  174. package/src/storage/history.ts +6 -1
  175. package/src/storage/journal.ts +9 -2
  176. package/src/storage/kv.ts +19 -6
  177. package/src/storage/media-index.ts +28 -6
  178. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  179. package/src/storage/repositories/sessions-repo.ts +10 -3
  180. package/src/storage/scripts.ts +24 -13
  181. package/src/storage/sessions.ts +11 -2
  182. package/src/storage/skills.ts +21 -2
  183. package/src/storage/stickers.ts +17 -3
  184. package/src/storage/triggers.ts +8 -3
  185. package/src/storage/turn-meta.ts +25 -7
  186. package/src/util/log.ts +189 -6
  187. package/src/util/logging/turn-scope.ts +85 -0
  188. package/src/util/time.ts +3 -3
  189. package/src/util/watchdog.ts +30 -0
  190. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -55,6 +55,8 @@ type Transfer = {
55
55
  resolve: (bytes: number) => void;
56
56
  reject: (err: Error) => void;
57
57
  };
58
+ /** pull only — tears down the upload once its HTTP leg is streaming. */
59
+ abort?: AbortController;
58
60
  };
59
61
 
60
62
  export class TransferStore {
@@ -104,12 +106,19 @@ export class TransferStore {
104
106
  return { token };
105
107
  }
106
108
 
107
- /** Drop a token (transfer arrangement failed before the HTTP leg). */
109
+ /**
110
+ * Drop a token — the arrangement failed, or the daemon gave up waiting.
111
+ * An upload already streaming is aborted too: otherwise a peer that
112
+ * stalls mid-body holds the request (and its temp file) open until the
113
+ * bridge's whole-request deadline, and one that finishes late renames a
114
+ * file into place after the caller was told the pull failed.
115
+ */
108
116
  cancel(token: string): void {
109
117
  const t = this.transfers.get(token);
110
118
  if (t?.uploadDone && !t.consumed) {
111
119
  t.uploadDone.reject(new Error("transfer cancelled"));
112
120
  }
121
+ t?.abort?.abort(new Error("transfer cancelled"));
113
122
  this.transfers.delete(token);
114
123
  }
115
124
 
@@ -129,11 +138,16 @@ export class TransferStore {
129
138
  if (!t)
130
139
  return { ok: false, error: "Unknown or already-used transfer token." };
131
140
  const tmp = `${t.localPath}.part-${randomBytes(4).toString("hex")}`;
141
+ const abort = new AbortController();
142
+ t.abort = abort;
132
143
  try {
133
144
  await mkdir(dirname(t.localPath), { recursive: true });
134
145
  let bytes = 0;
135
146
  body.on("data", (d: Buffer) => (bytes += d.length));
136
- await pipeline(body, createWriteStream(tmp, { mode: 0o600 }));
147
+ await pipeline(body, createWriteStream(tmp, { mode: 0o600 }), {
148
+ signal: abort.signal,
149
+ });
150
+ abort.signal.throwIfAborted();
137
151
  await rename(tmp, t.localPath);
138
152
  this.transfers.delete(token);
139
153
  t.uploadDone?.resolve(bytes);
@@ -162,7 +176,6 @@ export class TransferStore {
162
176
  if (!s.isFile()) throw new Error("not a file");
163
177
  return { path: t.localPath, size: s.size };
164
178
  } catch {
165
- this.transfers.delete(token);
166
179
  return null;
167
180
  } finally {
168
181
  // Single-use either way; the device retries by re-arranging.
@@ -60,10 +60,7 @@
60
60
  * have their stored override returned verbatim — no way to validate.
61
61
  */
62
62
 
63
- import {
64
- getChatModelForBackend,
65
- getChatSettings,
66
- } from "../../storage/chat-settings.js";
63
+ import { getChatModelForBackend } from "../../storage/chat-settings.js";
67
64
  import type { Backend } from "../agent-runtime/capabilities.js";
68
65
  import {
69
66
  isBackendId,
@@ -82,7 +79,7 @@ import { logWarn } from "../../util/log.js";
82
79
  * (default)" vs "Model: X (override)" vs "No model selected" without
83
80
  * second-guessing the chain.
84
81
  */
85
- export type ActiveModelSource =
82
+ type ActiveModelSource =
86
83
  | "override-valid"
87
84
  | "override-invalid-fallback"
88
85
  | "backend-canonical"
@@ -311,25 +308,6 @@ async function safeBackendDefault(backend: Backend): Promise<string | null> {
311
308
  }
312
309
  }
313
310
 
314
- /**
315
- * Convenience: same as `resolveActiveModelForChat` but returns just
316
- * the model id (or `null`). Use when the source tag isn't needed.
317
- */
318
- export async function getActiveModelForChat(
319
- chatId: string,
320
- backend: Backend | null,
321
- backendId: string | null,
322
- config: TalonConfig,
323
- ): Promise<string | null> {
324
- const { model } = await resolveActiveModelForChat(
325
- chatId,
326
- backend,
327
- backendId,
328
- config,
329
- );
330
- return model;
331
- }
332
-
333
311
  // ── ref enrichment ──────────────────────────────────────────────────────────
334
312
 
335
313
  /**
@@ -412,34 +390,3 @@ function mapCacheSupport(backend: Backend | null): CacheSupport {
412
390
  return "none";
413
391
  }
414
392
  }
415
-
416
- /**
417
- * Human-readable label for the source — used in toast wording so the
418
- * user sees *why* the resolved model is what it is. Keeps the wording
419
- * centralised so the UI stays consistent across frontends.
420
- */
421
- export function describeActiveModelSource(source: ActiveModelSource): string {
422
- switch (source) {
423
- case "override-valid":
424
- return "your pick";
425
- case "override-invalid-fallback":
426
- return "previous pick invalid — falling back to default";
427
- case "backend-canonical":
428
- return "backend default";
429
- case "config-backend-defaults":
430
- return "configured default";
431
- case "config-legacy-global":
432
- return "global default";
433
- case "none":
434
- return "no model selected";
435
- }
436
- }
437
-
438
- /** Read-only helper to surface the `modelByBackend` map for a chat
439
- * (e.g. for /status rendering). Returns a shallow copy. */
440
- export function getModelByBackendSnapshot(
441
- chatId: string,
442
- ): Record<string, string> {
443
- const settings = getChatSettings(chatId);
444
- return { ...settings.modelByBackend };
445
- }
@@ -14,6 +14,22 @@
14
14
  import { logError } from "../../util/log.js";
15
15
  import type { ActionResult } from "../types.js";
16
16
  import { registry } from "./registry.js";
17
+ import type { TalonPlugin } from "./types.js";
18
+
19
+ /** Run a plugin that has `handleAction`; a throw becomes an error result. */
20
+ async function runAction(
21
+ plugin: TalonPlugin,
22
+ body: Record<string, unknown>,
23
+ chatId: string,
24
+ ): Promise<ActionResult | null> {
25
+ try {
26
+ return await plugin.handleAction!(body, chatId);
27
+ } catch (err) {
28
+ const detail = err instanceof Error ? err.message : String(err);
29
+ logError("plugin", `${plugin.name} action error: ${detail}`);
30
+ return { ok: false, error: `Plugin ${plugin.name}: ${detail}` };
31
+ }
32
+ }
17
33
 
18
34
  export async function handlePluginAction(
19
35
  body: Record<string, unknown>,
@@ -21,19 +37,8 @@ export async function handlePluginAction(
21
37
  ): Promise<ActionResult | null> {
22
38
  for (const { plugin } of registry.all) {
23
39
  if (!plugin.handleAction) continue;
24
- try {
25
- const result = await plugin.handleAction(body, chatId);
26
- if (result) return result;
27
- } catch (err) {
28
- logError(
29
- "plugin",
30
- `${plugin.name} action error: ${err instanceof Error ? err.message : err}`,
31
- );
32
- return {
33
- ok: false,
34
- error: `Plugin ${plugin.name}: ${err instanceof Error ? err.message : err}`,
35
- };
36
- }
40
+ const result = await runAction(plugin, body, chatId);
41
+ if (result) return result;
37
42
  }
38
43
  return null;
39
44
  }
@@ -56,11 +61,5 @@ export async function handlePluginActionIn(
56
61
  ): Promise<ActionResult | null> {
57
62
  const entry = registry.all.find(({ plugin }) => plugin.name === name);
58
63
  if (!entry?.plugin.handleAction) return null;
59
- try {
60
- return await entry.plugin.handleAction(body, chatId);
61
- } catch (err) {
62
- const detail = err instanceof Error ? err.message : String(err);
63
- logError("plugin", `${name} action error: ${detail}`);
64
- return { ok: false, error: `Plugin ${name}: ${detail}` };
65
- }
64
+ return runAction(entry.plugin, body, chatId);
66
65
  }
@@ -4,7 +4,10 @@
4
4
  */
5
5
 
6
6
  import { log, logError, logWarn } from "../../util/log.js";
7
+ import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
8
+ import { faultText } from "../engine/fault-text.js";
7
9
  import type { TalonConfig } from "../config/index.js";
10
+ import type { TalonPlugin } from "./types.js";
8
11
  import { registry, reloadState } from "./registry.js";
9
12
  import type { ProvisionOutcome } from "./provision.js";
10
13
  import { NATIVE_RUNTIMES, type NativePluginId } from "./native-runtimes.js";
@@ -37,6 +40,7 @@ function reportProvision(
37
40
  if (outcome.status === "failed" && outcome.error) {
38
41
  logError(pluginName, `provision failed: ${outcome.error}`);
39
42
  }
43
+ alertOnProvision(pluginName, outcome.status, outcome.error);
40
44
  const background = outcome.background;
41
45
  if (background) {
42
46
  // Tracked so the post-update report waits for it to settle (its
@@ -56,6 +60,27 @@ function reportProvision(
56
60
  }
57
61
  }
58
62
 
63
+ /**
64
+ * A native runtime that could not be provisioned leaves its plugin down
65
+ * until a human looks (disk, network, a broken toolchain) — alert on the
66
+ * failed pass, clear once a later pass leaves it usable.
67
+ */
68
+ function alertOnProvision(
69
+ pluginName: NativePluginId,
70
+ status: ProvisionOutcome["status"] | "threw",
71
+ error: unknown,
72
+ ): void {
73
+ const key = `provision.${pluginName}`;
74
+ if (status === "failed" || status === "threw") {
75
+ raiseAlert(
76
+ key,
77
+ `Installing the ${pluginName} runtime failed: ${faultText(error ?? "no detail")}. The ${pluginName} plugin will not work until it is fixed (see \`talon doctor\`).`,
78
+ );
79
+ } else if (status === "ready" || status === "degraded") {
80
+ resolveAlert(key, `The ${pluginName} runtime is installed and usable.`);
81
+ }
82
+ }
83
+
59
84
  /**
60
85
  * Provision every enabled native runtime (see native-runtimes.ts) and
61
86
  * return the outcomes so plugin construction can read what it got
@@ -77,136 +102,109 @@ async function provisionNativeRuntimes(
77
102
  runtime.id,
78
103
  `provision: ${err instanceof Error ? err.message : err}`,
79
104
  );
105
+ alertOnProvision(runtime.id, "threw", err);
80
106
  }
81
107
  }
82
108
  return outcomes;
83
109
  }
84
110
 
111
+ /**
112
+ * Register one built-in plugin from its config section and run its init.
113
+ * A plugin that fails to build or init is logged, never fatal.
114
+ */
115
+ async function loadBuiltin(
116
+ label: string,
117
+ section: object,
118
+ initTimeoutMs: number,
119
+ build: () => Promise<TalonPlugin>,
120
+ ): Promise<void> {
121
+ try {
122
+ const loaded = registerPlugin(
123
+ await build(),
124
+ section as Record<string, unknown>,
125
+ );
126
+ if (loaded) {
127
+ await initPluginWithTimeout(
128
+ loaded.plugin,
129
+ loaded.config,
130
+ initTimeoutMs,
131
+ `${label} init`,
132
+ `${label} init`,
133
+ );
134
+ }
135
+ } catch (err) {
136
+ logError(
137
+ "plugin",
138
+ `${label} init: ${err instanceof Error ? err.message : err}`,
139
+ );
140
+ raiseAlert(
141
+ `plugin.${label.toLowerCase()}`,
142
+ `Built-in plugin ${label} failed to load: ${faultText(err)}. Its tools are unavailable until the next reload or restart.`,
143
+ );
144
+ }
145
+ }
146
+
85
147
  /**
86
148
  * Load built-in plugins (GitHub, MemPalace, mem0, Playwright) based on config flags.
87
- * Shared by both bootstrap and hot-reload to avoid duplication.
149
+ * Shared by both bootstrap and hot-reload.
88
150
  */
89
151
  export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
90
152
  const provisioned = await provisionNativeRuntimes(config);
91
153
 
92
154
  const github = config.github;
93
155
  if (github?.enabled) {
94
- try {
156
+ await loadBuiltin("GitHub", github, 15_000, async () => {
95
157
  const { createGitHubPlugin } =
96
158
  await import("../../plugins/github/index.js");
97
- const gh = createGitHubPlugin({
159
+ return createGitHubPlugin({
98
160
  token: github.token,
99
161
  imageTag: github.imageTag,
100
162
  });
101
- const ghConfig = github as unknown as Record<string, unknown>;
102
- const loaded = registerPlugin(gh, ghConfig);
103
- if (loaded) {
104
- await initPluginWithTimeout(
105
- loaded.plugin,
106
- loaded.config,
107
- 15_000,
108
- "GitHub init",
109
- "GitHub init",
110
- );
111
- }
112
- } catch (err) {
113
- logError(
114
- "plugin",
115
- `GitHub init: ${err instanceof Error ? err.message : err}`,
116
- );
117
- }
163
+ });
118
164
  }
119
165
 
120
166
  const mempalace = config.mempalace;
121
167
  if (mempalace?.enabled) {
122
- try {
168
+ await loadBuiltin("MemPalace", mempalace, 30_000, async () => {
123
169
  const { createMempalacePlugin } =
124
170
  await import("../../plugins/mempalace/index.js");
125
171
  const { resolveMempalacePaths } =
126
172
  await import("../../plugins/mempalace/provision.js");
127
173
  const { pythonPath, palacePath } = resolveMempalacePaths(mempalace);
128
- const mp = createMempalacePlugin({
174
+ return createMempalacePlugin({
129
175
  pythonPath,
130
176
  palacePath,
131
177
  entityLanguages: mempalace.entityLanguages,
132
178
  verbose: mempalace.verbose,
133
179
  installedVersion: provisioned.get("mempalace")?.version,
134
180
  });
135
- const mpConfig = mempalace as unknown as Record<string, unknown>;
136
- const loaded = registerPlugin(mp, mpConfig);
137
- if (loaded) {
138
- await initPluginWithTimeout(
139
- loaded.plugin,
140
- loaded.config,
141
- 30_000,
142
- "MemPalace init",
143
- "MemPalace init",
144
- );
145
- }
146
- } catch (err) {
147
- logError(
148
- "plugin",
149
- `MemPalace init: ${err instanceof Error ? err.message : err}`,
150
- );
151
- }
181
+ });
152
182
  }
153
183
 
154
184
  const mem0 = config.mem0;
155
185
  if (mem0?.enabled) {
156
- try {
186
+ await loadBuiltin("mem0", mem0, 15_000, async () => {
157
187
  const { createMem0Plugin } = await import("../../plugins/mem0/index.js");
158
- const m0 = createMem0Plugin({
188
+ return createMem0Plugin({
159
189
  apiKey: mem0.apiKey,
160
190
  host: mem0.host,
161
191
  userId: mem0.userId,
162
192
  });
163
- const m0Config = mem0 as unknown as Record<string, unknown>;
164
- const loaded = registerPlugin(m0, m0Config);
165
- if (loaded) {
166
- await initPluginWithTimeout(
167
- loaded.plugin,
168
- loaded.config,
169
- 15_000,
170
- "mem0 init",
171
- "mem0 init",
172
- );
173
- }
174
- } catch (err) {
175
- logError(
176
- "plugin",
177
- `mem0 init: ${err instanceof Error ? err.message : err}`,
178
- );
179
- }
193
+ });
180
194
  }
181
195
 
182
196
  const playwright = config.playwright;
183
197
  if (playwright?.enabled) {
184
- try {
198
+ await loadBuiltin("Playwright", playwright, 15_000, async () => {
185
199
  const { createPlaywrightPlugin } =
186
200
  await import("../../plugins/playwright/index.js");
187
- const pwConfig = playwright as unknown as Record<string, unknown>;
188
- const pw = createPlaywrightPlugin({
201
+ return createPlaywrightPlugin({
189
202
  browser: playwright.browser,
190
203
  headless: playwright.headless,
191
204
  endpoint: playwright.endpoint,
192
205
  endpointFile: playwright.endpointFile,
193
206
  });
194
- const loaded = registerPlugin(pw, pwConfig);
195
- if (loaded) {
196
- await initPluginWithTimeout(
197
- loaded.plugin,
198
- loaded.config,
199
- 15_000,
200
- "Playwright init",
201
- "Playwright init",
202
- );
203
- }
204
- } catch (err) {
205
- logError(
206
- "plugin",
207
- `Playwright init: ${err instanceof Error ? err.message : err}`,
208
- );
209
- }
207
+ });
210
208
  }
211
209
  }
212
210
 
@@ -217,9 +215,9 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
217
215
  *
218
216
  * Throws on config parse/validation failure so the gateway can report an error.
219
217
  *
220
- * Does NOT restart the main process, Claude session, or bot connection. Active
221
- * conversations continue uninterrupted — new MCP servers spawn automatically
222
- * on the next tool call.
218
+ * Does NOT restart the main process, backend session, or bot connection.
219
+ * Active conversations continue uninterrupted — new MCP servers spawn
220
+ * automatically on the next tool call.
223
221
  */
224
222
  export async function reloadPlugins(
225
223
  activeFrontends?: string[],
@@ -229,30 +227,22 @@ export async function reloadPlugins(
229
227
  const { loadConfig, getFrontends } = await import("../config/index.js");
230
228
  const config = loadConfig();
231
229
 
232
- // Derive frontends from config if not explicitly provided
233
230
  const frontends = activeFrontends ?? getFrontends(config);
234
231
 
235
- // Bump reload timestamp so every MCP subprocess env differs from the previous
236
- // load — the Claude SDK will see a changed env and spawn fresh subprocesses,
237
- // picking up any source-file changes without a full Talon restart.
232
+ // New cache-bust key: the re-import below must load edited plugin source.
238
233
  reloadState.lastReloadAt = new Date().toISOString();
239
234
 
240
- // Config is valid — safe to destroy current plugins now
241
235
  log("plugin", "Hot-reload: destroying current plugins...");
242
236
  await registry.destroyAndClear();
243
237
 
244
- // Re-load external plugins
245
238
  if (config.plugins.length > 0) {
246
239
  await loadPlugins(config.plugins, frontends);
247
240
  }
248
-
249
- // Re-load built-in plugins using shared helper
250
241
  await loadBuiltinPlugins(config);
251
242
 
252
243
  // Retire the hub's MCP children: the next tool call (any chat) spawns
253
244
  // fresh processes from the reloaded registry, while in-flight calls
254
- // drain on the old ones (stdio-era behaviour was per-turn respawn via
255
- // the TALON_RELOAD_AT env bump; the hub owns lifecycles directly).
245
+ // drain on the old ones.
256
246
  const { reloadHubChildren } = await import("../mcp-hub/index.js");
257
247
  reloadHubChildren();
258
248
 
@@ -7,10 +7,7 @@
7
7
  * - `loader` — load/validate/register plugins, init hooks, query helpers
8
8
  * - `builtins` — built-in plugin loading + hot-reload
9
9
  * - `actions` — gateway-action routing through plugins
10
- * - `mcp` — MCP server config assembly for the Claude Agent SDK
11
- *
12
- * Re-exports the same public surface the old single-file module exposed,
13
- * including the `_deps` test seam.
10
+ * - `mcp` — MCP server spec assembly for the hub
14
11
  */
15
12
 
16
13
  export { _deps } from "./registry.js";
@@ -16,6 +16,8 @@ import type {
16
16
  import { isMcpPlugin } from "./types.js";
17
17
  import { registry, _deps } from "./registry.js";
18
18
  import { GATEWAY_TOKEN_ENV } from "../engine/gateway-auth.js";
19
+ import { faultText } from "../engine/fault-text.js";
20
+ import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
19
21
 
20
22
  /**
21
23
  * Candidate entry point paths, checked in order. Exported for
@@ -48,7 +50,6 @@ export async function loadPlugins(
48
50
  );
49
51
  continue;
50
52
  }
51
- // Standalone MCP servers are registered for getPluginMcpServers, not loaded as modules
52
53
  if (isMcpPlugin(entry)) {
53
54
  if (registry.registerMcpEntry(entry)) {
54
55
  log("plugin", `Registered standalone MCP server: ${entry.name}`);
@@ -108,6 +109,7 @@ export async function initPluginWithTimeout(
108
109
 
109
110
  let timer: ReturnType<typeof setTimeout> | undefined;
110
111
 
112
+ const alertKey = `plugin.${plugin.name}`;
111
113
  try {
112
114
  await Promise.race([
113
115
  Promise.resolve(plugin.init(config)),
@@ -120,11 +122,18 @@ export async function initPluginWithTimeout(
120
122
  timer.unref?.();
121
123
  }),
122
124
  ]);
125
+ resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
123
126
  } catch (err) {
124
127
  logError(
125
128
  "plugin",
126
129
  `${errorPrefix}: ${err instanceof Error ? err.message : err}`,
127
130
  );
131
+ // Init runs once per boot or reload: a plugin that failed it stays
132
+ // half-loaded until someone fixes it, so this needs no threshold.
133
+ raiseAlert(
134
+ alertKey,
135
+ `Plugin "${plugin.name}" failed to initialise: ${faultText(err)}. Its tools may not work until the next reload or restart.`,
136
+ );
128
137
  } finally {
129
138
  if (timer) clearTimeout(timer);
130
139
  }
@@ -136,7 +145,6 @@ async function loadSinglePlugin(
136
145
  ): Promise<void> {
137
146
  const pluginDir = resolve(entry.path);
138
147
 
139
- // Resolve entry point
140
148
  const entryPoint = resolveEntryPoint(pluginDir);
141
149
  if (!entryPoint) {
142
150
  logError(
@@ -146,7 +154,6 @@ async function loadSinglePlugin(
146
154
  return;
147
155
  }
148
156
 
149
- // Import and extract plugin module
150
157
  const mod = await _deps.importModule(entryPoint);
151
158
  const plugin = extractPlugin(mod);
152
159
  if (!plugin) {
@@ -157,7 +164,6 @@ async function loadSinglePlugin(
157
164
  return;
158
165
  }
159
166
 
160
- // Check frontend whitelist — skip if plugin specifies frontends and none match
161
167
  if (plugin.frontends && plugin.frontends.length > 0 && activeFrontends) {
162
168
  const match = activeFrontends.some((fe) => plugin.frontends!.includes(fe));
163
169
  if (!match) {
@@ -173,7 +179,6 @@ async function loadSinglePlugin(
173
179
  const loaded = registerPluginInstance(plugin, config, pluginDir);
174
180
  if (!loaded) return;
175
181
 
176
- // Run init hook
177
182
  await initPluginWithTimeout(
178
183
  loaded.plugin,
179
184
  loaded.config,
@@ -182,11 +187,14 @@ async function loadSinglePlugin(
182
187
  `Plugin "${loaded.plugin.name}" init failed`,
183
188
  );
184
189
 
185
- const version = loaded.plugin.version ? ` v${loaded.plugin.version}` : "";
186
- const desc = loaded.plugin.description
187
- ? ` — ${loaded.plugin.description}`
188
- : "";
189
- log("plugin", `Loaded: ${loaded.plugin.name}${version}${desc}`);
190
+ log("plugin", `Loaded: ${describePlugin(loaded.plugin)}`);
191
+ }
192
+
193
+ /** `name v1.0 — description`, for load/register log lines. */
194
+ function describePlugin(plugin: TalonPlugin): string {
195
+ const version = plugin.version ? ` v${plugin.version}` : "";
196
+ const desc = plugin.description ? ` — ${plugin.description}` : "";
197
+ return `${plugin.name}${version}${desc}`;
190
198
  }
191
199
 
192
200
  function resolveEntryPoint(pluginDir: string): string | null {
@@ -198,13 +206,11 @@ function resolveEntryPoint(pluginDir: string): string | null {
198
206
  }
199
207
 
200
208
  function extractPlugin(mod: Record<string, unknown>): TalonPlugin | null {
201
- // Support: export default { ... } or module.exports = { ... }
209
+ // `export default { … }` or `module.exports = { … }`
202
210
  const candidate = mod.default ?? mod;
203
211
  if (!candidate || typeof candidate !== "object") return null;
204
212
  const plugin = candidate as Record<string, unknown>;
205
- // Validate required field types
206
213
  if (typeof plugin.name !== "string" || !plugin.name) return null;
207
- // Validate optional fields are the right types if present
208
214
  if (
209
215
  plugin.handleAction !== undefined &&
210
216
  typeof plugin.handleAction !== "function"
@@ -241,33 +247,27 @@ function extractPlugin(mod: Record<string, unknown>): TalonPlugin | null {
241
247
 
242
248
  // ── Public API ─────────────────────────────────────────────────────────────
243
249
 
244
- /** Get all loaded plugins. */
245
250
  export function getLoadedPlugins(): readonly LoadedPlugin[] {
246
251
  return registry.all;
247
252
  }
248
253
 
249
- /** Get a plugin by name. */
250
254
  export function getPlugin(name: string): LoadedPlugin | undefined {
251
255
  return registry.getByName(name);
252
256
  }
253
257
 
254
- /** Number of loaded plugins. */
255
258
  export function getPluginCount(): number {
256
259
  return registry.count;
257
260
  }
258
261
 
259
- /** Destroy all plugins (called during shutdown). */
262
+ /** Shutdown path. */
260
263
  export async function destroyPlugins(): Promise<void> {
261
264
  await registry.destroyAll();
262
265
  }
263
266
 
264
267
  /**
265
- * Register a built-in plugin directly (bypasses filesystem loader).
266
- * Used for tightly-integrated plugins like mempalace that are configured via
267
- * dedicated config fields rather than the plugins[] array.
268
- *
269
- * NOTE: This only registers the plugin — it does NOT call `init()`. The caller
270
- * is responsible for calling `plugin.init()` separately after registration.
268
+ * Register a built-in plugin (configured by its own config section, not a
269
+ * `plugins[]` entry) without the filesystem loader. Does NOT call `init()`;
270
+ * the caller does.
271
271
  */
272
272
  export function registerPlugin(
273
273
  plugin: TalonPlugin,
@@ -275,19 +275,11 @@ export function registerPlugin(
275
275
  ): LoadedPlugin | null {
276
276
  const loaded = registerPluginInstance(plugin, config, "(built-in)");
277
277
  if (!loaded) return null;
278
-
279
- const version = loaded.plugin.version ? ` v${loaded.plugin.version}` : "";
280
- const desc = loaded.plugin.description
281
- ? ` — ${loaded.plugin.description}`
282
- : "";
283
- log("plugin", `Registered built-in: ${loaded.plugin.name}${version}${desc}`);
278
+ log("plugin", `Registered built-in: ${describePlugin(loaded.plugin)}`);
284
279
  return loaded;
285
280
  }
286
281
 
287
- /**
288
- * Collect system prompt additions from all plugins.
289
- * Called during config/prompt assembly.
290
- */
282
+ /** Every plugin's trimmed system-prompt addition, load order. */
291
283
  export function getPluginPromptAdditions(): string[] {
292
284
  const additions: string[] = [];
293
285
  for (const { plugin, config } of registry.all) {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * MCP server config — build the Claude Agent SDK MCP server map for plugins
3
- * that expose an MCP server (via `mcpServer` command/args or `mcpServerPath`),
4
- * plus the standalone MCP entries from config.
2
+ * MCP server specs — the stdio command for every plugin that exposes an MCP
3
+ * server (via `mcpServer` command/args or `mcpServerPath`), plus the
4
+ * standalone MCP entries from config. The hub spawns its children from these.
5
5
  */
6
6
 
7
7
  import { resolve } from "node:path";
@@ -54,7 +54,6 @@ export function getPluginMcpServers(
54
54
  );
55
55
 
56
56
  for (const { plugin, envVars } of registry.all) {
57
- // Skip plugins not in the allow-list when filtering
58
57
  if (only !== undefined && !only.includes(plugin.name)) continue;
59
58
 
60
59
  // Let the plugin re-materialize on-disk state the child will read. Runs
@@ -95,7 +94,6 @@ export function getPluginMcpServers(
95
94
  }
96
95
  }
97
96
 
98
- // Include standalone MCP server entries from config
99
97
  for (const entry of registry.mcpEntries) {
100
98
  if (only !== undefined && !only.includes(entry.name)) continue;
101
99
  servers[`${entry.name}-tools`] = wrapMcpServer({