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
@@ -27,42 +27,29 @@ class PluginRegistry {
27
27
  return this.plugins.length;
28
28
  }
29
29
 
30
- private getRegistrationSource(name: string): string | undefined {
31
- const existingPlugin = this.plugins.find(
32
- (entry) => entry.plugin.name === name,
30
+ /** True when `name` is free; warns and returns false on a duplicate. */
31
+ private isNameFree(name: string): boolean {
32
+ const source =
33
+ this.plugins.find((entry) => entry.plugin.name === name)?.path ??
34
+ (this.standaloneMcpServers.some((entry) => entry.name === name)
35
+ ? "standalone MCP entry"
36
+ : undefined);
37
+ if (!source) return true;
38
+ logWarn(
39
+ "plugin",
40
+ `Duplicate plugin/MCP name "${name}" — skipping (already registered from ${source})`,
33
41
  );
34
- if (existingPlugin) return existingPlugin.path;
35
-
36
- const existingMcpEntry = this.standaloneMcpServers.find(
37
- (entry) => entry.name === name,
38
- );
39
- if (existingMcpEntry) return "standalone MCP entry";
40
-
41
- return undefined;
42
+ return false;
42
43
  }
43
44
 
44
45
  register(loaded: LoadedPlugin): boolean {
45
- const existingSource = this.getRegistrationSource(loaded.plugin.name);
46
- if (existingSource) {
47
- logWarn(
48
- "plugin",
49
- `Duplicate plugin/MCP name "${loaded.plugin.name}" — skipping (already registered from ${existingSource})`,
50
- );
51
- return false;
52
- }
46
+ if (!this.isNameFree(loaded.plugin.name)) return false;
53
47
  this.plugins.push(loaded);
54
48
  return true;
55
49
  }
56
50
 
57
51
  registerMcpEntry(entry: PluginMcpEntry): boolean {
58
- const existingSource = this.getRegistrationSource(entry.name);
59
- if (existingSource) {
60
- logWarn(
61
- "plugin",
62
- `Duplicate plugin/MCP name "${entry.name}" — skipping (already registered from ${existingSource})`,
63
- );
64
- return false;
65
- }
52
+ if (!this.isNameFree(entry.name)) return false;
66
53
  this.standaloneMcpServers.push(entry);
67
54
  return true;
68
55
  }
@@ -98,14 +85,12 @@ class PluginRegistry {
98
85
  }
99
86
  }
100
87
 
101
- // Module-level singleton
102
88
  export const registry = new PluginRegistry();
103
89
 
104
90
  /**
105
- * Tracks the last reload timestamp. Injected into every MCP subprocess env as
106
- * TALON_RELOAD_AT so the Claude SDK sees a changed env on each reload and
107
- * spawns a fresh subprocess — picking up source-file changes without a full
108
- * Talon restart. On a holder object so other modules can read/update it.
91
+ * The last plugin reload time: the module cache-bust key (see
92
+ * `_deps.importModule`), also passed to every MCP child as TALON_RELOAD_AT.
93
+ * On a holder object so other modules can read/update it.
109
94
  */
110
95
  export const reloadState: { lastReloadAt: string } = {
111
96
  lastReloadAt: new Date().toISOString(),
@@ -125,9 +110,8 @@ export const _deps = {
125
110
  // URL forever, so without a changing query a hot reload re-imports the
126
111
  // *old* module object and the plugin's own source edits are invisible
127
112
  // until a full restart — silently, since the stale module still loads
128
- // fine (observed 2026-09-16: a rewritten plugin kept reporting its
129
- // previous version through three reloads). The timestamp only moves per
130
- // reload, so every module in one load cycle shares a key.
113
+ // fine. The timestamp only moves per reload, so every module in one
114
+ // load cycle shares a key.
131
115
  const url = pathToFileURL(path);
132
116
  url.searchParams.set("talonReloadAt", reloadState.lastReloadAt);
133
117
  return import(url.href);
@@ -35,10 +35,7 @@ export function isMcpPlugin(entry: PluginEntry): entry is PluginMcpEntry {
35
35
  return "command" in entry && "name" in entry && !("path" in entry);
36
36
  }
37
37
 
38
- /**
39
- * Core plugin interface — only `name` is required.
40
- * All other capabilities are optional (Interface Segregation).
41
- */
38
+ /** Core plugin interface — only `name` is required. */
42
39
  export interface TalonPlugin {
43
40
  /** Unique plugin identifier. Used as MCP server name prefix. */
44
41
  readonly name: string;
@@ -134,7 +131,7 @@ export interface LoadedPlugin {
134
131
  readonly path: string;
135
132
  }
136
133
 
137
- /** MCP server configuration for the Claude Agent SDK. */
134
+ /** Stdio MCP server spec (command, args, env). */
138
135
  export interface McpServerConfig {
139
136
  command: string;
140
137
  args: string[];
@@ -63,7 +63,7 @@ import { existsSync, readFileSync } from "node:fs";
63
63
  import { resolve } from "node:path";
64
64
  import { dirs, files as pathFiles } from "../../util/paths.js";
65
65
  import { todayAndYesterday } from "../../util/time.js";
66
- import { log } from "../../util/log.js";
66
+ import { log, logWarn } from "../../util/log.js";
67
67
  import { loadSystemTemplate } from "./templates.js";
68
68
  import { renderMemoryView } from "./memory-view.js";
69
69
  import { renderWorkspaceListing } from "./workspace-listing.js";
@@ -125,11 +125,23 @@ const MEMORY_CHARS_METRIC = "prompt.memory_chars";
125
125
 
126
126
  // ── Helpers ─────────────────────────────────────────────────────────────────
127
127
 
128
+ /** Unreadable prompt files already reported (path + error code). */
129
+ const reportedUnreadable = new Set<string>();
130
+
128
131
  function readOptionalFile(path: string): string {
129
132
  try {
130
133
  if (existsSync(path)) return readFileSync(path, "utf-8").trim();
131
- } catch {
132
- /* ignore */
134
+ } catch (err) {
135
+ // Absent is fine (existsSync above); present-but-unreadable silently
136
+ // drops a prompt section, so say which — once, not every assembly.
137
+ const key = `${path}\0${(err as NodeJS.ErrnoException).code ?? ""}`;
138
+ if (!reportedUnreadable.has(key)) {
139
+ reportedUnreadable.add(key);
140
+ logWarn(
141
+ "workspace",
142
+ `Prompt file unreadable, section omitted path=${path}: ${err instanceof Error ? err.message : String(err)}`,
143
+ );
144
+ }
133
145
  }
134
146
  return "";
135
147
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Lua trigger runner — executes a trigger script inside a WASM-sandboxed
3
3
  * Lua 5.4 VM (wasmoon), speaking the same stdout protocol bash/python/node
4
- * triggers use (see core/background/triggers.ts).
4
+ * triggers use (see core/background/triggers/).
5
5
  *
6
6
  * Why a separate process at all: the trigger supervisor's whole contract
7
7
  * (kill/respawn, SIGTERM grace, log streaming, TALON_FIRE line scanning)
@@ -62,7 +62,11 @@ import { basename } from "node:path";
62
62
  * Dispatched by src/index.ts and src/cli.ts before the app graph loads. */
63
63
  export const LUA_RUN_SUBCOMMAND = "_lua-run";
64
64
 
65
- /** Must match FIRE_PREFIX in core/background/triggers.ts. */
65
+ /**
66
+ * Must match FIRE_PREFIX in core/background/triggers/state.ts. Copied,
67
+ * not imported: that module pulls in the dispatcher, and the `_lua-run`
68
+ * helper process must stay light.
69
+ */
66
70
  const FIRE_PREFIX = "TALON_FIRE:";
67
71
 
68
72
  /**
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { bus } from "../bus/index.js";
16
+ import { logWarn } from "../../util/log.js";
16
17
  import type { TaskSettledEvent, TaskStartedEvent } from "../bus/events.js";
17
18
  import type {
18
19
  KillOutcome,
@@ -114,9 +115,14 @@ export class TaskTable {
114
115
  task.killRequested = true;
115
116
  try {
116
117
  task.abort();
117
- } catch {
118
+ } catch (err) {
118
119
  // The abort hook contract is "must not throw" — a violation must
119
- // not break the kill path for the caller.
120
+ // not break the kill path for the caller, but the run may now
121
+ // never settle, so leave a trace of why.
122
+ logWarn(
123
+ "tasks",
124
+ `Abort hook threw task=${id} kind=${task.record.kind} label=${JSON.stringify(task.record.label)}: ${err instanceof Error ? err.message : String(err)}`,
125
+ );
120
126
  }
121
127
  }
122
128
  return { ok: true };
@@ -1,8 +1,6 @@
1
1
  /**
2
- * Bridge utilities — shared by the unified MCP server.
3
- *
4
- * Extracted from the old per-backend tools.ts files so there's
5
- * exactly one copy of callBridge / textResult.
2
+ * Bridge utilities — the one copy of callBridge / textResult, shared by
3
+ * the unified MCP server.
6
4
  *
7
5
  * At the tools root, not in `ops/`: the group directories hold tool
8
6
  * DOMAINS (one file per family of tool definitions), and this is the
@@ -25,7 +25,7 @@ Media: give file_path (a workspace file) or url (a public URL, fetched by the pl
25
25
  Examples:
26
26
  send_via(frontend="whatsapp", target="+353871234567", text="On my way")
27
27
  send_via(frontend="whatsapp", target="+353871234567", file_path="media/plot.png", text="here's the chart")
28
- send_via(frontend="telegram", target="-1001426819337", text="Build is green")
28
+ send_via(frontend="telegram", target="-1009876543210", text="Build is green")
29
29
 
30
30
  Fails with a clear error when the named frontend is not enabled or not connected.`,
31
31
  schema: {
@@ -257,7 +257,7 @@ Examples:
257
257
  chat_id: chatIdSchema
258
258
  .optional()
259
259
  .describe(
260
- "Target chat ID. Omit to send to the current chat (chat mode). Required from heartbeat mode where there is no ambient chat — use list_chats or known IDs from memory. Telegram supergroup/channel IDs are negative (e.g. -1001426819337); user DMs are positive.",
260
+ "Target chat ID. Omit to send to the current chat (chat mode). Required from heartbeat mode where there is no ambient chat — use list_chats or known IDs from memory. Telegram supergroup/channel IDs are negative (e.g. -1009876543210); user DMs are positive.",
261
261
  ),
262
262
  silent: z
263
263
  .boolean()
@@ -112,7 +112,7 @@ const ALL_TOOL_NAMES: ReadonlySet<string> = new Set(
112
112
  * e.g. `mcp__telegram-tools__end_turn` → `end_turn`.
113
113
  *
114
114
  * 2. **Kilo / OpenCode:** `<server>_<tool>` (single underscore boundary,
115
- * no `mcp__` prefix). e.g. `talon-tools-352042062_send` → `send`,
115
+ * no `mcp__` prefix). e.g. `talon-tools-424242420_send` → `send`,
116
116
  * `talon-tools-heartbeat_end_turn` → `end_turn`. Bare names that
117
117
  * contain underscores (`end_turn`) make a "split on _" approach
118
118
  * ambiguous, so we resolve by checking whether the trailing segment
@@ -132,7 +132,7 @@ export function stripMcpPrefix(toolName: string): string {
132
132
 
133
133
  // Kilo / OpenCode `<server>_<bare>` form. Walk underscore boundaries
134
134
  // from the right; the longest tail that matches a registered tool name
135
- // is the bare name. Prevents `talon-tools-352042062_end_turn` from
135
+ // is the bare name. Prevents `talon-tools-424242420_end_turn` from
136
136
  // resolving to `turn` (which isn't a tool).
137
137
  const segments = toolName.split("_");
138
138
  for (let i = 1; i < segments.length; i++) {
@@ -1,70 +1,13 @@
1
1
  /**
2
- * Talon MCP server environment — the single place that defines the
3
- * env-var contract between backends (which spawn `mcp-server.ts` as a
4
- * subprocess) and the server itself (which reads these vars at boot).
5
- *
6
- * Before this helper, four backends (claude-sdk, codex, openai-agents,
7
- * kilo/opencode via remote-server) each hand-built the same
8
- * `{TALON_BRIDGE_URL, TALON_CHAT_ID, TALON_FRONTEND}` literal —
9
- * adding a variable meant editing all four. Now they call
10
- * `buildTalonMcpEnv` and new vars propagate everywhere.
11
- *
12
2
  * Tool-surface trimming: every registered MCP tool costs context
13
3
  * tokens in EVERY session (name + description + schema). Deployments
14
4
  * that never use whole tool groups (stickers, polls, triggers, …) can
15
5
  * reclaim that budget via `disabledToolTags` / `disabledTools` in
16
- * talon.json — carried to the subprocess as comma-separated env vars.
6
+ * talon.json.
17
7
  */
18
8
 
19
- // ── Env-var names (shared constants, not stringly-typed call sites) ────────
20
-
21
- const ENV_BRIDGE_URL = "TALON_BRIDGE_URL";
22
- const ENV_CHAT_ID = "TALON_CHAT_ID";
23
- const ENV_FRONTEND = "TALON_FRONTEND";
24
- export const ENV_DISABLED_TOOLS = "TALON_DISABLED_TOOLS";
25
- export const ENV_DISABLED_TOOL_TAGS = "TALON_DISABLED_TOOL_TAGS";
26
-
27
- // ── Builder (backend side) ──────────────────────────────────────────────────
28
-
29
- /** The slice of TalonConfig the MCP env cares about. */
9
+ /** The slice of TalonConfig the tool exclusions care about. */
30
10
  export type ToolExclusionConfig = {
31
11
  disabledTools?: readonly string[];
32
12
  disabledToolTags?: readonly string[];
33
13
  };
34
-
35
- export type TalonMcpEnvInputs = {
36
- bridgeUrl: string;
37
- chatId: string;
38
- frontend: string;
39
- /** Pass the Talon config (or the exclusion slice of it) when available. */
40
- config?: ToolExclusionConfig | null;
41
- };
42
-
43
- /** Build the env map for spawning Talon's unified MCP tool server. */
44
- export function buildTalonMcpEnv(
45
- inputs: TalonMcpEnvInputs,
46
- ): Record<string, string> {
47
- const env: Record<string, string> = {
48
- [ENV_BRIDGE_URL]: inputs.bridgeUrl,
49
- [ENV_CHAT_ID]: inputs.chatId,
50
- [ENV_FRONTEND]: inputs.frontend,
51
- };
52
- const tools = inputs.config?.disabledTools;
53
- if (tools?.length) env[ENV_DISABLED_TOOLS] = tools.join(",");
54
- const tags = inputs.config?.disabledToolTags;
55
- if (tags?.length) env[ENV_DISABLED_TOOL_TAGS] = tags.join(",");
56
- return env;
57
- }
58
-
59
- // ── Spawn spec ──────────────────────────────────────────────────────────────
60
-
61
- // ── Parser (server side) ────────────────────────────────────────────────────
62
-
63
- /** Parse a comma-separated env var into a trimmed, de-blanked list. */
64
- export function parseEnvList(raw: string | undefined): string[] {
65
- if (!raw) return [];
66
- return raw
67
- .split(",")
68
- .map((s) => s.trim())
69
- .filter((s) => s.length > 0);
70
- }
@@ -184,10 +184,31 @@ export const agentTools: ToolDefinition[] = [
184
184
  tag: "agents",
185
185
  },
186
186
 
187
+ {
188
+ name: "list_peers",
189
+ description:
190
+ "Sub-agents only. List the other agents your parent spawned alongside you — id, label and what each is working on. These are the only agents you may message directly with message_peer. Call it before assuming you are working alone.",
191
+ schema: {},
192
+ execute: (params, bridge) => bridge("list_peers", params),
193
+ tag: "agents",
194
+ },
195
+
196
+ {
197
+ name: "message_peer",
198
+ description:
199
+ "Sub-agents only. Send a note straight to a peer — an agent spawned by the same parent as you — without routing it through your parent. Use it when you find something that changes another agent's work: a shared fact, a dead end worth not repeating, a correction to something you sent earlier. The peer sees it at its next check_inbox, so it is not an interrupt. You can only address peers (see list_peers); any other id is refused. This does not end your run and does not replace report_result.",
200
+ schema: {
201
+ agent_id: z.string().describe("Peer agent id, from list_peers"),
202
+ text: z.string().min(1).describe("What the peer needs to know"),
203
+ },
204
+ execute: (params, bridge) => bridge("message_peer", params),
205
+ tag: "agents",
206
+ },
207
+
187
208
  {
188
209
  name: "check_inbox",
189
210
  description:
190
- "Sub-agents only. Drain any instructions your parent has sent you. Messages are delivered no other way, so check at milestones: after a phase of work, before a long operation, and before you report.",
211
+ "Sub-agents only. Drain anything sent to you — instructions from your parent, and notes from peer agents working alongside you. Each message names its sender. Messages are delivered no other way, so check at milestones: after a phase of work, before a long operation, and before you report.",
191
212
  schema: {},
192
213
  execute: (_params, bridge) => bridge("check_inbox", {}),
193
214
  tag: "agents",
@@ -41,8 +41,8 @@ export const idSchema = z.union([
41
41
  * sentinel the gateway already treats as falsy/unrouted.
42
42
  *
43
43
  * Accepts:
44
- * - actual non-zero integer numbers (`352042062`, `-1001426819337`)
45
- * - integer strings with optional leading minus (`"-1001426819337"`)
44
+ * - actual non-zero integer numbers (`424242420`, `-1009876543210`)
45
+ * - integer strings with optional leading minus (`"-1009876543210"`)
46
46
  *
47
47
  * Rejects:
48
48
  * - zero (`0`, `"0"`, `"-0"`)
@@ -51,13 +51,8 @@ export const idSchema = z.union([
51
51
  *
52
52
  * Use this for `chat_id` fields on tool input schemas. The bare
53
53
  * `idSchema` is for message/user/reply IDs (always positive) and
54
- * would reject the negative IDs Telegram uses for groups/channels —
55
- * which was the bug PR #150 shipped with: heartbeat outbound `send`
56
- * to a supergroup got `expected number, received string` (the model
57
- * sees a `number` JSON schema, but zod rejects negatives before the
58
- * gateway sees the request). Gateway-side handling for negative
59
- * chat_ids was already tested and correct — only the tool-input
60
- * schema layer needed the fix.
54
+ * would reject the negative IDs Telegram uses for groups/channels
55
+ * before the gateway ever sees the request.
61
56
  */
62
57
  const nonZeroInt = z
63
58
  .number()
@@ -90,11 +90,6 @@ export function namespaceFsStatus(): FuseStatus {
90
90
  return status;
91
91
  }
92
92
 
93
- /** Test seam — force a status without a real mount. */
94
- function _setNamespaceFsStatusForTesting(next: FuseStatus): void {
95
- status = next;
96
- }
97
-
98
93
  /** Test seam — drive one watchdog tick synchronously (no interval wait). */
99
94
  export async function _checkNamespaceFsHealthForTesting(): Promise<void> {
100
95
  await checkNamespaceFsHealth();
@@ -12,7 +12,8 @@
12
12
  * skills/ SKILL.md bundles (writable)
13
13
  * scripts/ agent script bodies (writable)
14
14
  * logs/ daily logs (read-only — the daemon writes these)
15
- * proc/ task table + event bus ring (synthetic, read-only)
15
+ * proc/ task table, event bus ring, and the daemon's own
16
+ * log/errors/alerts views (synthetic, read-only)
16
17
  * plugins/ plugin registry view (synthetic, read-only)
17
18
  *
18
19
  * The namespace IS the filesystem: it lives at ~/.talon/ns (symlink
@@ -25,10 +26,12 @@
25
26
  * grammar in vfs.ts / fusefs.ts; consumers never see it.)
26
27
  */
27
28
 
28
- import { dirs } from "../../util/paths.js";
29
+ import { dirs, files } from "../../util/paths.js";
29
30
  import { bus } from "../bus/index.js";
31
+ import { activeAlerts } from "../frontend-runtime/alerts.js";
30
32
  import { registry } from "../plugin/registry.js";
31
33
  import { taskTable } from "../tasks/index.js";
34
+ import { createDiagnosticViews } from "./mounts/diagnostics.js";
32
35
  import { createFileMount } from "./mounts/files.js";
33
36
  import { createPluginsMount, type PluginView } from "./mounts/plugins.js";
34
37
  import { createProcMount } from "./mounts/proc.js";
@@ -95,6 +98,10 @@ function buildDefaultNamespace(): Vfs {
95
98
  createProcMount({
96
99
  tasks: () => taskTable.list(),
97
100
  events: () => bus.recent(0),
101
+ views: createDiagnosticViews({
102
+ logPath: files.log,
103
+ alerts: activeAlerts,
104
+ }),
98
105
  }),
99
106
  );
100
107
  vfs.mount("plugins", createPluginsMount(pluginViews));
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Diagnostic proc views — "what went wrong" without knowing a path:
3
+ *
4
+ * proc/log the last lines of talon.log, one human line each
5
+ * proc/errors recent warn/error/fatal entries, with stack frames
6
+ * proc/alerts the operator alerts currently raised
7
+ *
8
+ * Rendered text, not pino JSON, so `cat` answers the question directly.
9
+ * The log views read a bounded tail of talon.log (plus its newest
10
+ * rotated generation when the live file is young) — a regular file
11
+ * outside ~/.talon/ns, so the FUSE deadlock rule is not in play — and
12
+ * memoize for a second: FUSE stats a node before reading it, and both
13
+ * should see the same text without reading the log twice.
14
+ */
15
+
16
+ import {
17
+ formatLogRecord,
18
+ readLogRecords,
19
+ type LogRecord,
20
+ } from "../../daemon/log-reader.js";
21
+
22
+ export type AlertView = {
23
+ key: string;
24
+ severity: string;
25
+ message: string;
26
+ since: number;
27
+ };
28
+
29
+ export interface DiagnosticViewDeps {
30
+ /** talon.log. */
31
+ logPath: string;
32
+ /** The live alert table (`activeAlerts`). */
33
+ alerts: () => ReadonlyArray<AlertView>;
34
+ now?: () => number;
35
+ }
36
+
37
+ const LOG_LINES = 200;
38
+ const ERROR_ENTRIES = 100;
39
+ const MEMO_MS = 1_000;
40
+ /** Two generations: the live file and the one it rotated from. */
41
+ const READ_OPTS = { maxFiles: 2, maxBytesPerFile: 1024 * 1024 } as const;
42
+
43
+ function memo(render: () => string, now: () => number): () => string {
44
+ let cached: { at: number; text: string } | null = null;
45
+ return () => {
46
+ const t = now();
47
+ if (cached && t - cached.at < MEMO_MS) return cached.text;
48
+ cached = { at: t, text: render() };
49
+ return cached.text;
50
+ };
51
+ }
52
+
53
+ function body(records: LogRecord[], stackLines: number, max: number): string {
54
+ return records
55
+ .map((r) => formatLogRecord(r, { stackLines, maxMsgChars: max }))
56
+ .join("\n");
57
+ }
58
+
59
+ function renderLogView(logPath: string): string {
60
+ const records = readLogRecords(logPath, { limit: LOG_LINES, ...READ_OPTS });
61
+ if (records.length === 0) return `# ${logPath} is empty or missing\n`;
62
+ return (
63
+ `# last ${records.length} lines of ${logPath}, newest last ` +
64
+ `(older: ${logPath}.1…, or \`talon logs --since 1h\`)\n` +
65
+ `${body(records, 0, 400)}\n`
66
+ );
67
+ }
68
+
69
+ function renderErrorsView(logPath: string): string {
70
+ const records = readLogRecords(logPath, {
71
+ limit: ERROR_ENTRIES,
72
+ filter: { minLevel: "warn" },
73
+ ...READ_OPTS,
74
+ });
75
+ if (records.length === 0) {
76
+ return `# no warnings or errors in the recent ${logPath}\n`;
77
+ }
78
+ return (
79
+ `# last ${records.length} warn/error entries from ${logPath}, newest last\n` +
80
+ `${body(records, 5, 500)}\n`
81
+ );
82
+ }
83
+
84
+ export function renderAlertsView(
85
+ alerts: ReadonlyArray<AlertView>,
86
+ now: number,
87
+ ): string {
88
+ if (alerts.length === 0) return "# no active alerts\n";
89
+ const lines = alerts.map((a) => {
90
+ const mins = Math.max(0, Math.round((now - a.since) / 60_000));
91
+ return (
92
+ `[${a.severity}] ${a.key} since ${new Date(a.since).toISOString()} ` +
93
+ `(${mins} min)\n ${a.message}`
94
+ );
95
+ });
96
+ return `# ${alerts.length} active alert(s)\n${lines.join("\n")}\n`;
97
+ }
98
+
99
+ /** The proc views, keyed by file name — wired by the vfs index. */
100
+ export function createDiagnosticViews(
101
+ deps: DiagnosticViewDeps,
102
+ ): Record<string, () => string> {
103
+ const now = deps.now ?? Date.now;
104
+ return {
105
+ log: memo(() => renderLogView(deps.logPath), now),
106
+ errors: memo(() => renderErrorsView(deps.logPath), now),
107
+ alerts: () => renderAlertsView(deps.alerts(), now()),
108
+ };
109
+ }
@@ -5,6 +5,8 @@
5
5
  * proc/
6
6
  * tasks/<id> one task table record, pretty JSON
7
7
  * events the event bus ring, JSON Lines (newest last)
8
+ * <view> any extra text views the index wires (log, errors,
9
+ * alerts — see ./diagnostics.ts)
8
10
  *
9
11
  * Providers are injected so the mount is a pure projection — the index
10
12
  * wires the real task table and bus, tests wire fixtures. Sizes and
@@ -20,6 +22,8 @@ import { vfsError, vfsOk } from "../types.js";
20
22
  export interface ProcMountDeps {
21
23
  tasks: () => readonly TaskRecord[];
22
24
  events: () => readonly PublishedEvent[];
25
+ /** Extra read-only text files at the proc root, name → renderer. */
26
+ views?: Readonly<Record<string, () => string>>;
23
27
  }
24
28
 
25
29
  const DIR = (path: string, name: string): VfsStat => ({
@@ -54,6 +58,13 @@ function renderEvents(events: readonly PublishedEvent[]): string {
54
58
  }
55
59
 
56
60
  export function createProcMount(deps: ProcMountDeps): VfsMount {
61
+ const views: Readonly<Record<string, () => string>> = Object.assign(
62
+ Object.create(null) as Record<string, () => string>,
63
+ deps.views,
64
+ );
65
+ const viewStats = (): VfsStat[] =>
66
+ Object.keys(views).map((name) => fileStat(name, views[name]()));
67
+
57
68
  function taskById(id: string): TaskRecord | undefined {
58
69
  if (!/^\d+$/.test(id)) return undefined;
59
70
  return deps.tasks().find((task) => task.id === Number(id));
@@ -66,6 +77,7 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
66
77
  const events = deps.events();
67
78
  return vfsOk(fileStat("events", renderEvents(events), events.at(-1)?.at));
68
79
  }
80
+ if (views[rel]) return vfsOk(fileStat(rel, views[rel]()));
69
81
  const taskId = rel.startsWith("tasks/") ? rel.slice("tasks/".length) : null;
70
82
  if (taskId !== null && !taskId.includes("/")) {
71
83
  const task = taskById(taskId);
@@ -78,7 +90,9 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
78
90
  }
79
91
 
80
92
  return {
81
- description: "Live daemon state: task table and event bus ring",
93
+ description: deps.views
94
+ ? "Live daemon state: task table, event bus ring, daemon log views"
95
+ : "Live daemon state: task table and event bus ring",
82
96
  writable: false,
83
97
 
84
98
  stat: resolveNode,
@@ -89,6 +103,7 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
89
103
  return vfsOk([
90
104
  DIR("tasks", "tasks"),
91
105
  fileStat("events", renderEvents(events), events.at(-1)?.at),
106
+ ...viewStats(),
92
107
  ]);
93
108
  }
94
109
  if (rel === "tasks") {
@@ -112,6 +127,7 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
112
127
 
113
128
  read(rel) {
114
129
  if (rel === "events") return vfsOk(renderEvents(deps.events()));
130
+ if (views[rel]) return vfsOk(views[rel]());
115
131
  if (rel.startsWith("tasks/")) {
116
132
  const task = taskById(rel.slice("tasks/".length));
117
133
  if (!task) return vfsError("not-found");
@@ -20,6 +20,7 @@ import {
20
20
  } from "node:fs";
21
21
  import { createHash } from "node:crypto";
22
22
  import { join, resolve } from "node:path";
23
+ import writeFileAtomic from "write-file-atomic";
23
24
  import { log } from "../../util/log.js";
24
25
  import { dirs, files as pathFiles } from "../../util/paths.js";
25
26
  import { listSeedPrompts, readPromptAsset } from "#prompt-assets";
@@ -259,8 +260,11 @@ function seedPrompts(): void {
259
260
  const pkgHash = sha256(pkg);
260
261
 
261
262
  if (!existsSync(dst)) {
263
+ // Atomic: a write cut short (ENOSPC, crash) must leave no file
264
+ // rather than a truncated one, which the next boot could not tell
265
+ // from a user edit and would keep forever.
262
266
  try {
263
- writeFileSync(dst, pkg);
267
+ writeFileAtomic.sync(dst, pkg);
264
268
  } catch (err) {
265
269
  log(
266
270
  "workspace",
@@ -293,7 +297,7 @@ function seedPrompts(): void {
293
297
  } else if (seededHash !== undefined && curHash === seededHash) {
294
298
  // Pristine seeded copy + package changed → refresh on upgrade.
295
299
  try {
296
- writeFileSync(dst, pkg);
300
+ writeFileAtomic.sync(dst, pkg);
297
301
  } catch {
298
302
  continue; // manifest keeps the old hash — retried next boot
299
303
  }
@@ -308,7 +312,7 @@ function seedPrompts(): void {
308
312
 
309
313
  if (manifestDirty) {
310
314
  try {
311
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
315
+ writeFileAtomic.sync(manifestPath, JSON.stringify(manifest, null, 2));
312
316
  } catch {
313
317
  /* non-fatal — worst case we re-derive next boot */
314
318
  }