@nordbyte/nordrelay 0.4.0 → 0.5.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.
@@ -0,0 +1,838 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { CODEX_AGENT_CAPABILITIES, agentLabel } from "./agent.js";
3
+ import { getAgentDiagnostics } from "./agent-activity.js";
4
+ import { enabledAgents } from "./agent-factory.js";
5
+ import { isTelegramImagePreview } from "./artifacts.js";
6
+ import { friendlyErrorText } from "./error-messages.js";
7
+ import { escapeHTML } from "./format.js";
8
+ const TOOL_OUTPUT_PREVIEW_LIMIT = 500;
9
+ const STREAMING_PREVIEW_LIMIT = 3800;
10
+ export function renderVersionCheckPlain(check) {
11
+ const icon = versionStatusIcon(check);
12
+ const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
13
+ return `${label}: ${icon} ${formatVersionCheckDetailPlain(check)}`;
14
+ }
15
+ export function renderVersionCheckHTML(check) {
16
+ const icon = versionStatusIcon(check);
17
+ const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
18
+ return `<b>${escapeHTML(label)}:</b> ${icon} ${formatVersionCheckDetailHTML(check)}`;
19
+ }
20
+ export function formatCliPathPlain(label, cliPath, fallback) {
21
+ return cliPath ? `${label} path: ${cliPath}` : `${label}: ${fallback}`;
22
+ }
23
+ export function formatCliPathHTML(label, cliPath, fallback) {
24
+ return cliPath
25
+ ? `<b>${escapeHTML(label)} path:</b> <code>${escapeHTML(cliPath)}</code>`
26
+ : `<b>${escapeHTML(label)}:</b> <code>${escapeHTML(fallback)}</code>`;
27
+ }
28
+ export function formatVersionCheckDetailPlain(check) {
29
+ if (check.status === "not-installed") {
30
+ return "not installed";
31
+ }
32
+ if (check.status === "outdated") {
33
+ return `${check.installedLabel} (latest ${check.latestVersion ?? "unknown"})`;
34
+ }
35
+ if (check.status === "current") {
36
+ return `${check.installedLabel} (latest)`;
37
+ }
38
+ return `${check.installedLabel} (latest unknown${check.detail ? `: ${check.detail}` : ""})`;
39
+ }
40
+ export function formatVersionCheckDetailHTML(check) {
41
+ if (check.status === "not-installed") {
42
+ return "<code>not installed</code>";
43
+ }
44
+ if (check.status === "outdated") {
45
+ return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest ${escapeHTML(check.latestVersion ?? "unknown")})</i>`;
46
+ }
47
+ if (check.status === "current") {
48
+ return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest)</i>`;
49
+ }
50
+ return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest unknown${check.detail ? `: ${escapeHTML(check.detail)}` : ""})</i>`;
51
+ }
52
+ export function versionStatusIcon(check) {
53
+ return check.status === "current" ? "✅" : "⚠️";
54
+ }
55
+ export function renderAuditEvents(events) {
56
+ if (events.length === 0) {
57
+ return {
58
+ plain: "Audit log is empty.",
59
+ html: escapeHTML("Audit log is empty."),
60
+ };
61
+ }
62
+ const lines = events.map((event) => {
63
+ const time = formatLocalDateTime(new Date(event.timestamp));
64
+ const actor = event.actorId ? `user ${event.actorId}` : "system";
65
+ const prompt = event.promptId ? ` · ${event.promptId}` : "";
66
+ const detail = event.detail ? ` · ${trimLine(event.detail, 90)}` : "";
67
+ const description = event.description ? ` · ${trimLine(event.description, 90)}` : "";
68
+ return `${time} · ${event.status.toUpperCase()} · ${event.action} · ${actor}${prompt}${description}${detail}`;
69
+ });
70
+ return {
71
+ plain: ["Audit:", ...lines].join("\n"),
72
+ html: [
73
+ "<b>Audit:</b>",
74
+ ...lines.map((line) => escapeHTML(line)),
75
+ ].join("\n"),
76
+ };
77
+ }
78
+ export function renderSessionLocks(locks) {
79
+ if (locks.length === 0) {
80
+ return {
81
+ plain: "No active session locks.",
82
+ html: escapeHTML("No active session locks."),
83
+ };
84
+ }
85
+ const lines = locks.map((lock) => {
86
+ const expires = lock.expiresAt ? ` · expires ${formatLocalDateTime(new Date(lock.expiresAt))}` : "";
87
+ return `${lock.contextKey} · ${formatLockOwner(lock)}${expires}`;
88
+ });
89
+ return {
90
+ plain: ["Session locks:", ...lines].join("\n"),
91
+ html: ["<b>Session locks:</b>", ...lines.map((line) => escapeHTML(line))].join("\n"),
92
+ };
93
+ }
94
+ export function formatLockOwner(lock) {
95
+ if (!lock) {
96
+ return "nobody";
97
+ }
98
+ return lock.ownerName ? `${lock.ownerName} (${lock.ownerId})` : `user ${lock.ownerId}`;
99
+ }
100
+ export function formatTelegramName(ctx) {
101
+ const firstName = ctx.from?.first_name?.trim();
102
+ const lastName = ctx.from?.last_name?.trim();
103
+ const username = ctx.from?.username?.trim();
104
+ const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
105
+ return fullName || (username ? `@${username}` : undefined);
106
+ }
107
+ export function formatLocalDateTime(date) {
108
+ if (Number.isNaN(date.getTime())) {
109
+ return "-";
110
+ }
111
+ return [
112
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
113
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
114
+ ].join(" ");
115
+ }
116
+ export function pad2(value) {
117
+ return String(value).padStart(2, "0");
118
+ }
119
+ export function buildArtifactActionsKeyboard(reports) {
120
+ const keyboard = new InlineKeyboard();
121
+ for (const [index, report] of reports.slice(0, 5).entries()) {
122
+ const label = `${index + 1}`;
123
+ keyboard
124
+ .text(`${label} Send`, `artifact_send:${report.turnId}`)
125
+ .text(`${label} ZIP`, `artifact_zip:${report.turnId}`)
126
+ .text(`${label} Delete`, `artifact_delete:${report.turnId}`)
127
+ .row();
128
+ }
129
+ return keyboard;
130
+ }
131
+ export function filterArtifactReports(reports, argument) {
132
+ const normalized = argument.trim().toLowerCase();
133
+ if (!normalized) {
134
+ return null;
135
+ }
136
+ let predicate = null;
137
+ if (normalized === "images" || normalized === "image" || normalized === "photos") {
138
+ predicate = (artifact) => isTelegramImagePreview(artifact);
139
+ }
140
+ else if (normalized === "docs" || normalized === "documents" || normalized === "files") {
141
+ predicate = (artifact) => !isTelegramImagePreview(artifact);
142
+ }
143
+ else if (normalized.startsWith("search ")) {
144
+ const query = normalized.slice("search ".length).trim();
145
+ if (!query) {
146
+ return [];
147
+ }
148
+ predicate = (artifact) => artifact.name.toLowerCase().includes(query);
149
+ }
150
+ if (!predicate) {
151
+ return null;
152
+ }
153
+ return reports
154
+ .map((report) => ({
155
+ ...report,
156
+ artifacts: report.artifacts.filter(predicate),
157
+ }))
158
+ .filter((report) => report.artifacts.length > 0);
159
+ }
160
+ export function renderProgressPlain(progress, queueLength, busyState, info) {
161
+ const busyFlags = formatBusyFlags(busyState);
162
+ if (!progress) {
163
+ return [
164
+ "Progress:",
165
+ "Status: idle",
166
+ `Thread: ${info.threadId ?? "(not started yet)"}`,
167
+ `Queue: ${queueLength}`,
168
+ `Busy: ${busyFlags || "no"}`,
169
+ ].join("\n");
170
+ }
171
+ const lines = [
172
+ "Progress:",
173
+ `Status: ${progress.status}`,
174
+ `Prompt: ${progress.promptDescription}`,
175
+ `Elapsed: ${formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000)}`,
176
+ `Current tool: ${progress.currentTool ?? "-"}`,
177
+ `Last tool: ${progress.lastTool ?? "-"}`,
178
+ `Tools: ${formatToolSummaryLine(progress.toolCounts) || "-"}`,
179
+ `Output chars: ${progress.textCharacters}`,
180
+ `Queue: ${queueLength}`,
181
+ `Busy: ${busyFlags || "no"}`,
182
+ ];
183
+ if (progress.error) {
184
+ lines.push(`Error: ${progress.error}`);
185
+ }
186
+ return lines.join("\n");
187
+ }
188
+ export function renderProgressHTML(progress, queueLength, busyState, info) {
189
+ const busyFlags = formatBusyFlags(busyState);
190
+ if (!progress) {
191
+ return [
192
+ "<b>Progress:</b>",
193
+ "<b>Status:</b> <code>idle</code>",
194
+ `<b>Thread:</b> <code>${escapeHTML(info.threadId ?? "(not started yet)")}</code>`,
195
+ `<b>Queue:</b> <code>${queueLength}</code>`,
196
+ `<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
197
+ ].join("\n");
198
+ }
199
+ const lines = [
200
+ "<b>Progress:</b>",
201
+ `<b>Status:</b> <code>${escapeHTML(progress.status)}</code>`,
202
+ `<b>Prompt:</b> <code>${escapeHTML(progress.promptDescription)}</code>`,
203
+ `<b>Elapsed:</b> <code>${escapeHTML(formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000))}</code>`,
204
+ `<b>Current tool:</b> <code>${escapeHTML(progress.currentTool ?? "-")}</code>`,
205
+ `<b>Last tool:</b> <code>${escapeHTML(progress.lastTool ?? "-")}</code>`,
206
+ `<b>Tools:</b> <code>${escapeHTML(formatToolSummaryLine(progress.toolCounts) || "-")}</code>`,
207
+ `<b>Output chars:</b> <code>${progress.textCharacters}</code>`,
208
+ `<b>Queue:</b> <code>${queueLength}</code>`,
209
+ `<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
210
+ ];
211
+ if (progress.error) {
212
+ lines.push(`<b>Error:</b> <code>${escapeHTML(progress.error)}</code>`);
213
+ }
214
+ return lines.join("\n");
215
+ }
216
+ export function renderExternalMirrorStatus(snapshot, queueLength) {
217
+ const prompt = trimLine(snapshot.latestUserMessage ?? "-", 180);
218
+ const elapsed = snapshot.activity.startedAt
219
+ ? formatDurationSeconds((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
220
+ : "-";
221
+ const lines = [
222
+ `${snapshot.agentLabel} CLI task running.`,
223
+ `Thread: ${snapshot.threadId}`,
224
+ `Elapsed: ${elapsed}`,
225
+ `Prompt: ${prompt}`,
226
+ `Last tool: ${snapshot.latestToolName ?? "-"}`,
227
+ `Queue: ${queueLength}`,
228
+ ];
229
+ return {
230
+ plain: lines.join("\n"),
231
+ html: [
232
+ `<b>${escapeHTML(snapshot.agentLabel)} CLI task running.</b>`,
233
+ `<b>Thread:</b> <code>${escapeHTML(snapshot.threadId)}</code>`,
234
+ `<b>Elapsed:</b> <code>${escapeHTML(elapsed)}</code>`,
235
+ `<b>Prompt:</b> <code>${escapeHTML(prompt)}</code>`,
236
+ `<b>Last tool:</b> <code>${escapeHTML(snapshot.latestToolName ?? "-")}</code>`,
237
+ `<b>Queue:</b> <code>${queueLength}</code>`,
238
+ ].join("\n"),
239
+ };
240
+ }
241
+ export function renderExternalMirrorEvent(event) {
242
+ if (event.kind === "task") {
243
+ const status = event.status ?? event.type;
244
+ const plain = `CLI task: ${status}`;
245
+ return {
246
+ plain,
247
+ html: `<b>CLI task:</b> <code>${escapeHTML(status)}</code>`,
248
+ };
249
+ }
250
+ if (event.kind !== "tool") {
251
+ return null;
252
+ }
253
+ const status = event.status ?? event.type;
254
+ const tool = event.toolName ?? "tool";
255
+ const detail = event.text ? `\n${trimLine(event.text.replace(/\s+/g, " "), 180)}` : "";
256
+ const plain = `CLI tool ${status}: ${tool}${detail}`;
257
+ return {
258
+ plain,
259
+ html: `<b>CLI tool ${escapeHTML(status)}:</b> <code>${escapeHTML(tool)}</code>${detail ? `\n<code>${escapeHTML(detail.trim())}</code>` : ""}`,
260
+ };
261
+ }
262
+ export function renderActivityTimeline(threadId, events, options = { limit: 16, filter: "all", exportFile: false }) {
263
+ if (events.length === 0) {
264
+ return {
265
+ plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo activity events found.`,
266
+ html: `<b>Activity:</b>\n<b>Thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>\n<code>No activity events found.</code>`,
267
+ };
268
+ }
269
+ const lines = events.map((event) => {
270
+ const time = event.timestamp ? event.timestamp.toISOString().slice(11, 19) : "--:--:--";
271
+ const label = activityEventLabel(event);
272
+ const detail = event.text ? ` · ${trimLine(event.text.replace(/\s+/g, " ").trim(), 120)}` : "";
273
+ const tool = event.toolName ? ` · ${event.toolName}` : "";
274
+ return `${time} · ${label}${tool}${detail}`;
275
+ });
276
+ return {
277
+ plain: ["Activity:", `Thread: ${threadId}`, `Filter: ${options.filter}`, `Events: ${events.length}`, ...lines].join("\n"),
278
+ html: [
279
+ "<b>Activity:</b>",
280
+ `<b>Thread:</b> <code>${escapeHTML(threadId)}</code>`,
281
+ `<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>`,
282
+ `<b>Events:</b> <code>${events.length}</code>`,
283
+ ...lines.map((line) => `<code>${escapeHTML(line)}</code>`),
284
+ ].join("\n"),
285
+ };
286
+ }
287
+ export function parseActivityOptions(argument) {
288
+ const options = {
289
+ limit: 16,
290
+ filter: "all",
291
+ exportFile: false,
292
+ };
293
+ const parts = argument.split(/\s+/).filter(Boolean);
294
+ for (let index = 0; index < parts.length; index += 1) {
295
+ const part = parts[index].toLowerCase();
296
+ if (/^\d+$/.test(part)) {
297
+ options.limit = Math.min(200, Math.max(1, Number(part)));
298
+ continue;
299
+ }
300
+ if (part === "export") {
301
+ options.exportFile = true;
302
+ continue;
303
+ }
304
+ if (isActivityFilter(part)) {
305
+ options.filter = part;
306
+ continue;
307
+ }
308
+ if (part === "since" && parts[index + 1]) {
309
+ options.sinceMs = parseDurationToMs(parts[index + 1]);
310
+ index += 1;
311
+ }
312
+ }
313
+ return options;
314
+ }
315
+ export function filterActivityEvents(events, options) {
316
+ const cutoff = options.sinceMs ? Date.now() - options.sinceMs : undefined;
317
+ return events
318
+ .filter((event) => {
319
+ if (cutoff && event.timestamp && event.timestamp.getTime() < cutoff) {
320
+ return false;
321
+ }
322
+ switch (options.filter) {
323
+ case "tools":
324
+ return event.kind === "tool";
325
+ case "errors":
326
+ return event.status === "failed" || event.status === "error" || /error|failed/i.test(event.text ?? "");
327
+ case "user":
328
+ return event.kind === "user";
329
+ case "agent":
330
+ return event.kind === "agent";
331
+ case "tasks":
332
+ return event.kind === "task";
333
+ default:
334
+ return true;
335
+ }
336
+ })
337
+ .slice(-options.limit);
338
+ }
339
+ export function isActivityFilter(value) {
340
+ return value === "all" || value === "tools" || value === "errors" || value === "user" || value === "agent" || value === "tasks";
341
+ }
342
+ export function formatAgentLaunchProfileLabel(profile, selected) {
343
+ const prefix = selected ? "✅" : profile.unsafe ? "⚠️" : "🚀";
344
+ return `${prefix} ${profile.label} · ${trimLine(profile.behavior, 24)}`;
345
+ }
346
+ export function formatModelButtonLabel(model, selected) {
347
+ const meta = [
348
+ model.contextWindow ? formatCompactNumber(model.contextWindow) : undefined,
349
+ model.supportsImages === true ? "img" : model.supportsImages === false ? "text" : undefined,
350
+ model.supportsThinking === true ? "think" : undefined,
351
+ ].filter(Boolean).join(" ");
352
+ return trimLine(`${selected ? "✅ " : ""}${model.displayName}${meta ? ` · ${meta}` : ""}`, 58);
353
+ }
354
+ export function formatCompactNumber(value) {
355
+ if (value >= 1_000_000_000)
356
+ return `${Math.round(value / 100_000_000) / 10}B`;
357
+ if (value >= 1_000_000)
358
+ return `${Math.round(value / 100_000) / 10}M`;
359
+ if (value >= 1_000)
360
+ return `${Math.round(value / 100) / 10}K`;
361
+ return String(value);
362
+ }
363
+ export function renderAgentDiagnostics(diagnostics) {
364
+ return {
365
+ plain: [
366
+ `${diagnostics.agentLabel} state:`,
367
+ ...diagnostics.lines.map((line) => `${line.label}: ${line.value}`),
368
+ ].join("\n"),
369
+ html: [
370
+ `<b>${escapeHTML(diagnostics.agentLabel)} state:</b>`,
371
+ ...diagnostics.lines.map((line) => `<b>${escapeHTML(line.label)}:</b> <code>${escapeHTML(line.value)}</code>`),
372
+ ].join("\n"),
373
+ };
374
+ }
375
+ export function activityEventLabel(event) {
376
+ if (event.kind === "task") {
377
+ return `task ${event.status ?? event.type}`;
378
+ }
379
+ if (event.kind === "user") {
380
+ return "user";
381
+ }
382
+ if (event.kind === "agent") {
383
+ return event.phase ? `agent ${event.phase}` : "agent";
384
+ }
385
+ return event.status ? `tool ${event.status}` : "tool";
386
+ }
387
+ export function isEmptyArtifactReport(report) {
388
+ return report.artifacts.length === 0 && report.skippedCount === 0 && !(report.omittedCount && report.omittedCount > 0);
389
+ }
390
+ export function formatBusyFlags(state) {
391
+ return Object.entries(state)
392
+ .filter(([, enabled]) => enabled)
393
+ .map(([name]) => name)
394
+ .join(", ");
395
+ }
396
+ export function renderDiagnosticsPlain(config, registry, health, authenticated, role, queueLength, progress, runtime) {
397
+ const contexts = registry.listContexts();
398
+ return [
399
+ "Diagnostics:",
400
+ `Status: ${health.state.status ?? "unknown"}`,
401
+ `Version: ${health.version}`,
402
+ `Role: ${role}`,
403
+ `Auth: ${authenticated ? "yes" : "no"} (${health.state.authMethod ?? "-"})`,
404
+ `PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
405
+ `App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
406
+ `Workspace: ${config.workspace}`,
407
+ `State backend: ${config.stateBackend}`,
408
+ `Telegram transport: ${config.telegramTransport}`,
409
+ `Codex CLI: ${health.codexCli}`,
410
+ `Pi CLI: ${health.piCli}`,
411
+ `Hermes CLI: ${health.hermesCli}`,
412
+ `OpenClaw CLI: ${health.openClawCli}`,
413
+ `Claude Code CLI: ${health.claudeCodeCli}`,
414
+ `Hermes API: ${config.hermesApiBaseUrl}`,
415
+ `OpenClaw Gateway: ${config.openClawGatewayUrl}`,
416
+ `Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
417
+ `State DB: ${health.databasePath ?? "-"}`,
418
+ `Log file: ${health.logFile}`,
419
+ `Log format: ${config.logFormat}`,
420
+ `Tool verbosity: ${config.toolVerbosity}`,
421
+ `Telegram rate limit queued/running/retries/429: ${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}`,
422
+ `Telegram last retry_after: ${runtime.rateLimit.lastRetryAfterSeconds ?? "-"}s`,
423
+ `CLI mirror mode/update: ${runtime.mirrorMode} / ${config.telegramMirrorMinUpdateMs} ms`,
424
+ `Notify/quiet: ${runtime.notifyMode} / ${runtime.quietHours}`,
425
+ `Voice: ${runtime.voiceBackend} / ${runtime.voiceLanguage} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}`,
426
+ `Sync interval: ${config.codexSyncIntervalMs} ms`,
427
+ `External busy check/stale: ${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms`,
428
+ `External mirrors/timers/status messages: ${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}`,
429
+ `Auto-send artifacts: ${config.telegramAutoSendArtifacts ? "yes" : "no"}`,
430
+ `Artifact ignore dirs/globs: ${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}`,
431
+ `Artifact retention: ${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs`,
432
+ `Workspace allowed/warn roots: ${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}`,
433
+ "User management: users/groups/telegram identities",
434
+ `Session lock TTL: ${config.sessionLockTtlMs} ms`,
435
+ `Audit max events: ${config.auditMaxEvents}`,
436
+ `Loaded sessions: ${contexts.length}`,
437
+ `Current queue: ${queueLength}`,
438
+ `Current progress: ${progress?.status ?? "idle"}`,
439
+ ].join("\n");
440
+ }
441
+ export function renderDiagnosticsHTML(config, registry, health, authenticated, role, queueLength, progress, runtime) {
442
+ const contexts = registry.listContexts();
443
+ return [
444
+ "<b>Diagnostics:</b>",
445
+ `<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
446
+ `<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
447
+ `<b>Role:</b> <code>${escapeHTML(role)}</code>`,
448
+ `<b>Auth:</b> <code>${authenticated ? "yes" : "no"} (${escapeHTML(health.state.authMethod ?? "-")})</code>`,
449
+ `<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
450
+ `<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
451
+ `<b>Workspace:</b> <code>${escapeHTML(config.workspace)}</code>`,
452
+ `<b>State backend:</b> <code>${escapeHTML(config.stateBackend)}</code>`,
453
+ `<b>Telegram transport:</b> <code>${escapeHTML(config.telegramTransport)}</code>`,
454
+ `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
455
+ `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
456
+ `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
457
+ `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
458
+ `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
459
+ `<b>Hermes API:</b> <code>${escapeHTML(config.hermesApiBaseUrl)}</code>`,
460
+ `<b>OpenClaw Gateway:</b> <code>${escapeHTML(config.openClawGatewayUrl)}</code>`,
461
+ `<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
462
+ `<b>State DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
463
+ `<b>Log file:</b> <code>${escapeHTML(health.logFile)}</code>`,
464
+ `<b>Log format:</b> <code>${escapeHTML(config.logFormat)}</code>`,
465
+ `<b>Tool verbosity:</b> <code>${escapeHTML(config.toolVerbosity)}</code>`,
466
+ `<b>Telegram rate limit queued/running/retries/429:</b> <code>${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}</code>`,
467
+ `<b>Telegram last retry_after:</b> <code>${escapeHTML(String(runtime.rateLimit.lastRetryAfterSeconds ?? "-"))}s</code>`,
468
+ `<b>CLI mirror mode/update:</b> <code>${escapeHTML(runtime.mirrorMode)} / ${config.telegramMirrorMinUpdateMs} ms</code>`,
469
+ `<b>Notify/quiet:</b> <code>${escapeHTML(runtime.notifyMode)} / ${escapeHTML(runtime.quietHours)}</code>`,
470
+ `<b>Voice:</b> <code>${escapeHTML(runtime.voiceBackend)} / ${escapeHTML(runtime.voiceLanguage)} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}</code>`,
471
+ `<b>Sync interval:</b> <code>${config.codexSyncIntervalMs} ms</code>`,
472
+ `<b>External busy check/stale:</b> <code>${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms</code>`,
473
+ `<b>External mirrors/timers/status messages:</b> <code>${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}</code>`,
474
+ `<b>Auto-send artifacts:</b> <code>${config.telegramAutoSendArtifacts ? "yes" : "no"}</code>`,
475
+ `<b>Artifact ignore dirs/globs:</b> <code>${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}</code>`,
476
+ `<b>Artifact retention:</b> <code>${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs</code>`,
477
+ `<b>Workspace allowed/warn roots:</b> <code>${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}</code>`,
478
+ "<b>User management:</b> <code>users/groups/telegram identities</code>",
479
+ `<b>Session lock TTL:</b> <code>${config.sessionLockTtlMs} ms</code>`,
480
+ `<b>Audit max events:</b> <code>${config.auditMaxEvents}</code>`,
481
+ `<b>Loaded sessions:</b> <code>${contexts.length}</code>`,
482
+ `<b>Current queue:</b> <code>${queueLength}</code>`,
483
+ `<b>Current progress:</b> <code>${escapeHTML(progress?.status ?? "idle")}</code>`,
484
+ ].join("\n");
485
+ }
486
+ export function renderHealthPlain(health, authenticated, role) {
487
+ return [
488
+ `Status: ${health.state.status ?? "unknown"}`,
489
+ `Version: ${health.version}`,
490
+ `Role: ${role}`,
491
+ `Auth: ${authenticated ? "yes" : "no"}`,
492
+ `PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
493
+ `App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
494
+ `Uptime: ${formatDuration(health.uptimeSeconds)}`,
495
+ `Workspace: ${health.state.workspace ?? "-"}`,
496
+ `Codex CLI: ${health.codexCli}`,
497
+ `Pi CLI: ${health.piCli}`,
498
+ `Hermes CLI: ${health.hermesCli}`,
499
+ `OpenClaw CLI: ${health.openClawCli}`,
500
+ `Claude Code CLI: ${health.claudeCodeCli}`,
501
+ `Codex state DB: ${health.databasePath ?? "-"}`,
502
+ `Log: ${health.logFile}`,
503
+ ].join("\n");
504
+ }
505
+ export function renderHealthHTML(health, authenticated, role) {
506
+ return [
507
+ `<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
508
+ `<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
509
+ `<b>Role:</b> <code>${escapeHTML(role)}</code>`,
510
+ `<b>Auth:</b> <code>${authenticated ? "yes" : "no"}</code>`,
511
+ `<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
512
+ `<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
513
+ `<b>Uptime:</b> <code>${escapeHTML(formatDuration(health.uptimeSeconds))}</code>`,
514
+ `<b>Workspace:</b> <code>${escapeHTML(health.state.workspace ?? "-")}</code>`,
515
+ `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
516
+ `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
517
+ `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
518
+ `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
519
+ `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
520
+ `<b>Codex state DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
521
+ `<b>Log:</b> <code>${escapeHTML(health.logFile)}</code>`,
522
+ ].join("\n");
523
+ }
524
+ export function parseFastModeArgument(argument, currentValue) {
525
+ if (!argument) {
526
+ return !currentValue;
527
+ }
528
+ const normalized = argument.toLowerCase();
529
+ if (["on", "enable", "enabled", "true", "1"].includes(normalized)) {
530
+ return true;
531
+ }
532
+ if (["off", "disable", "disabled", "false", "0"].includes(normalized)) {
533
+ return false;
534
+ }
535
+ return undefined;
536
+ }
537
+ export function parseToggle(argument) {
538
+ const normalized = argument.trim().toLowerCase();
539
+ if (["on", "enable", "enabled", "true", "1", "yes"].includes(normalized)) {
540
+ return true;
541
+ }
542
+ if (["off", "disable", "disabled", "false", "0", "no"].includes(normalized)) {
543
+ return false;
544
+ }
545
+ return undefined;
546
+ }
547
+ export function parseDurationToMs(value) {
548
+ const match = value.trim().match(/^(\d+)(s|m|h|d)?$/i);
549
+ if (!match) {
550
+ return undefined;
551
+ }
552
+ const amount = Number(match[1]);
553
+ const unit = (match[2] ?? "m").toLowerCase();
554
+ const multiplier = unit === "s"
555
+ ? 1000
556
+ : unit === "h"
557
+ ? 60 * 60 * 1000
558
+ : unit === "d"
559
+ ? 24 * 60 * 60 * 1000
560
+ : 60 * 1000;
561
+ return amount * multiplier;
562
+ }
563
+ export function extractCommandName(text) {
564
+ const match = text.trim().match(/^\/([a-zA-Z0-9_-]+)(?:@\w+)?(?:\s|$)/);
565
+ return match?.[1]?.toLowerCase();
566
+ }
567
+ export function isPromptEnvelopeLike(value) {
568
+ return typeof value === "object" && value !== null && "input" in value && "description" in value;
569
+ }
570
+ export function isQueuedPromptLike(value) {
571
+ return "id" in value &&
572
+ "contextKey" in value &&
573
+ "createdAt" in value &&
574
+ typeof value.id === "string" &&
575
+ typeof value.contextKey === "string" &&
576
+ typeof value.createdAt === "number";
577
+ }
578
+ export function capabilitiesOf(info) {
579
+ return info.capabilities ?? CODEX_AGENT_CAPABILITIES;
580
+ }
581
+ export function labelOf(info) {
582
+ return info.agentLabel ?? agentLabel(info.agentId ?? "codex");
583
+ }
584
+ export function idOf(info) {
585
+ return info.agentId ?? "codex";
586
+ }
587
+ export function authHelpText(info) {
588
+ const agentId = idOf(info);
589
+ if (agentId === "pi") {
590
+ return "Configure the required Pi provider environment variable on the host.";
591
+ }
592
+ if (agentId === "hermes") {
593
+ return "Start the Hermes API Server, configure HERMES_API_KEY when required, or use /login to start Hermes CLI auth.";
594
+ }
595
+ if (agentId === "openclaw") {
596
+ return "Start the OpenClaw Gateway and configure OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD when the gateway requires one.";
597
+ }
598
+ if (agentId === "claude-code") {
599
+ return "Use /login to start Claude Code CLI auth, or run 'claude auth login' on the host.";
600
+ }
601
+ return "Use /login to start authentication, or set CODEX_API_KEY on the host.";
602
+ }
603
+ export function formatAgentSettingScope(info, appliedToActiveThread) {
604
+ const agentId = idOf(info);
605
+ if (agentId === "hermes") {
606
+ return appliedToActiveThread
607
+ ? "applies to the next Hermes run in this session"
608
+ : "applies to new Hermes sessions";
609
+ }
610
+ if (agentId === "pi") {
611
+ return appliedToActiveThread
612
+ ? "applied to the current idle Pi session and future turns"
613
+ : "applies to new Pi sessions";
614
+ }
615
+ if (agentId === "openclaw") {
616
+ return appliedToActiveThread
617
+ ? "applies to the next OpenClaw run in this session"
618
+ : "applies to new OpenClaw sessions";
619
+ }
620
+ if (agentId === "claude-code") {
621
+ return appliedToActiveThread
622
+ ? "applies to the next Claude Code run in this session"
623
+ : "applies to new Claude Code sessions";
624
+ }
625
+ return appliedToActiveThread
626
+ ? "applied to the current idle thread and future threads"
627
+ : "applies to new threads";
628
+ }
629
+ export function requiresTurnApproval(info) {
630
+ return info.unsafeLaunch || info.approvalPolicy !== "never";
631
+ }
632
+ export function formatDuration(totalSeconds) {
633
+ const seconds = Math.max(0, Math.floor(totalSeconds));
634
+ const days = Math.floor(seconds / 86400);
635
+ const hours = Math.floor((seconds % 86400) / 3600);
636
+ const minutes = Math.floor((seconds % 3600) / 60);
637
+ if (days > 0) {
638
+ return `${days}d ${hours}h`;
639
+ }
640
+ if (hours > 0) {
641
+ return `${hours}h ${minutes}m`;
642
+ }
643
+ return `${minutes}m`;
644
+ }
645
+ export function formatDurationSeconds(totalSeconds) {
646
+ const seconds = Math.max(0, Math.floor(totalSeconds));
647
+ if (seconds < 60) {
648
+ return `${seconds}s`;
649
+ }
650
+ const minutes = Math.floor(seconds / 60);
651
+ const remainingSeconds = seconds % 60;
652
+ if (minutes < 60) {
653
+ return `${minutes}m ${remainingSeconds}s`;
654
+ }
655
+ const hours = Math.floor(minutes / 60);
656
+ return `${hours}h ${minutes % 60}m`;
657
+ }
658
+ export function renderToolStartMessage(toolName) {
659
+ return {
660
+ text: `<b>🔧 Running:</b> <code>${escapeHTML(toolName)}</code>`,
661
+ fallbackText: `🔧 Running: ${toolName}`,
662
+ parseMode: "HTML",
663
+ };
664
+ }
665
+ export function renderToolEndMessage(toolName, partialResult, isError) {
666
+ const preview = summarizeToolOutput(partialResult);
667
+ const icon = isError ? "❌" : "✅";
668
+ const htmlLines = [`<b>${icon}</b> <code>${escapeHTML(toolName)}</code>`];
669
+ const plainLines = [`${icon} ${toolName}`];
670
+ if (preview) {
671
+ htmlLines.push(`<pre>${escapeHTML(preview)}</pre>`);
672
+ plainLines.push(preview);
673
+ }
674
+ return {
675
+ text: htmlLines.join("\n"),
676
+ fallbackText: plainLines.join("\n"),
677
+ parseMode: "HTML",
678
+ };
679
+ }
680
+ export function formatToolSummaryLine(toolCounts) {
681
+ if (toolCounts.size === 0) {
682
+ return "";
683
+ }
684
+ const summarizedCounts = new Map();
685
+ for (const [toolName, count] of toolCounts.entries()) {
686
+ const summaryName = summarizeToolName(toolName);
687
+ summarizedCounts.set(summaryName, (summarizedCounts.get(summaryName) ?? 0) + count);
688
+ }
689
+ const entries = [...summarizedCounts.entries()].sort((left, right) => {
690
+ const countDelta = right[1] - left[1];
691
+ return countDelta !== 0 ? countDelta : left[0].localeCompare(right[0]);
692
+ });
693
+ const tools = entries
694
+ .map(([name, count]) => formatSummaryEntry(name, count))
695
+ .join(", ");
696
+ return `Tools used: ${tools}`;
697
+ }
698
+ export function renderTodoList(items) {
699
+ const lines = items.map((item) => {
700
+ const icon = item.completed ? "✅" : "⬜";
701
+ return `${icon} ${escapeHTML(item.text)}`;
702
+ });
703
+ return `📋 <b>Plan</b>\n${lines.join("\n")}`;
704
+ }
705
+ export function formatTurnUsageLine(usage) {
706
+ return `🪙 in: ${usage.inputTokens} · cached: ${usage.cachedInputTokens} · out: ${usage.outputTokens}`;
707
+ }
708
+ export function summarizeToolName(toolName) {
709
+ if (toolName.startsWith("🔍 ")) {
710
+ return "web_fetch";
711
+ }
712
+ if (toolName === "file_change") {
713
+ return "file_change";
714
+ }
715
+ if (toolName === "⚠️ error") {
716
+ return "error";
717
+ }
718
+ if (toolName.startsWith("mcp:")) {
719
+ const tool = toolName.split("/").at(-1) ?? toolName;
720
+ if (SUBAGENT_TOOL_NAMES.has(tool)) {
721
+ return "subagent";
722
+ }
723
+ return tool;
724
+ }
725
+ return "bash";
726
+ }
727
+ export function formatSummaryEntry(name, count) {
728
+ if (count <= 1) {
729
+ return name;
730
+ }
731
+ const label = name === "subagent" ? "subagents" : name;
732
+ return `${count}x ${label}`;
733
+ }
734
+ const SUBAGENT_TOOL_NAMES = new Set(["spawn_agent", "send_input", "wait_agent", "close_agent", "resume_agent"]);
735
+ export function buildStreamingPreview(text) {
736
+ if (text.length <= STREAMING_PREVIEW_LIMIT) {
737
+ return text;
738
+ }
739
+ return `${text.slice(0, STREAMING_PREVIEW_LIMIT)}\n\n… streaming (preview truncated)`;
740
+ }
741
+ export function appendWithCap(base, addition, cap) {
742
+ const combined = `${base}${addition}`;
743
+ return combined.length <= cap ? combined : combined.slice(-cap);
744
+ }
745
+ export function summarizeToolOutput(text) {
746
+ const trimmed = text.trim();
747
+ if (!trimmed) {
748
+ return "";
749
+ }
750
+ return trimmed.length <= TOOL_OUTPUT_PREVIEW_LIMIT ? trimmed : `${trimmed.slice(-TOOL_OUTPUT_PREVIEW_LIMIT)}\n…`;
751
+ }
752
+ export function trimLine(text, maxLength) {
753
+ const singleLine = text.replace(/\s+/g, " ").trim();
754
+ if (singleLine.length <= maxLength) {
755
+ return singleLine;
756
+ }
757
+ return `${singleLine.slice(0, maxLength - 1)}…`;
758
+ }
759
+ export function getWorkspaceShortName(workspace) {
760
+ return workspace.split(/[\\/]/).filter(Boolean).pop() ?? workspace;
761
+ }
762
+ export function formatRelativeTime(date) {
763
+ const deltaMs = Date.now() - date.getTime();
764
+ const deltaSeconds = Math.max(0, Math.floor(deltaMs / 1000));
765
+ if (deltaSeconds < 60) {
766
+ return "just now";
767
+ }
768
+ const deltaMinutes = Math.floor(deltaSeconds / 60);
769
+ if (deltaMinutes < 60) {
770
+ return `${deltaMinutes}m ago`;
771
+ }
772
+ const deltaHours = Math.floor(deltaMinutes / 60);
773
+ if (deltaHours < 48) {
774
+ return `${deltaHours}h ago`;
775
+ }
776
+ const deltaDays = Math.floor(deltaHours / 24);
777
+ if (deltaDays < 14) {
778
+ return `${deltaDays}d ago`;
779
+ }
780
+ const deltaWeeks = Math.floor(deltaDays / 7);
781
+ return `${deltaWeeks}w ago`;
782
+ }
783
+ export function filterSessions(sessions, query) {
784
+ const normalized = query.trim().toLowerCase();
785
+ if (!normalized) {
786
+ return sessions;
787
+ }
788
+ return sessions.filter((session) => [
789
+ session.id,
790
+ session.title ?? "",
791
+ session.cwd,
792
+ session.model ?? "",
793
+ session.firstUserMessage ?? "",
794
+ ].some((value) => value.toLowerCase().includes(normalized)));
795
+ }
796
+ export function orderPinnedSessions(sessions, pinnedThreadIds) {
797
+ const pinnedIndex = new Map(pinnedThreadIds.map((threadId, index) => [threadId, index]));
798
+ return [...sessions].sort((left, right) => {
799
+ const leftPinned = pinnedIndex.get(left.id);
800
+ const rightPinned = pinnedIndex.get(right.id);
801
+ if (leftPinned !== undefined && rightPinned !== undefined) {
802
+ return leftPinned - rightPinned;
803
+ }
804
+ if (leftPinned !== undefined) {
805
+ return -1;
806
+ }
807
+ if (rightPinned !== undefined) {
808
+ return 1;
809
+ }
810
+ return 0;
811
+ });
812
+ }
813
+ export function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
814
+ const now = Date.now();
815
+ const existing = buckets.get(key);
816
+ if (existing?.blockedUntil && existing.blockedUntil > now) {
817
+ return { limited: true, retryAfterMs: existing.blockedUntil - now };
818
+ }
819
+ const bucket = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;
820
+ bucket.count += 1;
821
+ if (bucket.count > limit) {
822
+ bucket.blockedUntil = now + blockMs;
823
+ buckets.set(key, bucket);
824
+ return { limited: true, retryAfterMs: blockMs };
825
+ }
826
+ buckets.set(key, bucket);
827
+ return { limited: false };
828
+ }
829
+ export function resetRateLimit(buckets, key) {
830
+ buckets.delete(key);
831
+ }
832
+ export function renderPromptFailure(accumulatedText, error) {
833
+ const message = friendlyErrorText(error);
834
+ return accumulatedText.trim() ? `${accumulatedText.trim()}\n\n⚠️ ${message}` : `⚠️ ${message}`;
835
+ }
836
+ export function formatError(error) {
837
+ return error instanceof Error ? error.message : String(error);
838
+ }