talon-agent 5.18.1 → 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 (194) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +8 -3
  5. package/package.json +4 -2
  6. package/prompts/system/agent-brief.md +20 -3
  7. package/src/app.ts +13 -0
  8. package/src/backend/claude-sdk/handler.ts +4 -4
  9. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  10. package/src/backend/claude-sdk/stream.ts +2 -2
  11. package/src/backend/codex/auth.ts +1 -1
  12. package/src/backend/codex/handler/message.ts +11 -11
  13. package/src/backend/codex/init.ts +4 -9
  14. package/src/backend/codex/mcp-config.ts +1 -2
  15. package/src/backend/codex/oauth-incompat.ts +8 -4
  16. package/src/backend/codex/one-shot.ts +1 -1
  17. package/src/backend/openai-agents/builtins.ts +55 -27
  18. package/src/backend/openai-agents/factory.ts +3 -3
  19. package/src/backend/openai-agents/handler/message.ts +3 -5
  20. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  21. package/src/backend/remote-server/chat-turn.ts +6 -6
  22. package/src/backend/remote-server/events.ts +1 -5
  23. package/src/backend/remote-server/index.ts +0 -1
  24. package/src/backend/remote-server/messages.ts +3 -7
  25. package/src/backend/remote-server/one-shot.ts +1 -3
  26. package/src/backend/remote-server/session-helpers.ts +1 -4
  27. package/src/backend/remote-server/sse-stream.ts +8 -9
  28. package/src/backend/runtime/metrics.ts +7 -13
  29. package/src/backend/runtime/sleep.ts +1 -2
  30. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  31. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  32. package/src/bootstrap.ts +9 -1
  33. package/src/cli/doctor.ts +3 -0
  34. package/src/cli/index.ts +8 -10
  35. package/src/cli/logs.ts +148 -9
  36. package/src/cli/setup.ts +9 -11
  37. package/src/cli/status.ts +29 -0
  38. package/src/core/agent-runtime/README.md +5 -19
  39. package/src/core/agent-runtime/events.ts +3 -39
  40. package/src/core/agent-runtime/model-ref.ts +0 -8
  41. package/src/core/agents/registry.ts +49 -2
  42. package/src/core/auth/expiry-monitor.ts +9 -1
  43. package/src/core/auth/login-flow.ts +9 -1
  44. package/src/core/auth/status.ts +31 -3
  45. package/src/core/background/cron/scheduler.ts +25 -5
  46. package/src/core/background/dream/index.ts +29 -10
  47. package/src/core/background/failure-backoff.ts +30 -0
  48. package/src/core/background/heartbeat/agent.ts +2 -27
  49. package/src/core/background/heartbeat/index.ts +0 -2
  50. package/src/core/background/heartbeat/scheduler.ts +21 -13
  51. package/src/core/background/heartbeat/state.ts +10 -2
  52. package/src/core/background/isolated-agent.ts +6 -2
  53. package/src/core/background/pulse/pulse.ts +9 -0
  54. package/src/core/background/triggers/exit.ts +54 -0
  55. package/src/core/background/triggers/index.ts +1 -3
  56. package/src/core/background/triggers/resume.ts +2 -4
  57. package/src/core/backup/archive/tar.ts +14 -4
  58. package/src/core/backup/restore.ts +21 -13
  59. package/src/core/backup/scheduler.ts +28 -9
  60. package/src/core/backup/snapshot.ts +43 -10
  61. package/src/core/backup/store.ts +7 -19
  62. package/src/core/backup/targets.ts +69 -17
  63. package/src/core/config/index.ts +14 -0
  64. package/src/core/daemon/crash-marker.ts +141 -0
  65. package/src/core/daemon/crash.ts +9 -2
  66. package/src/core/daemon/handoff.ts +15 -0
  67. package/src/core/daemon/health-alerts.ts +297 -0
  68. package/src/core/daemon/log-reader.ts +289 -0
  69. package/src/core/doctor/index.ts +18 -2
  70. package/src/core/doctor/logs.ts +124 -0
  71. package/src/core/doctor/types.ts +1 -1
  72. package/src/core/engine/backend-controller/index.ts +1 -13
  73. package/src/core/engine/backend-router/router.ts +1 -1
  74. package/src/core/engine/dispatcher.ts +55 -2
  75. package/src/core/engine/fault-text.ts +40 -0
  76. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  77. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  78. package/src/core/engine/gateway-actions/history.ts +2 -4
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway.ts +60 -1
  81. package/src/core/engine/turn-health.ts +222 -0
  82. package/src/core/errors.ts +2 -2
  83. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  84. package/src/core/frontend-runtime/alerts.ts +130 -0
  85. package/src/core/mcp-hub/children.ts +78 -29
  86. package/src/core/mcp-hub/index.ts +21 -18
  87. package/src/core/mcp-hub/proxy-server.ts +8 -4
  88. package/src/core/mcp-hub/talon-server.ts +5 -12
  89. package/src/core/mesh/credentials/store.ts +16 -1
  90. package/src/core/mesh/devices/service.ts +26 -15
  91. package/src/core/mesh/devices/teleport.ts +14 -2
  92. package/src/core/mesh/links/node-binaries.ts +13 -6
  93. package/src/core/mesh/persist.ts +22 -10
  94. package/src/core/mesh/transfers/device-files.ts +5 -23
  95. package/src/core/mesh/transfers/transfers.ts +16 -3
  96. package/src/core/models/active-model.ts +2 -55
  97. package/src/core/plugin/actions.ts +19 -20
  98. package/src/core/plugin/builtins.ts +80 -90
  99. package/src/core/plugin/index.ts +1 -4
  100. package/src/core/plugin/loader.ts +25 -33
  101. package/src/core/plugin/mcp.ts +3 -5
  102. package/src/core/plugin/registry.ts +19 -35
  103. package/src/core/plugin/types.ts +2 -5
  104. package/src/core/prompt/assemble.ts +15 -3
  105. package/src/core/scripts/lua.ts +6 -2
  106. package/src/core/tasks/table.ts +8 -2
  107. package/src/core/tools/bridge.ts +2 -4
  108. package/src/core/tools/chat/cross-send.ts +1 -1
  109. package/src/core/tools/chat/messaging.ts +1 -1
  110. package/src/core/tools/index.ts +2 -2
  111. package/src/core/tools/mcp-env.ts +2 -59
  112. package/src/core/tools/ops/agents.ts +22 -1
  113. package/src/core/tools/schemas.ts +4 -9
  114. package/src/core/vfs/fusefs.ts +0 -5
  115. package/src/core/vfs/index.ts +9 -2
  116. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  117. package/src/core/vfs/mounts/proc.ts +17 -1
  118. package/src/core/vfs/workspace.ts +7 -3
  119. package/src/core/weaver/shuttle.ts +8 -1
  120. package/src/core/weaver/turn-log.ts +320 -0
  121. package/src/core/weaver/weaver.ts +48 -5
  122. package/src/frontend/discord/actions/index.ts +8 -1
  123. package/src/frontend/discord/diagnostics.ts +82 -6
  124. package/src/frontend/discord/handlers/index.ts +0 -2
  125. package/src/frontend/discord/middleware.ts +7 -13
  126. package/src/frontend/discord/runtime.ts +1 -3
  127. package/src/frontend/health/delivery.ts +115 -0
  128. package/src/frontend/health/outage.ts +116 -0
  129. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  130. package/src/frontend/native/bridge/server.ts +106 -21
  131. package/src/frontend/native/index.ts +1 -1
  132. package/src/frontend/native/media/media.ts +5 -1
  133. package/src/frontend/native/runtime.ts +12 -7
  134. package/src/frontend/native/surface/handlers.ts +1 -1
  135. package/src/frontend/native/surface/memory.ts +1 -1
  136. package/src/frontend/native/surface/models.ts +3 -3
  137. package/src/frontend/native/surface/settings.ts +20 -8
  138. package/src/frontend/native/turn/context.ts +6 -8
  139. package/src/frontend/native/turn/turn-meta.ts +2 -5
  140. package/src/frontend/native/turn/turn.ts +8 -10
  141. package/src/frontend/presentation/format.ts +2 -4
  142. package/src/frontend/presentation/session-status.ts +2 -6
  143. package/src/frontend/teams/actions.ts +8 -1
  144. package/src/frontend/teams/graph.ts +0 -1
  145. package/src/frontend/teams/index.ts +1 -4
  146. package/src/frontend/teams/poll.ts +40 -2
  147. package/src/frontend/teams/runtime.ts +14 -5
  148. package/src/frontend/telegram/actions/index.ts +4 -1
  149. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  150. package/src/frontend/telegram/actions/send.ts +12 -0
  151. package/src/frontend/telegram/handlers/context.ts +13 -2
  152. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  153. package/src/frontend/telegram/handlers/index.ts +0 -2
  154. package/src/frontend/telegram/index.ts +35 -9
  155. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  156. package/src/frontend/telegram/userbot.ts +147 -41
  157. package/src/frontend/terminal/builtins/session.ts +2 -2
  158. package/src/frontend/terminal/index.ts +1 -3
  159. package/src/frontend/terminal/renderer.ts +2 -18
  160. package/src/frontend/whatsapp/actions/index.ts +12 -1
  161. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  162. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  163. package/src/frontend/whatsapp/connection/health.ts +89 -0
  164. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  165. package/src/frontend/whatsapp/runtime.ts +11 -5
  166. package/src/native/blake3.ts +28 -2
  167. package/src/native/fusefs.ts +23 -5
  168. package/src/native/registry.ts +1 -1
  169. package/src/native/warden.ts +33 -5
  170. package/src/plugins/github/index.ts +0 -1
  171. package/src/plugins/mempalace/index.ts +9 -3
  172. package/src/plugins/playwright/index.ts +2 -4
  173. package/src/plugins/playwright/provision.ts +12 -4
  174. package/src/storage/chat-settings.ts +6 -1
  175. package/src/storage/cron.ts +29 -4
  176. package/src/storage/daily-log.ts +43 -47
  177. package/src/storage/db.ts +61 -33
  178. package/src/storage/history.ts +6 -1
  179. package/src/storage/journal.ts +9 -2
  180. package/src/storage/kv.ts +19 -6
  181. package/src/storage/media-index.ts +28 -6
  182. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  183. package/src/storage/repositories/sessions-repo.ts +10 -3
  184. package/src/storage/scripts.ts +24 -13
  185. package/src/storage/sessions.ts +11 -2
  186. package/src/storage/skills.ts +21 -2
  187. package/src/storage/stickers.ts +17 -3
  188. package/src/storage/triggers.ts +8 -3
  189. package/src/storage/turn-meta.ts +25 -7
  190. package/src/util/log.ts +189 -6
  191. package/src/util/logging/turn-scope.ts +85 -0
  192. package/src/util/time.ts +3 -3
  193. package/src/util/watchdog.ts +30 -0
  194. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Doctor's "what went wrong lately" probes: the last hour of talon.log
3
+ * summarised by component, and the operator alerts still open — so
4
+ * `talon doctor` points at the failing subsystem instead of only at the
5
+ * environment.
6
+ *
7
+ * Alerts come from the daemon's in-memory table when doctor runs inside
8
+ * the daemon (the chat /doctor command). `talon doctor` is its own
9
+ * process and sees an empty table, so it replays the alert lines in the
10
+ * log instead; those are reported as warnings without counting toward
11
+ * the issue total — the log can only say what was last announced.
12
+ */
13
+
14
+ import {
15
+ readLogRecords,
16
+ replayAlerts,
17
+ type LogRecord,
18
+ } from "../daemon/log-reader.js";
19
+ import { activeAlerts } from "../frontend-runtime/alerts.js";
20
+ import { files } from "../../util/paths.js";
21
+ import type { DoctorCheck } from "./types.js";
22
+
23
+ const HOUR_MS = 3_600_000;
24
+ /** How far back the alert replay looks for a raise still unresolved. */
25
+ const ALERT_WINDOW_MS = 24 * HOUR_MS;
26
+ const READ = { limit: Number.MAX_SAFE_INTEGER, maxBytesPerFile: 16 << 20 };
27
+ const DETAIL_CHARS = 160;
28
+
29
+ export type RecentLogOptions = {
30
+ logPath?: string;
31
+ now?: number;
32
+ /** The live alert table; defaults to this process's `activeAlerts()`. */
33
+ liveAlerts?: ReturnType<typeof activeAlerts>;
34
+ };
35
+
36
+ function clip(text: string): string {
37
+ return text.length > DETAIL_CHARS ? `${text.slice(0, DETAIL_CHARS)}…` : text;
38
+ }
39
+
40
+ function byComponent(records: readonly LogRecord[]): string {
41
+ const counts = new Map<string, number>();
42
+ for (const r of records) {
43
+ const c = r.component ?? "?";
44
+ counts.set(c, (counts.get(c) ?? 0) + 1);
45
+ }
46
+ return [...counts]
47
+ .sort((a, b) => b[1] - a[1])
48
+ .map(([c, n]) => `${c} ${n}`)
49
+ .join(", ");
50
+ }
51
+
52
+ /** The last hour's warn/error lines, as one doctor check. */
53
+ export function checkRecentErrors(opts: RecentLogOptions = {}): DoctorCheck {
54
+ const logPath = opts.logPath ?? files.log;
55
+ const now = opts.now ?? Date.now();
56
+ const recent = readLogRecords(logPath, {
57
+ ...READ,
58
+ filter: { since: now - HOUR_MS, minLevel: "warn" },
59
+ });
60
+ const errors = recent.filter(
61
+ (r) => r.level === "error" || r.level === "fatal",
62
+ );
63
+ const warnings = recent.length - errors.length;
64
+ if (errors.length === 0) {
65
+ return warnings === 0
66
+ ? { label: "Log: no errors or warnings in the last hour", status: "ok" }
67
+ : {
68
+ label: `Log: ${warnings} warning(s) in the last hour`,
69
+ status: "info",
70
+ detail: byComponent(recent),
71
+ };
72
+ }
73
+ const last = errors.at(-1)!;
74
+ const at = new Date(last.ts).toTimeString().slice(0, 5);
75
+ const what = clip(`${last.msg}${last.err ? ` (${last.err})` : ""}`);
76
+ return {
77
+ label: `Log: ${errors.length} error(s) in the last hour — ${byComponent(errors)}`,
78
+ status: "warn",
79
+ detail:
80
+ `latest ${at} ${last.component ?? "?"}: ${what} — ` +
81
+ `talon logs --errors --since 1h, or ~/.talon/ns/proc/errors`,
82
+ };
83
+ }
84
+
85
+ /** Open alerts, one check each (or one "none" line). */
86
+ export function checkOpenAlerts(opts: RecentLogOptions = {}): DoctorCheck[] {
87
+ const now = opts.now ?? Date.now();
88
+ const live = opts.liveAlerts ?? activeAlerts();
89
+ const fromLog = live.length === 0;
90
+ const alerts = fromLog ? loggedAlerts(opts.logPath ?? files.log, now) : live;
91
+ if (alerts.length === 0)
92
+ return [{ label: "Alerts: none open", status: "ok" }];
93
+ return alerts.map((a) => {
94
+ const mins = Math.max(0, Math.round((now - a.since) / 60_000));
95
+ const label = `Alert ${a.key} (${a.severity}, ${mins} min)`;
96
+ if (fromLog) {
97
+ return {
98
+ label,
99
+ status: "warn",
100
+ detail: `last logged: ${clip(a.message)}`,
101
+ };
102
+ }
103
+ return {
104
+ label,
105
+ status: a.severity === "warn" ? "warn" : "fail",
106
+ detail: clip(a.message),
107
+ issue: true,
108
+ };
109
+ });
110
+ }
111
+
112
+ /** Replay the last day's alert lines (plus boot lines, which reset them). */
113
+ function loggedAlerts(logPath: string, now: number) {
114
+ const since = now - ALERT_WINDOW_MS;
115
+ const alertLines = readLogRecords(logPath, {
116
+ ...READ,
117
+ filter: { since, component: "alert" },
118
+ });
119
+ const boots = readLogRecords(logPath, {
120
+ ...READ,
121
+ filter: { since, component: "bot", grep: "Starting Talon" },
122
+ });
123
+ return replayAlerts([...alertLines, ...boots].sort((a, b) => a.ts - b.ts));
124
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The doctor's data shapes, on their own so a backend factory can declare
3
3
  * its checks (`BackendFactory.doctor`) without importing the collector —
4
- * `core/doctor.ts` reads the registry, so the types living there would
4
+ * `core/doctor/index.ts` reads the registry, so the types living there would
5
5
  * close a cycle. Checks are pure data (label / status / detail); renderers
6
6
  * decide presentation.
7
7
  */
@@ -10,7 +10,7 @@
10
10
  * Different roles, and different chats, have different cost/latency/
11
11
  * quality needs. Typical post-Anthropic-metering setup: chat default
12
12
  * on free-tier OpenAI Agents, heartbeats on Claude Sonnet, dream
13
- * shared with chat — plus Pandario stays on Claude while DMs use the
13
+ * shared with chat — plus one group chat stays on Claude while DMs use the
14
14
  * cheap default. A single-active model can't express that. The pool
15
15
  * lets each holder bind independently while deduplicating instances
16
16
  * when ids overlap.
@@ -24,7 +24,6 @@
24
24
  * - `pool` — init/teardown, role accessors, availability, snapshot,
25
25
  * listeners.
26
26
  * - `rebind` — rebind/release holders + per-chat accessors.
27
- * - `legacy` — single-active aliases routed to the chat role.
28
27
  */
29
28
 
30
29
  export * from "./types.js";
@@ -57,14 +56,3 @@ export {
57
56
  hasChatBackendOverride,
58
57
  resolveChatBackend,
59
58
  } from "./rebind.js";
60
- export {
61
- initBackendController,
62
- getActiveBackend,
63
- hasActiveBackend,
64
- getActiveBackendOrNull,
65
- getActiveBackendId,
66
- getActiveBackendLabel,
67
- switchBackend,
68
- cleanupBackendController,
69
- resetBackendControllerForTest,
70
- } from "./legacy.js";
@@ -2,7 +2,7 @@
2
2
  * Plan-aware backend routing for background work.
3
3
  *
4
4
  * Background work — `spawn_agent` sub-agents, cron `query` jobs, the
5
- * heartbeat — used to inherit whichever backend the chat happened to be on.
5
+ * heartbeat — would otherwise inherit whichever backend the chat is on.
6
6
  * On a multi-subscription install that is a good way to burn one plan to its
7
7
  * ceiling while another sits idle. When nothing is pinned, this picks the
8
8
  * backend with the most headroom instead.
@@ -12,8 +12,15 @@ import type { ExecuteParams, ExecuteResult } from "../types.js";
12
12
  import { formatRelayBlock, takePendingRelay } from "./cross-chat-relay.js";
13
13
  import { recordMessageSignal } from "../memory/taps.js";
14
14
  import { log } from "../../util/log.js";
15
+ import { setStuckLoopListener } from "../../util/watchdog.js";
15
16
  import { taskTable, type KillOutcome } from "../tasks/index.js";
16
17
  import { initWeaver, type Weaver, type WeaverDeps } from "../weaver/index.js";
18
+ import {
19
+ noteTurnFailed,
20
+ noteTurnSucceeded,
21
+ stuckLoopAlert,
22
+ type TurnBinding,
23
+ } from "./turn-health.js";
17
24
 
18
25
  // ── Dependencies (injected at startup) ──────────────────────────────────────
19
26
 
@@ -21,7 +28,7 @@ import { initWeaver, type Weaver, type WeaverDeps } from "../weaver/index.js";
21
28
  * `getBackend` takes the string chat id so it can route per-chat —
22
29
  * a chat with a backend override returns its override backend, others
23
30
  * fall through to the global chat-role backend. Tests can pass a
24
- * stub that ignores the chat id. See `core/backend-controller.ts`.
31
+ * stub that ignores the chat id. See `core/engine/backend-controller/`.
25
32
  *
26
33
  * `resolveActiveModel` walks the 5-step active-model resolution
27
34
  * chain for the chat and returns both the resolved `ModelRef` and
@@ -33,9 +40,18 @@ import { initWeaver, type Weaver, type WeaverDeps } from "../weaver/index.js";
33
40
  type DispatcherDeps = WeaverDeps;
34
41
 
35
42
  let weaver: Weaver | null = null;
43
+ let backendIdFor: (chatId: string) => string = () => "unknown";
36
44
 
37
45
  export function initDispatcher(d: DispatcherDeps): void {
38
46
  weaver = initWeaver(d);
47
+ backendIdFor = (chatId) => {
48
+ try {
49
+ return d.getBackend(chatId).id;
50
+ } catch {
51
+ return "unknown";
52
+ }
53
+ };
54
+ setStuckLoopListener(stuckLoopAlert);
39
55
  log("dispatcher", "Initialized (per-chat serial, cross-chat parallel)");
40
56
  }
41
57
 
@@ -94,5 +110,42 @@ export async function execute(params: ExecuteParams): Promise<ExecuteResult> {
94
110
  const prompt = relayed.length
95
111
  ? formatRelayBlock(relayed) + params.prompt
96
112
  : params.prompt;
97
- return weaver.runTurn(relayed.length ? { ...params, prompt } : params);
113
+ const startedAt = Date.now();
114
+ try {
115
+ const result = await weaver.runTurn(
116
+ relayed.length ? { ...params, prompt } : params,
117
+ );
118
+ // Only a turn that reached a backend vouches for it — a refusal or a
119
+ // turn killed in the queue binds no warp this turn.
120
+ const bound = turnBinding(weaver, params, startedAt, false);
121
+ if (bound) noteTurnSucceeded(bound);
122
+ return result;
123
+ } catch (err) {
124
+ const bound = turnBinding(weaver, params, startedAt, true);
125
+ if (bound) noteTurnFailed(bound, err);
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The backend/model this turn ran on, for turn-health alerts: the warp
132
+ * the Weaver bound for it, or — when the turn failed before binding one
133
+ * — the backend currently serving the chat.
134
+ */
135
+ function turnBinding(
136
+ w: Weaver,
137
+ params: ExecuteParams,
138
+ startedAt: number,
139
+ failed: boolean,
140
+ ): TurnBinding | null {
141
+ const common = {
142
+ chatId: params.chatId,
143
+ source: params.source,
144
+ durationMs: Date.now() - startedAt,
145
+ };
146
+ const warp = w.loom.get(params.chatId)?.warp;
147
+ if (warp && warp.boundAt >= startedAt) {
148
+ return { ...common, backendId: warp.backendId, model: warp.model };
149
+ }
150
+ return failed ? { ...common, backendId: backendIdFor(params.chatId) } : null;
98
151
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The error text an operator alert or a key=value log line may carry.
3
+ *
4
+ * Alerts land on a phone and log lines get pasted into issues, so the
5
+ * raw message is flattened to one line, clipped, and stripped of the
6
+ * credential shapes a provider error can echo back (bearer tokens, API
7
+ * keys, `token=` query parameters).
8
+ */
9
+
10
+ const SECRET_PATTERNS: readonly RegExp[] = [
11
+ /\bBearer\s+[\w.~+/=-]+/gi,
12
+ /\b(?:sk|pk|rk)-[\w-]{8,}/g,
13
+ /\b(?:gh[pousr]|github_pat)_\w{8,}/g,
14
+ /\bxox[abprs]-[\w-]{8,}/g,
15
+ /\b(access_token|refresh_token|api_key|apikey|token|key|secret|password)=[^\s&"']+/gi,
16
+ ];
17
+
18
+ /** One-line, clipped, secret-free text for `err`. */
19
+ export function faultText(err: unknown, max = 200): string {
20
+ let raw: string;
21
+ if (err instanceof Error) raw = err.message || err.name;
22
+ else if (typeof err === "string") raw = err;
23
+ else {
24
+ try {
25
+ raw = String(err);
26
+ } catch {
27
+ raw = "[unprintable error]";
28
+ }
29
+ }
30
+ let text = raw.replace(/\s+/g, " ").trim();
31
+ for (const pattern of SECRET_PATTERNS) {
32
+ text = text.replace(pattern, (match, name?: string) =>
33
+ typeof name === "string" && match.includes("=")
34
+ ? `${name}=[redacted]`
35
+ : "[redacted]",
36
+ );
37
+ }
38
+ if (!text) return "unknown error";
39
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
40
+ }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * - `control` — what a parent calls: spawn / list / status / wait / send /
5
5
  * kill. Works from a chat and from inside another agent.
6
- * - `report` — what a sub-agent calls about itself: report_result /
7
- * message_parent / check_inbox.
6
+ * - `report` — what a sub-agent calls about itself and its siblings:
7
+ * report_result / message_parent / check_inbox / list_peers /
8
+ * message_peer.
8
9
  *
9
10
  * Both sets are reachable from an `agent:<id>` context: the gateway routes
10
11
  * those chat keys straight here (see `Gateway.handleAction`), because a
@@ -16,6 +16,7 @@ import {
16
16
  agentIdFromContextLabel,
17
17
  agentRegistry,
18
18
  deliverMessage,
19
+ deliverToAgent,
19
20
  describeParent,
20
21
  } from "../../../agents/index.js";
21
22
  import { logError } from "../../../../util/log.js";
@@ -86,6 +87,67 @@ export const agentReportHandlers: SharedActionHandlers = {
86
87
  };
87
88
  },
88
89
 
90
+ list_peers: (_body, _chatId, _backend, chatKey) => {
91
+ const record = callingAgent(chatKey);
92
+ if (!record) return notAnAgent("list_peers");
93
+ const peers = agentRegistry.peersOf(record.id);
94
+ if (peers.length === 0) {
95
+ return {
96
+ ok: true,
97
+ text:
98
+ "No peers — you are the only agent your parent has running. " +
99
+ "Report to your parent as usual.",
100
+ };
101
+ }
102
+ const rendered = peers
103
+ .map(
104
+ (peer) =>
105
+ `- ${peer.label} [${peer.state}]\n ID: ${peer.id}\n Working on: ${peer.brief.slice(0, 160).replace(/\s+/g, " ")}…`,
106
+ )
107
+ .join("\n");
108
+ return {
109
+ ok: true,
110
+ text: `${peers.length} peer(s) running alongside you:\n\n${rendered}`,
111
+ };
112
+ },
113
+
114
+ message_peer: (body, _chatId, _backend, chatKey) => {
115
+ const record = callingAgent(chatKey);
116
+ if (!record) return notAnAgent("message_peer");
117
+ const id = String(body.agent_id ?? "").trim();
118
+ if (!id) return { ok: false, error: "Missing agent_id" };
119
+ const text = String(body.text ?? "").trim();
120
+ if (!text) return { ok: false, error: "Missing text" };
121
+ // Peers only. An agent may address a sibling, never an arbitrary id:
122
+ // resolving through peersOf is what stops one swarm reaching into
123
+ // another, and it means a wrong id fails closed rather than delivering.
124
+ const peer = agentRegistry
125
+ .peersOf(record.id)
126
+ .find((candidate) => candidate.id === id);
127
+ if (!peer) {
128
+ return {
129
+ ok: false,
130
+ error:
131
+ `No peer "${id}". You can only message agents spawned by the same ` +
132
+ `parent as you — call list_peers to see them.`,
133
+ };
134
+ }
135
+ if (!deliverToAgent(record.id, id, text)) {
136
+ return {
137
+ ok: false,
138
+ error:
139
+ `Could not deliver to "${peer.label}" (${id}): it has already ` +
140
+ `settled, or its inbox is full.`,
141
+ };
142
+ }
143
+ return {
144
+ ok: true,
145
+ text:
146
+ `Queued for peer "${peer.label}" (${id}). It reads its inbox at its ` +
147
+ `own milestones, so this is not an interrupt.`,
148
+ };
149
+ },
150
+
89
151
  check_inbox: (_body, _chatId, _backend, chatKey) => {
90
152
  const record = callingAgent(chatKey);
91
153
  if (!record) return notAnAgent("check_inbox");
@@ -19,10 +19,8 @@ export const historyHandlers: SharedActionHandlers = {
19
19
  const limit = Math.min(100, Number(body.limit ?? 30));
20
20
  const cid = chatKey;
21
21
  // The tool schema advertises `offset_id` and `before` for paging back;
22
- // this fallback used to ignore both and hand back the same newest
23
- // window no matter what the model asked, so "go further back" was a
24
- // silent no-op on frontends without a platform history API (WhatsApp,
25
- // native).
22
+ // ignoring them here would make "go further back" a silent no-op on
23
+ // frontends without a platform history API (WhatsApp, native).
26
24
  const offsetId = Number(body.offset_id);
27
25
  if (Number.isFinite(offsetId) && offsetId > 0) {
28
26
  return { ok: true, text: getFormattedBefore(cid, offsetId, limit) };
@@ -61,23 +61,20 @@ async function bash(
61
61
  }
62
62
  }
63
63
 
64
- const result = await (async () => {
65
- if (background === true) {
66
- if (active) {
67
- return {
68
- ok: false,
69
- text:
70
- "background:true runs on the daemon host only. On a teleported device, " +
71
- "background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll " +
72
- "the log with read.",
73
- };
74
- }
75
- return bashBackground(cmd, dir);
64
+ if (background === true) {
65
+ if (active) {
66
+ return {
67
+ ok: false,
68
+ text:
69
+ "background:true runs on the daemon host only. On a teleported device, " +
70
+ "background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll " +
71
+ "the log with read.",
72
+ };
76
73
  }
77
- if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
78
- return bashLocal(cmd, dir, timeoutMs);
79
- })();
80
- return result;
74
+ return bashBackground(cmd, dir);
75
+ }
76
+ if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
77
+ return bashLocal(cmd, dir, timeoutMs);
81
78
  }
82
79
 
83
80
  function bashLocal(
@@ -14,8 +14,10 @@ import { classify } from "../errors.js";
14
14
  import { getActiveCount } from "./dispatcher.js";
15
15
  import { Loom, getActiveLoom, type ContextRegistry } from "../weaver/index.js";
16
16
  import { getHealthStatus } from "../../util/watchdog.js";
17
+ import { activeAlerts } from "../frontend-runtime/alerts.js";
17
18
  import { getActiveSessionCount } from "../../storage/sessions.js";
18
19
  import { log, logError, logDebug } from "../../util/log.js";
20
+ import { runInChatTurnScope } from "../../util/logging/turn-scope.js";
19
21
  import {
20
22
  handleSharedAction,
21
23
  handleChatFreeAction,
@@ -36,6 +38,38 @@ import type { FrontendActionHandler } from "../types.js";
36
38
  import type { Backend } from "../agent-runtime/capabilities.js";
37
39
  import { resolveOwnerFrontendId } from "../frontend-runtime/routing.js";
38
40
 
41
+ /** Serialized size of an action result; -1 when it can't be serialized. */
42
+ function actionResultBytes(result: unknown): number {
43
+ try {
44
+ return Buffer.byteLength(JSON.stringify(result) ?? "");
45
+ } catch {
46
+ return -1;
47
+ }
48
+ }
49
+
50
+ /** `tool.action name=… chat=… ms=… ok=… bytes=… [err=…]` for one bridge action. */
51
+ function logActionOutcome(
52
+ body: Record<string, unknown>,
53
+ chat: string,
54
+ ms: number,
55
+ result: unknown,
56
+ ): void {
57
+ const action = typeof body.action === "string" ? body.action : "?";
58
+ const r = result as { ok?: unknown; error?: unknown } | null | undefined;
59
+ const failed = !!r && typeof r === "object" && r.ok === false;
60
+ const line =
61
+ `tool.action name=${action} chat=${chat || "-"} ms=${ms} ` +
62
+ `ok=${!failed} bytes=${actionResultBytes(result)}`;
63
+ if (!failed) {
64
+ logDebug("gateway", line);
65
+ return;
66
+ }
67
+ const err = String(r.error ?? "")
68
+ .replace(/\s+/g, " ")
69
+ .slice(0, 200);
70
+ log("gateway", `${line} err=${err}`);
71
+ }
72
+
39
73
  // ── Retry helper (stateless — standalone export) ─────────────────────────────
40
74
 
41
75
  /**
@@ -290,7 +324,31 @@ export class Gateway {
290
324
  return null;
291
325
  }
292
326
 
293
- private async handleAction(body: Record<string, unknown>): Promise<unknown> {
327
+ /**
328
+ * Run one bridge action inside the log scope of the turn that issued it
329
+ * (the MCP subprocess reaches us over HTTP, a fresh async root), so the
330
+ * action's own lines carry `turn=<id>`, and close it with one
331
+ * `tool.action` summary line — debug on success, info with the error
332
+ * text when the action answered `ok:false`.
333
+ */
334
+ private handleAction(body: Record<string, unknown>): Promise<unknown> {
335
+ const rawChatId = body._chatId ? String(body._chatId) : "";
336
+ const numericId = Number(rawChatId);
337
+ const chatKeys = [
338
+ rawChatId,
339
+ rawChatId !== "" && !isNaN(numericId)
340
+ ? this.loom.stringIdForNumeric(numericId)
341
+ : null,
342
+ ];
343
+ return runInChatTurnScope(chatKeys, async () => {
344
+ const t0 = Date.now();
345
+ const result = await this.routeAction(body);
346
+ logActionOutcome(body, rawChatId, Date.now() - t0, result);
347
+ return result;
348
+ });
349
+ }
350
+
351
+ private async routeAction(body: Record<string, unknown>): Promise<unknown> {
294
352
  // Route by _chatId from the MCP subprocess request.
295
353
  // _chatId may be a string (Teams: "teams_chat_19:...") or numeric string
296
354
  // (Telegram: "123456"). The context map is keyed by numeric chatId, so
@@ -492,6 +550,7 @@ export class Gateway {
492
550
  w.msSinceLastMessage < 60000
493
551
  ? "just now"
494
552
  : `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
553
+ alerts: activeAlerts(),
495
554
  };
496
555
  }
497
556