talon-agent 5.0.0 → 5.1.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 (136) hide show
  1. package/package.json +2 -2
  2. package/src/app.ts +3 -3
  3. package/src/backend/claude-sdk/factory.ts +21 -41
  4. package/src/backend/claude-sdk/handler.ts +191 -69
  5. package/src/backend/claude-sdk/host/in-process.ts +147 -0
  6. package/src/backend/claude-sdk/one-shot.ts +17 -2
  7. package/src/backend/claude-sdk/stream.ts +9 -0
  8. package/src/backend/codex/mcp-config.ts +1 -1
  9. package/src/backend/codex/one-shot.ts +18 -4
  10. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  11. package/src/backend/remote-server/one-shot.ts +16 -3
  12. package/src/backend/runtime/index.ts +2 -2
  13. package/src/backend/runtime/one-shot-hooks.ts +45 -0
  14. package/src/backend/runtime/turn/delivery.ts +1 -1
  15. package/src/backend/runtime/turn/turn-phases.ts +1 -1
  16. package/src/bootstrap.ts +2 -2
  17. package/src/cli.ts +5 -2
  18. package/src/core/agent-runtime/README.md +15 -0
  19. package/src/core/agent-runtime/agent-host.ts +388 -0
  20. package/src/core/agent-runtime/capabilities.ts +3 -0
  21. package/src/core/background/cron/scheduler.ts +1 -1
  22. package/src/core/background/triggers/command.ts +1 -1
  23. package/src/{util → core/config}/harden.ts +1 -1
  24. package/src/core/config/index.ts +1 -1
  25. package/src/core/daemon/pidfile.ts +1 -1
  26. package/src/core/daemon/resource-sampler.ts +2 -2
  27. package/src/{util → core/daemon}/respawn.ts +1 -1
  28. package/src/core/doctor/index.ts +1 -1
  29. package/src/core/engine/gateway-actions/fetch-url.ts +1 -1
  30. package/src/core/engine/gateway-actions/index.ts +1 -1
  31. package/src/core/engine/gateway-actions/memory.ts +2 -2
  32. package/src/core/engine/gateway-actions/native/exec-background.ts +129 -0
  33. package/src/core/engine/gateway-actions/native/exec-remote.ts +85 -0
  34. package/src/core/engine/gateway-actions/native/exec.ts +182 -0
  35. package/src/core/engine/gateway-actions/native/index.ts +46 -0
  36. package/src/core/engine/gateway-actions/native/params.ts +46 -0
  37. package/src/core/engine/gateway-actions/native/read.ts +182 -0
  38. package/src/core/engine/gateway-actions/native/results.ts +34 -0
  39. package/src/core/engine/gateway-actions/native/search.ts +212 -0
  40. package/src/core/engine/gateway-actions/native/shell.ts +39 -0
  41. package/src/core/engine/gateway-actions/native/teleport.ts +55 -0
  42. package/src/core/engine/gateway-actions/native/write.ts +170 -0
  43. package/src/core/frontend-runtime/builtins.ts +2 -2
  44. package/src/core/mcp-hub/children.ts +21 -5
  45. package/src/core/mcp-hub/index.ts +1 -1
  46. package/src/core/mcp-hub/talon-server.ts +1 -1
  47. package/src/core/memory/taps.ts +1 -1
  48. package/src/core/plugin/mcp.ts +1 -1
  49. package/src/core/scripts/lua.ts +1 -1
  50. package/src/core/tools/{chat.ts → chat/chat.ts} +1 -1
  51. package/src/core/tools/{cross-send.ts → chat/cross-send.ts} +1 -1
  52. package/src/core/tools/{history.ts → chat/history.ts} +2 -2
  53. package/src/core/tools/{media.ts → chat/media.ts} +1 -1
  54. package/src/core/tools/{members.ts → chat/members.ts} +2 -2
  55. package/src/core/tools/{messaging.ts → chat/messaging.ts} +2 -2
  56. package/src/core/tools/{moderation.ts → chat/moderation.ts} +2 -2
  57. package/src/core/tools/{stickers.ts → chat/stickers.ts} +2 -2
  58. package/src/core/tools/{whatsapp.ts → chat/whatsapp.ts} +1 -1
  59. package/src/core/tools/{memory.ts → content/memory.ts} +2 -2
  60. package/src/core/tools/{web.ts → content/web.ts} +1 -1
  61. package/src/core/tools/index.ts +20 -20
  62. package/src/core/tools/{admin.ts → ops/admin.ts} +1 -1
  63. package/src/core/tools/{bridge.ts → ops/bridge.ts} +2 -2
  64. package/src/core/tools/{goals.ts → ops/goals.ts} +2 -2
  65. package/src/core/tools/{mesh.ts → ops/mesh.ts} +1 -1
  66. package/src/core/tools/{models.ts → ops/models.ts} +1 -1
  67. package/src/core/tools/{native.ts → ops/native.ts} +1 -1
  68. package/src/core/tools/{scheduling.ts → ops/scheduling.ts} +1 -1
  69. package/src/core/tools/{scripts.ts → ops/scripts.ts} +1 -1
  70. package/src/core/tools/{skills.ts → ops/skills.ts} +1 -1
  71. package/src/core/tools/{triggers.ts → ops/triggers.ts} +1 -1
  72. package/src/core/types.ts +11 -0
  73. package/src/{util → core/vfs}/workspace.ts +2 -2
  74. package/src/frontend/discord/callbacks/components/effort.ts +1 -1
  75. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  76. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  77. package/src/frontend/discord/commands/admin.ts +1 -1
  78. package/src/frontend/discord/commands/info.ts +1 -1
  79. package/src/frontend/discord/commands/interaction.ts +1 -1
  80. package/src/frontend/discord/commands/session.ts +3 -3
  81. package/src/frontend/discord/commands/settings.ts +1 -1
  82. package/src/frontend/discord/handlers/messages.ts +1 -1
  83. package/src/frontend/discord/handlers/state.ts +1 -1
  84. package/src/frontend/discord/middleware.ts +1 -1
  85. package/src/frontend/discord/ready.ts +1 -1
  86. package/src/frontend/discord/render.ts +41 -259
  87. package/src/frontend/native/chats/chats.ts +1 -1
  88. package/src/frontend/native/surface/models.ts +1 -1
  89. package/src/frontend/native/turn/context.ts +1 -1
  90. package/src/frontend/native/turn/emit.ts +1 -1
  91. package/src/frontend/{shared → presentation}/format.ts +4 -3
  92. package/src/frontend/{shared → presentation}/reasoning-levels.ts +12 -0
  93. package/src/frontend/presentation/reports.ts +493 -0
  94. package/src/frontend/{shared → presentation}/session-status.ts +1 -1
  95. package/src/frontend/teams/commands.ts +2 -2
  96. package/src/frontend/teams/turn.ts +1 -1
  97. package/src/frontend/telegram/admin/health.ts +1 -1
  98. package/src/frontend/telegram/admin/sessions.ts +1 -1
  99. package/src/frontend/telegram/callbacks/effort.ts +2 -2
  100. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  101. package/src/frontend/telegram/callbacks/model/views.ts +1 -1
  102. package/src/frontend/telegram/callbacks/query.ts +1 -1
  103. package/src/frontend/telegram/callbacks/settings.ts +2 -2
  104. package/src/frontend/telegram/commands/admin.ts +4 -4
  105. package/src/frontend/telegram/commands/info.ts +2 -2
  106. package/src/frontend/telegram/commands/session.ts +4 -4
  107. package/src/frontend/telegram/commands/settings.ts +4 -2
  108. package/src/frontend/telegram/handlers/state.ts +1 -1
  109. package/src/frontend/telegram/model-menu.ts +1 -1
  110. package/src/frontend/telegram/render/html.ts +30 -0
  111. package/src/frontend/telegram/{helpers → render}/menu.ts +46 -19
  112. package/src/frontend/telegram/render/reports.ts +182 -0
  113. package/src/frontend/terminal/builtins/context.ts +2 -2
  114. package/src/frontend/terminal/builtins/session.ts +1 -1
  115. package/src/frontend/terminal/builtins/status.ts +2 -2
  116. package/src/frontend/terminal/index.ts +2 -2
  117. package/src/frontend/terminal/renderer.ts +1 -1
  118. package/src/frontend/whatsapp/commands.ts +5 -5
  119. package/src/frontend/whatsapp/registry.ts +1 -1
  120. package/src/index.ts +5 -2
  121. package/src/util/runtime.ts +1 -1
  122. package/src/core/engine/gateway-actions/native.ts +0 -1035
  123. package/src/frontend/telegram/helpers/diagnostics.ts +0 -428
  124. package/src/frontend/telegram/helpers/format.ts +0 -42
  125. package/src/frontend/telegram/helpers/index.ts +0 -13
  126. package/src/util/cleanup-registry.ts +0 -36
  127. /package/src/{util → core/daemon}/boot-timer.ts +0 -0
  128. /package/src/{util → core/frontend-runtime}/chat-id.ts +0 -0
  129. /package/src/{util/mcp-launcher.ts → core/mcp-hub/launcher.ts} +0 -0
  130. /package/src/{util → core/tools/content}/web-content.ts +0 -0
  131. /package/src/core/tools/{mcp-env.ts → ops/mcp-env.ts} +0 -0
  132. /package/src/{util → core/weaver}/session-name.ts +0 -0
  133. /package/src/frontend/{shared → presentation}/access.ts +0 -0
  134. /package/src/frontend/{shared → presentation}/model-commands.ts +0 -0
  135. /package/src/frontend/{shared → presentation}/plan-usage-report.ts +0 -0
  136. /package/src/frontend/{shared → presentation}/status-context.ts +0 -0
@@ -0,0 +1,493 @@
1
+ /**
2
+ * The five chat reports — metrics, doctor, mesh, usage, settings — in one
3
+ * place, parameterised by the platform's markup dialect.
4
+ *
5
+ * Discord and Telegram carried line-for-line copies of these renderers,
6
+ * differing only in how they spell bold/italic/code, whether text needs
7
+ * HTML escaping, and how much fits in one message. Two copies of a
8
+ * report is two places to fix a bug and two places to drift, which is
9
+ * exactly what happened: the `/metrics` leaderboard cap and the metric
10
+ * label budget landed on Telegram and never reached Discord.
11
+ *
12
+ * The seam is `ReportFormatter`. Everything a platform does differently
13
+ * is one of its fields; everything else is written once here. Discord's
14
+ * `escape` is the identity function, so applying it at every point
15
+ * Telegram escapes leaves Discord's bytes untouched — and Telegram keeps
16
+ * escaping exactly where it always did, which is what stops a model id
17
+ * or a device name containing `<` from 400-ing the whole send.
18
+ *
19
+ * Where the two platforms differ in *content* rather than markup, both
20
+ * behaviours survive: `/metrics` really is two reports (Discord splits
21
+ * across messages, Telegram shrinks into one panel), and they share the
22
+ * section model rather than the output shape.
23
+ */
24
+
25
+ import type { DoctorReport } from "../../core/doctor/index.js";
26
+ import type { MeshPingResult } from "../../core/mesh/service.js";
27
+ import type { BackendUsageEntry } from "./plan-usage-report.js";
28
+ import {
29
+ DEFAULT_PULSE_INTERVAL_MS,
30
+ formatDuration,
31
+ formatBytes,
32
+ formatModelLabel,
33
+ } from "./format.js";
34
+
35
+ /**
36
+ * One platform's report dialect.
37
+ *
38
+ * Every field exists because the two implementations differed on it. Add
39
+ * a field only when a third frontend genuinely differs — a report that
40
+ * needs a new *shape* is a new function here, not a new flag.
41
+ */
42
+ export interface ReportFormatter {
43
+ /** Bold run: `**s**` on Discord, `<b>s</b>` on Telegram. */
44
+ bold(s: string): string;
45
+ /** Italic run: `_s_` on Discord, `<i>s</i>` on Telegram. */
46
+ italic(s: string): string;
47
+ /**
48
+ * The *other* italic spelling: `*s*` on Discord, `<i>s</i>` on
49
+ * Telegram. Discord markdown has two markers for one effect and
50
+ * `/usage` has always used this one for the staleness label; keeping
51
+ * both spellings is what makes that report byte-identical.
52
+ */
53
+ emphasis(s: string): string;
54
+ /** Inline code run: `` `s` `` on Discord, `<code>s</code>` on Telegram. */
55
+ code(s: string): string;
56
+ /**
57
+ * Escape text that is not markup. Telegram parses with `parse_mode:
58
+ * HTML`, so every interpolated value — model ids, device names, metric
59
+ * keys, backend notes — must go through this or the send fails with
60
+ * `can't parse entities`. Discord needs none, and passes the identity
61
+ * function.
62
+ */
63
+ escape(s: string): string;
64
+ /** Characters this platform accepts in one report message. */
65
+ lineLimit: number;
66
+ /** Longest metric key rendered before it is elided (60 vs 80). */
67
+ metricLabelMax: number;
68
+ /** Settings-panel pulse row label — Discord's carries a 🔔, Telegram's doesn't. */
69
+ pulseLabel: string;
70
+ }
71
+
72
+ export type MetricsSnapshot = {
73
+ counters: Record<string, number>;
74
+ histograms: Record<
75
+ string,
76
+ { count: number; avg: number; min: number; max: number }
77
+ >;
78
+ };
79
+
80
+ /**
81
+ * One titled block of a metrics report. `hidden` counts rows dropped to
82
+ * make the report fit; it renders as a trailing "…and N more".
83
+ */
84
+ type Section = { title: string; rows: string[]; hidden: number };
85
+
86
+ function truncateMetricLabel(label: string, max: number): string {
87
+ return label.length <= max ? label : `${label.slice(0, max - 3)}...`;
88
+ }
89
+
90
+ /**
91
+ * Group a snapshot into the report's sections.
92
+ *
93
+ * `toolCallsTopN` pre-caps the `tool_calls` leaderboard: a long-lived
94
+ * bot accumulates a lifetime entry per distinct tool name (easily 100+
95
+ * once plugin and MCP tools are counted) and past the top rows the tail
96
+ * is one-call tools nobody reads. Discord passes `Infinity` — it splits
97
+ * across messages instead and has never capped.
98
+ */
99
+ function buildMetricsSections(
100
+ metrics: MetricsSnapshot,
101
+ fmt: ReportFormatter,
102
+ toolCallsTopN: number,
103
+ ): Section[] {
104
+ const sections: Section[] = [];
105
+
106
+ // Histograms come in two flavours: durations (keys ending in `_ms`,
107
+ // rendered as human times) and plain counts like `tool_calls_per_turn`
108
+ // (rendered as bare numbers — "min=1ms" for a count is nonsense).
109
+ const histKeys = Object.keys(metrics.histograms).sort();
110
+ const durationKeys = histKeys.filter((key) => key.endsWith("_ms"));
111
+ const countKeys = histKeys.filter((key) => !key.endsWith("_ms"));
112
+ const histLine = (key: string, format: (v: number) => string): string => {
113
+ const h = metrics.histograms[key]!;
114
+ return (
115
+ ` ${fmt.code(fmt.escape(truncateMetricLabel(key, fmt.metricLabelMax)))}` +
116
+ ` n=${h.count} avg=${format(h.avg)} min=${format(h.min)} ` +
117
+ `max=${format(h.max)}`
118
+ );
119
+ };
120
+ if (durationKeys.length > 0) {
121
+ sections.push({
122
+ title: fmt.bold("Latency"),
123
+ rows: durationKeys.map((key) => histLine(key, formatDuration)),
124
+ hidden: 0,
125
+ });
126
+ }
127
+ if (countKeys.length > 0) {
128
+ sections.push({
129
+ title: fmt.bold("Distributions"),
130
+ rows: countKeys.map((key) => histLine(key, String)),
131
+ hidden: 0,
132
+ });
133
+ }
134
+
135
+ for (const [prefix, keys] of groupCounters(metrics)) {
136
+ const shown = keys.slice(0, toolCallsTopN);
137
+ sections.push({
138
+ title: fmt.bold(fmt.escape(prefix)),
139
+ rows: shown.map((key) => {
140
+ const label = key.includes(".")
141
+ ? key.split(".").slice(1).join(".")
142
+ : key;
143
+ const name = truncateMetricLabel(label, fmt.metricLabelMax);
144
+ return (
145
+ ` ${fmt.code(fmt.escape(name))} ` +
146
+ `${metrics.counters[key]!.toLocaleString()}`
147
+ );
148
+ }),
149
+ hidden: keys.length - shown.length,
150
+ });
151
+ }
152
+
153
+ return sections;
154
+ }
155
+
156
+ /** Counter keys bucketed by their dotted namespace, groups in name order. */
157
+ function groupCounters(metrics: MetricsSnapshot): Array<[string, string[]]> {
158
+ const groups = new Map<string, string[]>();
159
+ for (const key of Object.keys(metrics.counters).sort()) {
160
+ const prefix = key.includes(".") ? key.split(".")[0]! : "general";
161
+ if (!groups.has(prefix)) groups.set(prefix, []);
162
+ groups.get(prefix)!.push(key);
163
+ }
164
+ return [...groups.keys()].sort().map((prefix): [string, string[]] => {
165
+ // tool_calls reads best as a leaderboard — busiest tools first.
166
+ // Other groups keep alphabetical order (stable lookup by name).
167
+ const keys = groups.get(prefix)!;
168
+ return [
169
+ prefix,
170
+ prefix === "tool_calls"
171
+ ? [...keys].sort(
172
+ (a, b) =>
173
+ metrics.counters[b]! - metrics.counters[a]! || a.localeCompare(b),
174
+ )
175
+ : keys,
176
+ ];
177
+ });
178
+ }
179
+
180
+ /** A section as lines: title, rows, and the elision note when rows were dropped. */
181
+ function sectionLines(section: Section, fmt: ReportFormatter): string[] {
182
+ return [
183
+ section.title,
184
+ ...section.rows,
185
+ ...(section.hidden > 0
186
+ ? [` ${fmt.italic(`…and ${section.hidden} more`)}`]
187
+ : []),
188
+ ];
189
+ }
190
+
191
+ /**
192
+ * Render the metrics report across as many messages as it takes, each
193
+ * ≤ `maxLen`, continuation messages re-stating the title. Discord's
194
+ * shape: nothing is dropped, the report just gets longer.
195
+ */
196
+ export function renderMetricsMessages(
197
+ metrics: MetricsSnapshot,
198
+ fmt: ReportFormatter,
199
+ maxLen = fmt.lineLimit,
200
+ title = "📊 Metrics",
201
+ ): string[] {
202
+ const firstHeader = fmt.bold(title);
203
+ const continuationHeader = fmt.bold(`${title} (cont.)`);
204
+ const sections = buildMetricsSections(metrics, fmt, Infinity);
205
+ if (sections.length === 0) {
206
+ return [`${firstHeader}\n\n${fmt.italic("No metrics recorded yet.")}`];
207
+ }
208
+
209
+ const chunks: string[] = [];
210
+ let header = firstHeader;
211
+ let current = header;
212
+ const flush = () => {
213
+ chunks.push(current);
214
+ header = continuationHeader;
215
+ current = header;
216
+ };
217
+ const appendLine = (line: string) => {
218
+ if (!line && current === header) return;
219
+ const candidate = `${current}\n${line}`;
220
+ if (candidate.length <= maxLen) {
221
+ current = candidate;
222
+ return;
223
+ }
224
+ if (current !== header) {
225
+ flush();
226
+ if (!line) return;
227
+ }
228
+ const available = maxLen - header.length - 1;
229
+ if (available < 0) return;
230
+ const safeLine =
231
+ line.length <= available
232
+ ? line
233
+ : available >= 4
234
+ ? `${line.slice(0, available - 3)}...`
235
+ : line.slice(0, available);
236
+ current = `${current}\n${safeLine}`;
237
+ };
238
+
239
+ for (const section of sections) {
240
+ appendLine("");
241
+ for (const line of sectionLines(section, fmt)) appendLine(line);
242
+ }
243
+ if (current !== header || chunks.length === 0) chunks.push(current);
244
+ return chunks;
245
+ }
246
+
247
+ /**
248
+ * Render the metrics report as a SINGLE message, shrinking to fit.
249
+ *
250
+ * Telegram's shape: rather than split the report across messages — which
251
+ * is what the tappable panel replaced — rows are dropped from the longest
252
+ * section first (each drop bumping that section's "…and N more") until
253
+ * the whole thing fits. Sections always keep at least their first row, so
254
+ * every group stays visible.
255
+ */
256
+ export function renderMetricsPanel(
257
+ metrics: MetricsSnapshot,
258
+ fmt: ReportFormatter,
259
+ title: string,
260
+ maxLen = fmt.lineLimit,
261
+ toolCallsTopN = Infinity,
262
+ ): string {
263
+ const header = fmt.bold(fmt.escape(title));
264
+ const sections = buildMetricsSections(metrics, fmt, toolCallsTopN);
265
+ if (sections.length === 0) {
266
+ return `${header}\n\n${fmt.italic("No metrics recorded yet.")}`;
267
+ }
268
+
269
+ const render = () =>
270
+ [header, ...sections.map((s) => sectionLines(s, fmt).join("\n"))].join(
271
+ "\n\n",
272
+ );
273
+
274
+ let out = render();
275
+ while (out.length > maxLen) {
276
+ let target: Section | undefined;
277
+ for (const section of sections) {
278
+ if (section.rows.length <= 1) continue;
279
+ if (!target || section.rows.length > target.rows.length) target = section;
280
+ }
281
+ if (!target) break;
282
+ target.rows.pop();
283
+ target.hidden += 1;
284
+ out = render();
285
+ }
286
+
287
+ if (out.length <= maxLen) return out;
288
+ // Nothing left to shed (a pathologically small maxLen). Cut on a line
289
+ // boundary so we never slice through an HTML tag and fail the parse.
290
+ const cut = out.lastIndexOf("\n", maxLen - 1);
291
+ return cut > 0 ? out.slice(0, cut) : out.slice(0, maxLen);
292
+ }
293
+
294
+ const DOCTOR_ICONS: Record<string, string> = {
295
+ ok: "✅",
296
+ warn: "⚠️",
297
+ fail: "❌",
298
+ info: "▫️",
299
+ };
300
+
301
+ /**
302
+ * Render a DoctorReport as one message body.
303
+ *
304
+ * Same data as `talon doctor` (src/core/doctor/) plus in-process runtime
305
+ * info — when this renders, the bot is by definition running, so the
306
+ * CLI's "is the bot up" probe becomes an uptime line instead. Callers
307
+ * that have a message cap split the result themselves.
308
+ */
309
+ export function renderDoctorReport(
310
+ report: DoctorReport,
311
+ fmt: ReportFormatter,
312
+ ): string {
313
+ const lines = [fmt.bold("🩺 Talon Doctor"), "", fmt.bold("Environment")];
314
+
315
+ const render = (check: DoctorReport["checks"][number]): string => {
316
+ const detail = check.detail ? ` (${fmt.escape(check.detail)})` : "";
317
+ return `${DOCTOR_ICONS[check.status]} ${fmt.escape(check.label)}${detail}`;
318
+ };
319
+
320
+ for (const check of report.checks.filter((c) => !c.inactive)) {
321
+ lines.push(render(check));
322
+ }
323
+
324
+ // Configured-but-idle backends get their own block: they describe what a
325
+ // switch would run into, not the state of the running deployment.
326
+ const idle = report.checks.filter((c) => c.inactive);
327
+ if (idle.length > 0) {
328
+ lines.push("", fmt.bold("Other backends"), ...idle.map(render));
329
+ }
330
+
331
+ lines.push("", fmt.bold("Native modules"));
332
+ for (const mod of report.native) {
333
+ const size =
334
+ mod.sizeBytes !== undefined ? ` · ${formatBytes(mod.sizeBytes)}` : "";
335
+ const note = mod.note ? ` (${fmt.escape(mod.note)})` : "";
336
+ lines.push(
337
+ `${mod.ok ? DOCTOR_ICONS.ok : DOCTOR_ICONS.fail} ` +
338
+ `${fmt.code(fmt.escape(mod.name))} — ${fmt.escape(mod.language)} → ` +
339
+ `${fmt.escape(mod.target)}${size}${note}`,
340
+ );
341
+ }
342
+
343
+ lines.push(
344
+ "",
345
+ fmt.bold("Process"),
346
+ `Uptime ${formatDuration(process.uptime() * 1000)} · PID ${process.pid} · ` +
347
+ `Node ${fmt.escape(process.versions.node)}`,
348
+ "",
349
+ report.issues === 0
350
+ ? `${DOCTOR_ICONS.ok} All checks passed.`
351
+ : `${DOCTOR_ICONS.warn} ${report.issues} issue(s) found.`,
352
+ );
353
+
354
+ return lines.join("\n");
355
+ }
356
+
357
+ function meshDeviceLine(
358
+ r: MeshPingResult,
359
+ fmt: ReportFormatter,
360
+ now: number,
361
+ ): string {
362
+ const d = r.device;
363
+ const bits: string[] = [fmt.escape(d.platform)];
364
+ if (r.reachable && typeof r.latencyMs === "number") {
365
+ bits.push(`${r.latencyMs} ms`);
366
+ } else if (d.online && r.error) {
367
+ bits.push(fmt.escape(r.error));
368
+ } else if (!d.online) {
369
+ bits.push(`last seen ${formatDuration(now - d.lastSeen)} ago`);
370
+ }
371
+ if (typeof d.battery === "number") {
372
+ bits.push(`${d.battery}%${d.charging ? " charging" : ""}`);
373
+ }
374
+ return ` ${fmt.bold(fmt.escape(d.name))} — ${bits.join(" · ")}`;
375
+ }
376
+
377
+ /**
378
+ * Render the `/mesh` fleet report.
379
+ *
380
+ * Devices group under a state heading — Responding, Unreachable, Offline
381
+ * — rather than carrying a coloured status glyph per row: the grouping
382
+ * already says what the glyph said, and the report stays readable when
383
+ * the fleet grows. Empty groups are omitted entirely. `footer` is the
384
+ * platform's own trailer (Telegram appends the bridge block; Discord
385
+ * appends nothing).
386
+ */
387
+ export function renderMeshReport(
388
+ results: MeshPingResult[],
389
+ fmt: ReportFormatter,
390
+ now = Date.now(),
391
+ footer: string[] = [],
392
+ ): string {
393
+ const heading = fmt.bold("Mesh");
394
+ if (results.length === 0) {
395
+ return [
396
+ heading,
397
+ "",
398
+ fmt.italic("No devices have registered yet."),
399
+ ...footer,
400
+ ].join("\n");
401
+ }
402
+
403
+ const responding = results
404
+ .filter((r) => r.reachable)
405
+ .sort((a, b) => (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity));
406
+ const unreachable = results
407
+ .filter((r) => !r.reachable && r.device.online)
408
+ .sort((a, b) => a.device.name.localeCompare(b.device.name));
409
+ const offline = results
410
+ .filter((r) => !r.reachable && !r.device.online)
411
+ .sort((a, b) => b.device.lastSeen - a.device.lastSeen);
412
+
413
+ const summary = [
414
+ `${results.length} device${results.length === 1 ? "" : "s"}`,
415
+ `${responding.length} responding`,
416
+ ...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
417
+ ...(offline.length > 0 ? [`${offline.length} offline`] : []),
418
+ ].join(" · ");
419
+
420
+ const lines = [heading, summary];
421
+ const section = (title: string, entries: MeshPingResult[]): void => {
422
+ if (entries.length === 0) return;
423
+ lines.push(
424
+ "",
425
+ fmt.bold(title),
426
+ ...entries.map((r) => meshDeviceLine(r, fmt, now)),
427
+ );
428
+ };
429
+ section("Responding", responding);
430
+ section("Unreachable", unreachable);
431
+ section("Offline", offline);
432
+ lines.push(...footer);
433
+
434
+ return lines.join("\n");
435
+ }
436
+
437
+ /** Render the `/usage` report — one block per exposed backend. */
438
+ export function renderUsageMessage(
439
+ entries: BackendUsageEntry[],
440
+ fmt: ReportFormatter,
441
+ ): string {
442
+ const lines = [fmt.bold("📊 Plan usage")];
443
+
444
+ for (const entry of entries) {
445
+ const name = fmt.escape(entry.label || entry.id);
446
+ if (!entry.plan) {
447
+ lines.push(
448
+ "",
449
+ `${fmt.bold(name)} — ${fmt.italic(fmt.escape(entry.note ?? ""))}`,
450
+ );
451
+ continue;
452
+ }
453
+ const age = entry.plan.ageLabel
454
+ ? ` ${fmt.emphasis(`(${entry.plan.ageLabel})`)}`
455
+ : "";
456
+ const plan = entry.plan.plan ? ` · ${fmt.escape(entry.plan.plan)}` : "";
457
+ lines.push("", `${fmt.bold(name)}${plan}${age}`);
458
+ if (entry.plan.resetsAvailable) {
459
+ const n = entry.plan.resetsAvailable;
460
+ const resets = `usage limit reset${n === 1 ? "" : "s"} available`;
461
+ lines.push(` • You have ${fmt.bold(String(n))} ${resets}`);
462
+ }
463
+ for (const w of entry.plan.windows) {
464
+ const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
465
+ const bar = `${fmt.escape(w.label.padEnd(6))}${w.bar} ${String(w.percent).padStart(3)}%`;
466
+ lines.push(` ${fmt.code(bar)}${reset}`);
467
+ }
468
+ }
469
+
470
+ return lines.join("\n");
471
+ }
472
+
473
+ /** Settings panel body — model, effort, and the pulse row. */
474
+ export function renderSettingsText(
475
+ fmt: ReportFormatter,
476
+ model: string,
477
+ effort: string,
478
+ proactive: boolean,
479
+ pulseIntervalMs?: number,
480
+ modelDetails?: Array<string>,
481
+ ): string {
482
+ const intervalStr = formatDuration(
483
+ pulseIntervalMs || DEFAULT_PULSE_INTERVAL_MS,
484
+ );
485
+ return [
486
+ fmt.bold("🦅 Settings"),
487
+ "",
488
+ `${fmt.bold("Model:")} ${fmt.code(fmt.escape(formatModelLabel(model)))}`,
489
+ ...(modelDetails?.length ? modelDetails.map((d) => fmt.escape(d)) : []),
490
+ `${fmt.bold("Effort:")} ${effort}`,
491
+ `${fmt.bold(fmt.pulseLabel)} ${proactive ? "on" : "off"} (every ${intervalStr})`,
492
+ ].join("\n");
493
+ }
@@ -20,7 +20,7 @@ import { clearHistory } from "../../storage/history.js";
20
20
  import { getChatSettings } from "../../storage/chat-settings.js";
21
21
  import { resetPulseCheckpoint } from "../../core/background/pulse/pulse.js";
22
22
  import { isPulseEnabled } from "../../core/background/pulse/pulse.js";
23
- import { getWorkspaceDiskUsage } from "../../util/workspace.js";
23
+ import { getWorkspaceDiskUsage } from "../../core/vfs/workspace.js";
24
24
  import { appendDailyLog } from "../../storage/daily-log.js";
25
25
  import { resolveActiveModelForChat } from "../../core/models/active-model.js";
26
26
  import { getPooledBackend } from "../../core/engine/backend-controller/index.js";
@@ -9,8 +9,8 @@ import { resolveModel } from "../../core/models/catalog.js";
9
9
  import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
10
10
  import { getSessionInfo } from "../../storage/sessions.js";
11
11
  import { getChatSettings } from "../../storage/chat-settings.js";
12
- import { performSessionReset } from "../shared/session-status.js";
13
- import { buildCacheDisplay } from "../shared/status-context.js";
12
+ import { performSessionReset } from "../presentation/session-status.js";
13
+ import { buildCacheDisplay } from "../presentation/status-context.js";
14
14
  import { buildAdaptiveCard } from "./formatting.js";
15
15
  import type { ChatMessage } from "./graph.js";
16
16
  import { postCard } from "./outbound.js";
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { log, logError } from "../../util/log.js";
7
- import { deriveNumericChatId } from "../../util/chat-id.js";
7
+ import { deriveNumericChatId } from "../../core/frontend-runtime/chat-id.js";
8
8
  import {
9
9
  toolInputToRecord,
10
10
  type AgentEvent,
@@ -12,7 +12,7 @@ import { getAllSessions } from "../../../storage/sessions.js";
12
12
  import { todayLogDate } from "../../../storage/daily-log.js";
13
13
  import { getActiveCount } from "../../../core/engine/dispatcher.js";
14
14
  import { getHealthStatus, getRecentErrors } from "../../../util/watchdog.js";
15
- import { formatDuration } from "../helpers/index.js";
15
+ import { formatDuration } from "../../presentation/format.js";
16
16
 
17
17
  export async function replyStats(ctx: Context): Promise<void> {
18
18
  const h = getHealthStatus();
@@ -8,7 +8,7 @@ import { escapeHtml } from "../formatting.js";
8
8
  import { resetSession, getAllSessions } from "../../../storage/sessions.js";
9
9
  import { clearHistory } from "../../../storage/history.js";
10
10
  import { getChatSettings } from "../../../storage/chat-settings.js";
11
- import { formatModelLabel } from "../helpers/index.js";
11
+ import { formatModelLabel } from "../../presentation/format.js";
12
12
  import { replyHtmlChunked } from "./chunked-reply.js";
13
13
 
14
14
  /** `/admin chats` — every active session, newest first, titled via getChat. */
@@ -9,13 +9,13 @@ import {
9
9
  type EffortLevel,
10
10
  } from "../../../storage/chat-settings.js";
11
11
  import { getBackendIdForChat } from "../../../core/engine/backend-controller/index.js";
12
- import { renderEffortRows } from "../helpers/index.js";
12
+ import { renderEffortRows } from "../render/menu.js";
13
13
  import { resolveBackendForChat } from "../model-menu.js";
14
14
  import {
15
15
  displayReasoningEffort,
16
16
  getActiveReasoningLevels,
17
17
  supportsReasoningLevel,
18
- } from "../../shared/reasoning-levels.js";
18
+ } from "../../presentation/reasoning-levels.js";
19
19
  import { answerCallbackQuerySafe, type CallbackDeps } from "./query.js";
20
20
 
21
21
  export async function handleEffortCallback(
@@ -12,7 +12,7 @@ import {
12
12
  renderMetricsKeyboard,
13
13
  renderMetricsPanel,
14
14
  type MetricsView,
15
- } from "../helpers/index.js";
15
+ } from "../render/reports.js";
16
16
  import { isAuthorizedAdmin } from "../commands/state.js";
17
17
  import { answerCallbackQuerySafe, editOrIgnoreSame } from "./query.js";
18
18
 
@@ -11,7 +11,7 @@ import {
11
11
  renderModelBrowseKeyboard,
12
12
  renderBackendMenuKeyboard,
13
13
  renderBackendMenuText,
14
- } from "../../helpers/index.js";
14
+ } from "../../render/menu.js";
15
15
  import {
16
16
  buildModelMenuViewForChat,
17
17
  buildModelBrowseViewForChat,
@@ -7,7 +7,7 @@ import type { Context } from "grammy";
7
7
  import type { TalonConfig } from "../../../core/config/index.js";
8
8
  import type { Backend } from "../../../core/agent-runtime/capabilities.js";
9
9
  import { logWarn } from "../../../util/log.js";
10
- import type { SettingsButton } from "../helpers/index.js";
10
+ import type { SettingsButton } from "../render/menu.js";
11
11
 
12
12
  export type CallbackDeps = {
13
13
  config: TalonConfig;
@@ -20,14 +20,14 @@ import {
20
20
  renderSettingsText,
21
21
  renderSettingsKeyboard,
22
22
  type SettingsButton,
23
- } from "../helpers/index.js";
23
+ } from "../render/menu.js";
24
24
  import { resolveBackendForChat } from "../model-menu.js";
25
25
  import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
26
26
  import {
27
27
  displayReasoningEffort,
28
28
  getActiveReasoningLevels,
29
29
  supportsReasoningLevel,
30
- } from "../../shared/reasoning-levels.js";
30
+ } from "../../presentation/reasoning-levels.js";
31
31
  import { answerCallbackQuerySafe, type CallbackDeps } from "./query.js";
32
32
 
33
33
  export async function handleSettingsCallback(
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { Bot } from "grammy";
10
10
  import type { TalonConfig } from "../../../core/config/index.js";
11
- import { respawnSelf } from "../../../util/respawn.js";
11
+ import { respawnSelf } from "../../../core/daemon/respawn.js";
12
12
  import { isStaleCommand } from "../stale-command.js";
13
13
  import {
14
14
  getRepoRoot,
@@ -17,14 +17,14 @@ import {
17
17
  import { forceDream } from "../../../core/background/dream/index.js";
18
18
  import { escapeHtml } from "../formatting.js";
19
19
  import { closestMatch } from "../../../native/strsim.js";
20
+ import { formatDuration } from "../../presentation/format.js";
20
21
  import {
21
- formatDuration,
22
22
  renderDoctorMessage,
23
23
  renderMetricsKeyboard,
24
24
  renderMetricsPanel,
25
25
  renderUsageMessage,
26
- } from "../helpers/index.js";
27
- import { collectPlanUsage } from "../../shared/plan-usage-report.js";
26
+ } from "../render/reports.js";
27
+ import { collectPlanUsage } from "../../presentation/plan-usage-report.js";
28
28
  import { collectDoctorReport } from "../../../core/doctor/index.js";
29
29
  import { handleAdminCommand } from "../admin.js";
30
30
  import { getTodayMetrics } from "../../../storage/metrics.js";
@@ -5,12 +5,12 @@
5
5
  import type { Bot, Context } from "grammy";
6
6
  import { isUserClientReady } from "../userbot.js";
7
7
  import { escapeHtml } from "../formatting.js";
8
+ import { formatDuration } from "../../presentation/format.js";
8
9
  import {
9
- formatDuration,
10
10
  renderMeshPairLink,
11
11
  renderMeshReport,
12
12
  type MeshReachability,
13
- } from "../helpers/index.js";
13
+ } from "../render/reports.js";
14
14
  import { isAuthorizedAdmin } from "./state.js";
15
15
  import { getLoadedPlugins } from "../../../core/plugin/index.js";
16
16
  import { getMeshService } from "../../../core/mesh/index.js";