@nordbyte/nordrelay 0.2.1 → 0.3.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.
package/dist/bot.js CHANGED
@@ -7,8 +7,11 @@ import { Bot, InlineKeyboard, InputFile } from "grammy";
7
7
  import { hasTelegramPermission, permissionForCallbackData, permissionForCommand, } from "./access-control.js";
8
8
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
9
9
  import { collectArtifactReport, collectRecentWorkspaceArtifacts, createArtifactZipBundle, ensureOutDir, formatArtifactSummary, getArtifactTurnReport, isTelegramImagePreview, listRecentArtifactReports, persistWorkspaceArtifactReport, pruneConnectorTurnDirs, removeArtifactTurn, telegramArtifactFilename, totalArtifactSize, } from "./artifacts.js";
10
+ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
11
+ import { AuditLogStore } from "./audit-log.js";
10
12
  import { formatSessionLabel, renderHelpMessage, renderWelcomeFirstTime, renderWelcomeReturning, } from "./bot-ui.js";
11
13
  import { BotPreferencesStore, formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
14
+ import { listChannelDescriptors } from "./channel-adapter.js";
12
15
  import { CODEX_REASONING_EFFORTS, CODEX_AGENT_CAPABILITIES, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
13
16
  import { enabledAgents } from "./agent-factory.js";
14
17
  import { checkAuthStatus, clearAuthCache, startLogin, startLogout } from "./codex-auth.js";
@@ -17,9 +20,10 @@ import { getThreadActivity, getThreadActivityLog, getThreadRolloutSnapshot, } fr
17
20
  import { contextKeyFromCtx, isTopicContextKey, parseContextKey } from "./context-key.js";
18
21
  import { friendlyErrorText } from "./error-messages.js";
19
22
  import { escapeHTML, formatTelegramHTML } from "./format.js";
20
- import { getConnectorHealth, getUpdateLogPath, readConnectorState, readLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
23
+ import { getConnectorHealth, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
21
24
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
22
25
  import { configureRedaction, redactText } from "./redaction.js";
26
+ import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
23
27
  import { formatFileSize, renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
24
28
  import { SessionRegistry } from "./session-registry.js";
25
29
  import { getAvailableBackends, transcribeAudio } from "./voice.js";
@@ -82,8 +86,10 @@ export function createBot(config, registry) {
82
86
  const pendingAgentPicks = new Map();
83
87
  const pendingMediaGroups = new Map();
84
88
  const turnProgress = new Map();
85
- const promptStore = new PromptStore(config.workspace);
86
- const preferencesStore = new BotPreferencesStore(config.workspace);
89
+ const promptStore = new PromptStore(config.workspace, config.stateBackend);
90
+ const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
91
+ const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
92
+ const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
87
93
  const drainingQueues = new Set();
88
94
  const externalQueueTimers = new Map();
89
95
  const externalMirrors = new Map();
@@ -259,7 +265,10 @@ export function createBot(config, registry) {
259
265
  const age = formatRelativeTime(new Date(item.createdAt));
260
266
  const attempts = item.attempts && item.attempts > 0 ? ` · attempts ${item.attempts}` : "";
261
267
  const error = item.lastError ? ` · last error: ${trimLine(item.lastError, 80)}` : "";
262
- const eta = index === 0 ? "next" : `after ${index} queued item${index === 1 ? "" : "s"}`;
268
+ const scheduled = item.notBefore && item.notBefore > Date.now()
269
+ ? `scheduled ${formatLocalDateTime(new Date(item.notBefore))}`
270
+ : index === 0 ? "next" : `after ${index} queued item${index === 1 ? "" : "s"}`;
271
+ const eta = scheduled;
263
272
  return `${index + 1}. ${item.id} · ${age} · ${eta}${attempts}${error} · ${item.description}`;
264
273
  });
265
274
  const keyboard = new InlineKeyboard();
@@ -579,6 +588,42 @@ export function createBot(config, registry) {
579
588
  }
580
589
  return permissionForCommand(command);
581
590
  };
591
+ const audit = (event) => {
592
+ try {
593
+ auditLog.append(event);
594
+ }
595
+ catch (error) {
596
+ console.warn("Failed to write audit event:", error instanceof Error ? error.message : String(error));
597
+ }
598
+ };
599
+ const auditContext = (ctx, contextKey, session, patch) => {
600
+ const info = session.getInfo();
601
+ audit({
602
+ contextKey,
603
+ actorId: ctx.from?.id,
604
+ actorRole: getUserRole(ctx),
605
+ agentId: idOf(info),
606
+ threadId: info.threadId,
607
+ workspace: info.workspace,
608
+ ...patch,
609
+ });
610
+ };
611
+ const denyIfLocked = async (ctx, contextKey, session) => {
612
+ const lock = lockStore.get(contextKey);
613
+ const isAdmin = getUserRole(ctx) === "admin";
614
+ if (canWriteWithLock(lock, ctx.from?.id, isAdmin)) {
615
+ return false;
616
+ }
617
+ const owner = formatLockOwner(lock);
618
+ const text = `Session is locked by ${owner}. Use /locks to inspect or ask an admin to /unlock.`;
619
+ auditContext(ctx, contextKey, session, {
620
+ action: "prompt_started",
621
+ status: "denied",
622
+ detail: text,
623
+ });
624
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
625
+ return true;
626
+ };
582
627
  const setReaction = async (ctx, emoji) => {
583
628
  if (!config.enableTelegramReactions) {
584
629
  return;
@@ -669,6 +714,9 @@ export function createBot(config, registry) {
669
714
  const parsed = parseContextKey(contextKey);
670
715
  const messageThreadId = parsed.messageThreadId;
671
716
  const envelope = isPromptEnvelopeLike(prompt) ? prompt : toPromptEnvelope(prompt);
717
+ if (!options.fromQueue && await denyIfLocked(ctx, contextKey, session)) {
718
+ return;
719
+ }
672
720
  const busy = getBusyReason(contextKey);
673
721
  if (busy.busy) {
674
722
  if (options.fromQueue) {
@@ -689,6 +737,13 @@ export function createBot(config, registry) {
689
737
  fallbackText: queuedMessage,
690
738
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
691
739
  });
740
+ auditContext(ctx, contextKey, session, {
741
+ action: "prompt_queued",
742
+ status: "ok",
743
+ promptId: item.id,
744
+ description: item.description,
745
+ detail: busy.kind,
746
+ });
692
747
  if (busy.kind === "external") {
693
748
  scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
694
749
  }
@@ -1101,6 +1156,11 @@ export function createBot(config, registry) {
1101
1156
  return;
1102
1157
  }
1103
1158
  promptStore.setLastPrompt(contextKey, envelope);
1159
+ auditContext(ctx, contextKey, session, {
1160
+ action: "prompt_started",
1161
+ status: "ok",
1162
+ description: envelope.description,
1163
+ });
1104
1164
  await session.prompt(envelope.input, callbacks);
1105
1165
  updateSessionMetadata(contextKey, session);
1106
1166
  await finalizeResponse();
@@ -1115,10 +1175,21 @@ export function createBot(config, registry) {
1115
1175
  progress.status = "completed";
1116
1176
  progress.completedAt = Date.now();
1117
1177
  progress.updatedAt = progress.completedAt;
1178
+ auditContext(ctx, contextKey, session, {
1179
+ action: "prompt_completed",
1180
+ status: "ok",
1181
+ description: envelope.description,
1182
+ });
1118
1183
  }
1119
1184
  catch (error) {
1120
1185
  progress.status = "failed";
1121
1186
  progress.error = friendlyErrorText(error);
1187
+ auditContext(ctx, contextKey, session, {
1188
+ action: "prompt_failed",
1189
+ status: "failed",
1190
+ description: envelope.description,
1191
+ detail: progress.error,
1192
+ });
1122
1193
  progress.completedAt = Date.now();
1123
1194
  progress.updatedAt = progress.completedAt;
1124
1195
  stopTyping();
@@ -1175,6 +1246,11 @@ export function createBot(config, registry) {
1175
1246
  }
1176
1247
  const next = promptStore.dequeue(contextKey);
1177
1248
  if (!next) {
1249
+ const nextRunnableAt = promptStore.nextRunnableAt(contextKey);
1250
+ const queued = promptStore.list(contextKey).length;
1251
+ if (nextRunnableAt && queued > 0) {
1252
+ await updateQueueStatusMessage(contextKey, `Next queued prompt is scheduled for ${formatLocalDateTime(new Date(nextRunnableAt))}. ${queued} queued.`);
1253
+ }
1178
1254
  return;
1179
1255
  }
1180
1256
  const remainingBeforeRun = promptStore.list(contextKey).length + 1;
@@ -1457,6 +1533,50 @@ export function createBot(config, registry) {
1457
1533
  const help = renderHelpMessage();
1458
1534
  await safeReply(ctx, help.html, { fallbackText: help.plain });
1459
1535
  });
1536
+ bot.command("channels", async (ctx) => {
1537
+ const descriptors = listChannelDescriptors();
1538
+ const lines = descriptors.map((descriptor) => {
1539
+ const status = descriptor.status === "available" ? "available" : "planned";
1540
+ return `${descriptor.label}: ${status} · ${descriptor.capabilities.join(", ")}`;
1541
+ });
1542
+ const html = [
1543
+ "<b>Channel adapters:</b>",
1544
+ ...descriptors.map((descriptor) => {
1545
+ const statusIcon = descriptor.status === "available" ? "✅" : "🟡";
1546
+ const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1547
+ return `${statusIcon} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(descriptor.status)}</code>\n <code>${escapeHTML(descriptor.capabilities.join(", "))}</code>${notes}`;
1548
+ }),
1549
+ ].join("\n");
1550
+ await safeReply(ctx, html, { fallbackText: ["Channel adapters:", ...lines].join("\n") });
1551
+ });
1552
+ bot.command("agents", async (ctx) => {
1553
+ const descriptors = listAgentAdapterDescriptors();
1554
+ const plain = [
1555
+ "Agent adapters:",
1556
+ ...descriptors.map((descriptor) => {
1557
+ const enabled = descriptor.id === "codex"
1558
+ ? config.codexEnabled
1559
+ : descriptor.id === "pi"
1560
+ ? config.piEnabled
1561
+ : false;
1562
+ return `${descriptor.label}: ${descriptor.status}${descriptor.status === "available" ? ` · ${enabled ? "enabled" : "disabled"}` : ""}`;
1563
+ }),
1564
+ ].join("\n");
1565
+ const html = [
1566
+ "<b>Agent adapters:</b>",
1567
+ ...descriptors.map((descriptor) => {
1568
+ const enabled = descriptor.id === "codex"
1569
+ ? config.codexEnabled
1570
+ : descriptor.id === "pi"
1571
+ ? config.piEnabled
1572
+ : false;
1573
+ const status = descriptor.status === "available" ? `${enabled ? "enabled" : "disabled"}` : "planned";
1574
+ const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1575
+ return `${descriptor.status === "available" ? "✅" : "🟡"} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(status)}</code>${notes}`;
1576
+ }),
1577
+ ].join("\n");
1578
+ await safeReply(ctx, html, { fallbackText: plain });
1579
+ });
1460
1580
  bot.command("agent", async (ctx) => {
1461
1581
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1462
1582
  if (!contextSession) {
@@ -1817,17 +1937,22 @@ export function createBot(config, registry) {
1817
1937
  bot.command("version", async (ctx) => {
1818
1938
  const health = await getConnectorHealth();
1819
1939
  const state = await readConnectorState();
1940
+ const versions = await getVersionChecks({ piCliPath: config.piCliPath });
1820
1941
  const plain = [
1821
- `NordRelay ${health.version}`,
1942
+ renderVersionCheckPlain(versions.nordrelay),
1822
1943
  `Runtime status: ${state.status ?? "unknown"}`,
1823
- `Codex CLI: ${health.codexCli}`,
1824
- `Pi CLI: ${health.piCli}`,
1944
+ formatCliPathPlain("Codex CLI", health.codexCliPath, health.codexCli),
1945
+ renderVersionCheckPlain(versions.codex),
1946
+ formatCliPathPlain("Pi CLI", health.piCliPath, health.piCli),
1947
+ renderVersionCheckPlain(versions.pi),
1825
1948
  ].join("\n");
1826
1949
  const html = [
1827
- `<b>NordRelay</b> <code>${escapeHTML(health.version)}</code>`,
1950
+ renderVersionCheckHTML(versions.nordrelay),
1828
1951
  `<b>Runtime status:</b> <code>${escapeHTML(state.status ?? "unknown")}</code>`,
1829
- `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
1830
- `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
1952
+ formatCliPathHTML("Codex CLI", health.codexCliPath, health.codexCli),
1953
+ renderVersionCheckHTML(versions.codex),
1954
+ formatCliPathHTML("Pi CLI", health.piCliPath, health.piCli),
1955
+ renderVersionCheckHTML(versions.pi),
1831
1956
  ].join("\n");
1832
1957
  await safeReply(ctx, html, { fallbackText: plain });
1833
1958
  });
@@ -1882,6 +2007,61 @@ export function createBot(config, registry) {
1882
2007
  }
1883
2008
  await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
1884
2009
  });
2010
+ bot.command("audit", async (ctx) => {
2011
+ const rawText = ctx.message?.text ?? "";
2012
+ const limitArg = rawText.replace(/^\/audit(?:@\w+)?\s*/i, "").trim();
2013
+ const limit = /^\d+$/.test(limitArg) ? Number(limitArg) : 20;
2014
+ const events = auditLog.list(limit);
2015
+ const rendered = renderAuditEvents(events);
2016
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2017
+ });
2018
+ bot.command("lock", async (ctx) => {
2019
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2020
+ if (!contextSession || !ctx.from) {
2021
+ return;
2022
+ }
2023
+ const { contextKey, session } = contextSession;
2024
+ const existing = lockStore.get(contextKey);
2025
+ if (existing && existing.ownerId !== ctx.from.id && getUserRole(ctx) !== "admin") {
2026
+ const text = `Session is already locked by ${formatLockOwner(existing)}.`;
2027
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2028
+ return;
2029
+ }
2030
+ const lock = lockStore.set(contextKey, ctx.from.id, formatTelegramName(ctx), config.sessionLockTtlMs);
2031
+ auditContext(ctx, contextKey, session, {
2032
+ action: "lock_updated",
2033
+ status: "ok",
2034
+ detail: `locked by ${lock.ownerId}`,
2035
+ });
2036
+ const text = `Session locked by ${formatLockOwner(lock)}${lock.expiresAt ? ` until ${formatLocalDateTime(new Date(lock.expiresAt))}` : ""}.`;
2037
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2038
+ });
2039
+ bot.command("unlock", async (ctx) => {
2040
+ const contextSession = await getContextSession(ctx, { deferThreadStart: true });
2041
+ if (!contextSession) {
2042
+ return;
2043
+ }
2044
+ const { contextKey, session } = contextSession;
2045
+ const lock = lockStore.get(contextKey);
2046
+ if (lock && lock.ownerId !== ctx.from?.id && getUserRole(ctx) !== "admin") {
2047
+ const text = `Only ${formatLockOwner(lock)} or an admin can unlock this session.`;
2048
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2049
+ return;
2050
+ }
2051
+ const removed = lockStore.clear(contextKey);
2052
+ auditContext(ctx, contextKey, session, {
2053
+ action: "lock_updated",
2054
+ status: "ok",
2055
+ detail: removed ? "unlocked" : "no lock",
2056
+ });
2057
+ const text = removed ? "Session lock released." : "No active lock for this session.";
2058
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2059
+ });
2060
+ bot.command("locks", async (ctx) => {
2061
+ const locks = lockStore.list();
2062
+ const rendered = renderSessionLocks(locks);
2063
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2064
+ });
1885
2065
  bot.command("diagnostics", async (ctx) => {
1886
2066
  const health = await getConnectorHealth();
1887
2067
  const authStatus = await checkAuthStatus(config.codexApiKey);
@@ -1944,10 +2124,20 @@ export function createBot(config, registry) {
1944
2124
  bot.command("logs", async (ctx) => {
1945
2125
  const rawText = ctx.message?.text ?? "";
1946
2126
  const argument = rawText.replace(/^\/logs(?:@\w+)?\s*/i, "").trim();
1947
- const lines = Number.parseInt(argument || "80", 10);
1948
- const logTail = await readLogTail(Number.isNaN(lines) ? 80 : lines);
1949
- const plain = `Connector log tail:\n\n${logTail || "(empty)"}`;
1950
- const html = `<b>Connector log tail:</b>\n\n<pre>${escapeHTML(logTail || "(empty)")}</pre>`;
2127
+ const logRequest = parseLogsCommand(argument);
2128
+ const logs = logRequest.target === "all"
2129
+ ? [
2130
+ { title: "Connector", tail: await readFormattedLogTail(logRequest.lines) },
2131
+ { title: "Update", tail: await readFormattedLogTail(logRequest.lines, getUpdateLogPath()) },
2132
+ ]
2133
+ : [
2134
+ {
2135
+ title: logRequest.target === "update" ? "Update" : "Connector",
2136
+ tail: await readFormattedLogTail(logRequest.lines, logRequest.target === "update" ? getUpdateLogPath() : undefined),
2137
+ },
2138
+ ];
2139
+ const plain = logs.map(({ title, tail }) => renderLogTailPlain(title, tail)).join("\n\n");
2140
+ const html = logs.map(({ title, tail }) => renderLogTailHTML(title, tail)).join("\n\n");
1951
2141
  await safeReply(ctx, html, { fallbackText: plain });
1952
2142
  });
1953
2143
  bot.command("restart", async (ctx) => {
@@ -1959,18 +2149,22 @@ export function createBot(config, registry) {
1959
2149
  }, 300);
1960
2150
  });
1961
2151
  bot.command("update", async (ctx) => {
1962
- const updateLog = spawnSelfUpdate();
2152
+ const update = spawnSelfUpdate();
1963
2153
  const plain = [
1964
2154
  "Update started.",
1965
- "The connector will pull main, install dependencies, run check, tests, build, and restart only if all steps pass.",
1966
- `Log: ${updateLog}`,
1967
- "Use /logs after the restart or inspect update.log on the host.",
2155
+ `Method: ${update.method}`,
2156
+ update.summary,
2157
+ `Source: ${update.sourceRoot}`,
2158
+ `Log: ${update.logPath}`,
2159
+ "Use /logs update after the restart or inspect update.log on the host.",
1968
2160
  ].join("\n");
1969
2161
  const html = [
1970
2162
  "<b>Update started.</b>",
1971
- "The connector will pull <code>main</code>, install dependencies, run check, tests, build, and restart only if all steps pass.",
1972
- `<b>Log:</b> <code>${escapeHTML(updateLog)}</code>`,
1973
- `Use <code>/logs</code> after the restart or inspect <code>${escapeHTML(getUpdateLogPath())}</code> on the host.`,
2163
+ `<b>Method:</b> <code>${escapeHTML(update.method)}</code>`,
2164
+ escapeHTML(update.summary),
2165
+ `<b>Source:</b> <code>${escapeHTML(update.sourceRoot)}</code>`,
2166
+ `<b>Log:</b> <code>${escapeHTML(update.logPath)}</code>`,
2167
+ `Use <code>/logs update</code> after the restart or inspect <code>${escapeHTML(getUpdateLogPath())}</code> on the host.`,
1974
2168
  ].join("\n");
1975
2169
  await safeReply(ctx, html, { fallbackText: plain });
1976
2170
  });
@@ -2085,6 +2279,39 @@ export function createBot(config, registry) {
2085
2279
  const { contextKey, session } = contextSession;
2086
2280
  const rawText = ctx.message?.text ?? "";
2087
2281
  const argument = rawText.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
2282
+ const laterMatch = argument.match(/^later\s+(\d+)(?:m|min|minutes?)?\s+([\s\S]+)$/i);
2283
+ if (laterMatch) {
2284
+ const minutes = Math.min(7 * 24 * 60, Math.max(1, Number(laterMatch[1])));
2285
+ const text = laterMatch[2].trim();
2286
+ const notBefore = Date.now() + minutes * 60 * 1000;
2287
+ const item = promptStore.enqueue(contextKey, toPromptEnvelope(text), { notBefore });
2288
+ const message = `Queued prompt ${item.id} for ${formatLocalDateTime(new Date(notBefore))}.`;
2289
+ await safeReply(ctx, escapeHTML(message), {
2290
+ fallbackText: message,
2291
+ replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
2292
+ });
2293
+ auditContext(ctx, contextKey, session, {
2294
+ action: "prompt_queued",
2295
+ status: "ok",
2296
+ promptId: item.id,
2297
+ description: item.description,
2298
+ detail: "scheduled",
2299
+ });
2300
+ return;
2301
+ }
2302
+ const inspectMatch = argument.match(/^inspect\s+([a-z0-9]+)$/i);
2303
+ if (inspectMatch) {
2304
+ const item = promptStore.get(contextKey, inspectMatch[1]);
2305
+ if (!item) {
2306
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${inspectMatch[1]}.`), {
2307
+ fallbackText: `No queued prompt found with id ${inspectMatch[1]}.`,
2308
+ });
2309
+ return;
2310
+ }
2311
+ const rendered = renderQueuedPromptDetail(item);
2312
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2313
+ return;
2314
+ }
2088
2315
  if (/^pause$/i.test(argument)) {
2089
2316
  promptStore.pause(contextKey);
2090
2317
  const message = `Queue paused. ${promptStore.list(contextKey).length} queued.`;
@@ -2151,8 +2378,8 @@ export function createBot(config, registry) {
2151
2378
  return;
2152
2379
  }
2153
2380
  if (argument) {
2154
- await safeReply(ctx, escapeHTML("Usage: /queue, /queue pause, /queue resume, /queue move <id> top|up|down, /queue run <id>"), {
2155
- fallbackText: "Usage: /queue, /queue pause, /queue resume, /queue move <id> top|up|down, /queue run <id>",
2381
+ await safeReply(ctx, escapeHTML("Usage: /queue, /queue pause, /queue resume, /queue later <minutes> <prompt>, /queue inspect <id>, /queue move <id> top|up|down, /queue run <id>"), {
2382
+ fallbackText: "Usage: /queue, /queue pause, /queue resume, /queue later <minutes> <prompt>, /queue inspect <id>, /queue move <id> top|up|down, /queue run <id>",
2156
2383
  });
2157
2384
  return;
2158
2385
  }
@@ -2218,6 +2445,34 @@ export function createBot(config, registry) {
2218
2445
  }
2219
2446
  if (argument) {
2220
2447
  const parts = argument.split(/\s+/).filter(Boolean);
2448
+ if (parts[0]?.toLowerCase() === "delete" && parts[1]) {
2449
+ const selected = reports.find((report) => report.turnId === parts[1] || report.turnId.startsWith(parts[1]));
2450
+ if (!selected) {
2451
+ await safeReply(ctx, escapeHTML(`No artifact turn found for "${parts[1]}".`), {
2452
+ fallbackText: `No artifact turn found for "${parts[1]}".`,
2453
+ });
2454
+ return;
2455
+ }
2456
+ const removed = await removeArtifactTurn(workspace, selected.turnId);
2457
+ const text = removed ? `Deleted artifact turn: ${selected.turnId}` : `Artifact turn not found: ${selected.turnId}`;
2458
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2459
+ return;
2460
+ }
2461
+ const filtered = filterArtifactReports(reports, argument);
2462
+ if (filtered) {
2463
+ if (filtered.length === 0) {
2464
+ await safeReply(ctx, escapeHTML(`No artifacts matched "${argument}".`), {
2465
+ fallbackText: `No artifacts matched "${argument}".`,
2466
+ });
2467
+ return;
2468
+ }
2469
+ const rendered = renderArtifactReports(filtered);
2470
+ await safeReply(ctx, rendered.html, {
2471
+ fallbackText: rendered.plain,
2472
+ replyMarkup: buildArtifactActionsKeyboard(filtered),
2473
+ });
2474
+ return;
2475
+ }
2221
2476
  const shouldZip = parts[0]?.toLowerCase() === "zip";
2222
2477
  const requestedTurn = shouldZip ? parts[1] : parts[0];
2223
2478
  const selected = !requestedTurn || requestedTurn.toLowerCase() === "latest"
@@ -3641,6 +3896,8 @@ export async function registerCommands(bot) {
3641
3896
  await bot.api.setMyCommands([
3642
3897
  { command: "start", description: "Welcome & status" },
3643
3898
  { command: "help", description: "Command reference" },
3899
+ { command: "channels", description: "Messaging adapter status" },
3900
+ { command: "agents", description: "Agent adapter status" },
3644
3901
  { command: "agent", description: "Select Codex or Pi" },
3645
3902
  { command: "new", description: "Start a new thread" },
3646
3903
  { command: "session", description: "Current thread details" },
@@ -3670,11 +3927,15 @@ export async function registerCommands(bot) {
3670
3927
  { command: "tasks", description: "Current turn progress" },
3671
3928
  { command: "progress", description: "Current turn progress" },
3672
3929
  { command: "activity", description: "Thread activity timeline" },
3930
+ { command: "audit", description: "Admin: recent audit events" },
3673
3931
  { command: "status", description: "Connector runtime status" },
3674
3932
  { command: "health", description: "Connector health report" },
3675
3933
  { command: "version", description: "Connector version" },
3676
3934
  { command: "logs", description: "Admin: show connector logs" },
3677
3935
  { command: "diagnostics", description: "Admin: connector diagnostics" },
3936
+ { command: "lock", description: "Lock session writes to you" },
3937
+ { command: "unlock", description: "Release session write lock" },
3938
+ { command: "locks", description: "List session write locks" },
3678
3939
  { command: "restart", description: "Admin: restart connector" },
3679
3940
  { command: "update", description: "Admin: update connector" },
3680
3941
  { command: "handback", description: "Hand session back to CLI" },
@@ -3688,11 +3949,210 @@ function renderArtifactReports(reports) {
3688
3949
  const skipped = report.skippedCount > 0 ? `, ${report.skippedCount} skipped` : "";
3689
3950
  return `${index + 1}. ${report.turnId} · ${formatRelativeTime(report.updatedAt)} · ${report.artifacts.length} file${report.artifacts.length === 1 ? "" : "s"} · ${size}${skipped}`;
3690
3951
  });
3691
- const usage = "Tap an action below, or use /artifacts latest, /artifacts zip latest, or /artifacts <turn-id>.";
3952
+ const usage = "Tap an action below, or use /artifacts latest, /artifacts zip latest, /artifacts images, /artifacts docs, /artifacts search <text>, or /artifacts delete <turn-id>.";
3692
3953
  const plain = ["Recent artifacts:", ...lines, "", usage].join("\n");
3693
3954
  const html = ["<b>Recent artifacts:</b>", ...lines.map(escapeHTML), "", escapeHTML(usage)].join("\n");
3694
3955
  return { html, plain };
3695
3956
  }
3957
+ function renderVersionCheckPlain(check) {
3958
+ const icon = versionStatusIcon(check);
3959
+ const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
3960
+ return `${label}: ${icon} ${formatVersionCheckDetailPlain(check)}`;
3961
+ }
3962
+ function renderVersionCheckHTML(check) {
3963
+ const icon = versionStatusIcon(check);
3964
+ const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
3965
+ return `<b>${escapeHTML(label)}:</b> ${icon} ${formatVersionCheckDetailHTML(check)}`;
3966
+ }
3967
+ function formatCliPathPlain(label, cliPath, fallback) {
3968
+ return cliPath ? `${label} path: ${cliPath}` : `${label}: ${fallback}`;
3969
+ }
3970
+ function formatCliPathHTML(label, cliPath, fallback) {
3971
+ return cliPath
3972
+ ? `<b>${escapeHTML(label)} path:</b> <code>${escapeHTML(cliPath)}</code>`
3973
+ : `<b>${escapeHTML(label)}:</b> <code>${escapeHTML(fallback)}</code>`;
3974
+ }
3975
+ function formatVersionCheckDetailPlain(check) {
3976
+ if (check.status === "not-installed") {
3977
+ return "not installed";
3978
+ }
3979
+ if (check.status === "outdated") {
3980
+ return `${check.installedLabel} (latest ${check.latestVersion ?? "unknown"})`;
3981
+ }
3982
+ if (check.status === "current") {
3983
+ return `${check.installedLabel} (latest)`;
3984
+ }
3985
+ return `${check.installedLabel} (latest unknown${check.detail ? `: ${check.detail}` : ""})`;
3986
+ }
3987
+ function formatVersionCheckDetailHTML(check) {
3988
+ if (check.status === "not-installed") {
3989
+ return "<code>not installed</code>";
3990
+ }
3991
+ if (check.status === "outdated") {
3992
+ return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest ${escapeHTML(check.latestVersion ?? "unknown")})</i>`;
3993
+ }
3994
+ if (check.status === "current") {
3995
+ return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest)</i>`;
3996
+ }
3997
+ return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest unknown${check.detail ? `: ${escapeHTML(check.detail)}` : ""})</i>`;
3998
+ }
3999
+ function versionStatusIcon(check) {
4000
+ return check.status === "current" ? "✅" : "⚠️";
4001
+ }
4002
+ function parseLogsCommand(argument) {
4003
+ const tokens = argument.split(/\s+/).filter(Boolean);
4004
+ let target = "connector";
4005
+ let lines = 80;
4006
+ for (const token of tokens) {
4007
+ const normalized = token.toLowerCase();
4008
+ if (normalized === "connector" || normalized === "main") {
4009
+ target = "connector";
4010
+ continue;
4011
+ }
4012
+ if (normalized === "update" || normalized === "updates") {
4013
+ target = "update";
4014
+ continue;
4015
+ }
4016
+ if (normalized === "all") {
4017
+ target = "all";
4018
+ continue;
4019
+ }
4020
+ const parsedLines = Number.parseInt(token, 10);
4021
+ if (!Number.isNaN(parsedLines)) {
4022
+ lines = parsedLines;
4023
+ }
4024
+ }
4025
+ return { target, lines };
4026
+ }
4027
+ function renderLogTailPlain(title, tail) {
4028
+ return [
4029
+ `${title} log tail`,
4030
+ `File: ${tail.filePath}`,
4031
+ `Updated: ${tail.updatedAt ? formatLogDate(tail.updatedAt) : "-"}`,
4032
+ `Lines: ${tail.lineCount}/${tail.requestedLines}`,
4033
+ "",
4034
+ tail.plain || "(empty)",
4035
+ ].join("\n");
4036
+ }
4037
+ function renderLogTailHTML(title, tail) {
4038
+ const body = tail.plain
4039
+ ? tail.plain.split("\n").map(renderLogLineHTML).join("\n")
4040
+ : "<code>(empty)</code>";
4041
+ return [
4042
+ `<b>${escapeHTML(title)} log tail</b>`,
4043
+ `<b>File:</b> <code>${escapeHTML(tail.filePath)}</code>`,
4044
+ `<b>Updated:</b> <code>${escapeHTML(tail.updatedAt ? formatLogDate(tail.updatedAt) : "-")}</code>`,
4045
+ `<b>Lines:</b> <code>${tail.lineCount}/${tail.requestedLines}</code>`,
4046
+ "",
4047
+ body,
4048
+ ].join("\n");
4049
+ }
4050
+ function formatLogDate(date) {
4051
+ return [
4052
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
4053
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
4054
+ ].join(" ");
4055
+ }
4056
+ function renderLogLineHTML(line) {
4057
+ const structured = line.match(/^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|unknown time\s*)\s+(?<level>INFO|WARN|ERROR)\s+(?<message>.*)$/);
4058
+ if (structured?.groups) {
4059
+ const level = structured.groups.level;
4060
+ const levelHtml = level === "INFO" ? escapeHTML(level) : `<b>${escapeHTML(level)}</b>`;
4061
+ return [
4062
+ `<code>${escapeHTML(structured.groups.timestamp.trim())}</code>`,
4063
+ levelHtml,
4064
+ escapeHTML(structured.groups.message),
4065
+ ].join(" ");
4066
+ }
4067
+ return escapeHTML(line);
4068
+ }
4069
+ function renderAuditEvents(events) {
4070
+ if (events.length === 0) {
4071
+ return {
4072
+ plain: "Audit log is empty.",
4073
+ html: escapeHTML("Audit log is empty."),
4074
+ };
4075
+ }
4076
+ const lines = events.map((event) => {
4077
+ const time = formatLocalDateTime(new Date(event.timestamp));
4078
+ const actor = event.actorId ? `user ${event.actorId}` : "system";
4079
+ const prompt = event.promptId ? ` · ${event.promptId}` : "";
4080
+ const detail = event.detail ? ` · ${trimLine(event.detail, 90)}` : "";
4081
+ const description = event.description ? ` · ${trimLine(event.description, 90)}` : "";
4082
+ return `${time} · ${event.status.toUpperCase()} · ${event.action} · ${actor}${prompt}${description}${detail}`;
4083
+ });
4084
+ return {
4085
+ plain: ["Audit:", ...lines].join("\n"),
4086
+ html: [
4087
+ "<b>Audit:</b>",
4088
+ ...lines.map((line) => escapeHTML(line)),
4089
+ ].join("\n"),
4090
+ };
4091
+ }
4092
+ function renderSessionLocks(locks) {
4093
+ if (locks.length === 0) {
4094
+ return {
4095
+ plain: "No active session locks.",
4096
+ html: escapeHTML("No active session locks."),
4097
+ };
4098
+ }
4099
+ const lines = locks.map((lock) => {
4100
+ const expires = lock.expiresAt ? ` · expires ${formatLocalDateTime(new Date(lock.expiresAt))}` : "";
4101
+ return `${lock.contextKey} · ${formatLockOwner(lock)}${expires}`;
4102
+ });
4103
+ return {
4104
+ plain: ["Session locks:", ...lines].join("\n"),
4105
+ html: ["<b>Session locks:</b>", ...lines.map((line) => escapeHTML(line))].join("\n"),
4106
+ };
4107
+ }
4108
+ function renderQueuedPromptDetail(item) {
4109
+ const lines = [
4110
+ "Queued prompt:",
4111
+ `ID: ${item.id}`,
4112
+ `Created: ${formatLocalDateTime(new Date(item.createdAt))}`,
4113
+ item.notBefore ? `Scheduled: ${formatLocalDateTime(new Date(item.notBefore))}` : undefined,
4114
+ `Attempts: ${item.attempts ?? 0}`,
4115
+ item.lastError ? `Last error: ${item.lastError}` : undefined,
4116
+ `Description: ${item.description}`,
4117
+ ].filter((line) => Boolean(line));
4118
+ return {
4119
+ plain: lines.join("\n"),
4120
+ html: [
4121
+ "<b>Queued prompt:</b>",
4122
+ `<b>ID:</b> <code>${escapeHTML(item.id)}</code>`,
4123
+ `<b>Created:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.createdAt)))}</code>`,
4124
+ item.notBefore ? `<b>Scheduled:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.notBefore)))}</code>` : undefined,
4125
+ `<b>Attempts:</b> <code>${item.attempts ?? 0}</code>`,
4126
+ item.lastError ? `<b>Last error:</b> ${escapeHTML(item.lastError)}` : undefined,
4127
+ `<b>Description:</b> ${escapeHTML(item.description)}`,
4128
+ ].filter((line) => Boolean(line)).join("\n"),
4129
+ };
4130
+ }
4131
+ function formatLockOwner(lock) {
4132
+ if (!lock) {
4133
+ return "nobody";
4134
+ }
4135
+ return lock.ownerName ? `${lock.ownerName} (${lock.ownerId})` : `user ${lock.ownerId}`;
4136
+ }
4137
+ function formatTelegramName(ctx) {
4138
+ const firstName = ctx.from?.first_name?.trim();
4139
+ const lastName = ctx.from?.last_name?.trim();
4140
+ const username = ctx.from?.username?.trim();
4141
+ const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
4142
+ return fullName || (username ? `@${username}` : undefined);
4143
+ }
4144
+ function formatLocalDateTime(date) {
4145
+ if (Number.isNaN(date.getTime())) {
4146
+ return "-";
4147
+ }
4148
+ return [
4149
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
4150
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
4151
+ ].join(" ");
4152
+ }
4153
+ function pad2(value) {
4154
+ return String(value).padStart(2, "0");
4155
+ }
3696
4156
  function buildArtifactActionsKeyboard(reports) {
3697
4157
  const keyboard = new InlineKeyboard();
3698
4158
  for (const [index, report] of reports.slice(0, 5).entries()) {
@@ -3705,6 +4165,35 @@ function buildArtifactActionsKeyboard(reports) {
3705
4165
  }
3706
4166
  return keyboard;
3707
4167
  }
4168
+ function filterArtifactReports(reports, argument) {
4169
+ const normalized = argument.trim().toLowerCase();
4170
+ if (!normalized) {
4171
+ return null;
4172
+ }
4173
+ let predicate = null;
4174
+ if (normalized === "images" || normalized === "image" || normalized === "photos") {
4175
+ predicate = (artifact) => isTelegramImagePreview(artifact);
4176
+ }
4177
+ else if (normalized === "docs" || normalized === "documents" || normalized === "files") {
4178
+ predicate = (artifact) => !isTelegramImagePreview(artifact);
4179
+ }
4180
+ else if (normalized.startsWith("search ")) {
4181
+ const query = normalized.slice("search ".length).trim();
4182
+ if (!query) {
4183
+ return [];
4184
+ }
4185
+ predicate = (artifact) => artifact.name.toLowerCase().includes(query);
4186
+ }
4187
+ if (!predicate) {
4188
+ return null;
4189
+ }
4190
+ return reports
4191
+ .map((report) => ({
4192
+ ...report,
4193
+ artifacts: report.artifacts.filter(predicate),
4194
+ }))
4195
+ .filter((report) => report.artifacts.length > 0);
4196
+ }
3708
4197
  function renderProgressPlain(progress, queueLength, busyState, info) {
3709
4198
  const busyFlags = formatBusyFlags(busyState);
3710
4199
  if (!progress) {
@@ -3966,6 +4455,8 @@ function renderDiagnosticsPlain(config, registry, health, authenticated, role, q
3966
4455
  `PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
3967
4456
  `App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
3968
4457
  `Workspace: ${config.workspace}`,
4458
+ `State backend: ${config.stateBackend}`,
4459
+ `Telegram transport: ${config.telegramTransport}`,
3969
4460
  `Codex CLI: ${health.codexCli}`,
3970
4461
  `Pi CLI: ${health.piCli}`,
3971
4462
  `Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
@@ -3986,6 +4477,8 @@ function renderDiagnosticsPlain(config, registry, health, authenticated, role, q
3986
4477
  `Artifact retention: ${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs`,
3987
4478
  `Workspace allowed/warn roots: ${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}`,
3988
4479
  `Allowed users/chats/admins/readonly: ${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}`,
4480
+ `Session lock TTL: ${config.sessionLockTtlMs} ms`,
4481
+ `Audit max events: ${config.auditMaxEvents}`,
3989
4482
  `Loaded sessions: ${contexts.length}`,
3990
4483
  `Current queue: ${queueLength}`,
3991
4484
  `Current progress: ${progress?.status ?? "idle"}`,
@@ -4002,6 +4495,8 @@ function renderDiagnosticsHTML(config, registry, health, authenticated, role, qu
4002
4495
  `<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
4003
4496
  `<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
4004
4497
  `<b>Workspace:</b> <code>${escapeHTML(config.workspace)}</code>`,
4498
+ `<b>State backend:</b> <code>${escapeHTML(config.stateBackend)}</code>`,
4499
+ `<b>Telegram transport:</b> <code>${escapeHTML(config.telegramTransport)}</code>`,
4005
4500
  `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4006
4501
  `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4007
4502
  `<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
@@ -4022,6 +4517,8 @@ function renderDiagnosticsHTML(config, registry, health, authenticated, role, qu
4022
4517
  `<b>Artifact retention:</b> <code>${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs</code>`,
4023
4518
  `<b>Workspace allowed/warn roots:</b> <code>${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}</code>`,
4024
4519
  `<b>Allowed users/chats/admins/readonly:</b> <code>${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}</code>`,
4520
+ `<b>Session lock TTL:</b> <code>${config.sessionLockTtlMs} ms</code>`,
4521
+ `<b>Audit max events:</b> <code>${config.auditMaxEvents}</code>`,
4025
4522
  `<b>Loaded sessions:</b> <code>${contexts.length}</code>`,
4026
4523
  `<b>Current queue:</b> <code>${queueLength}</code>`,
4027
4524
  `<b>Current progress:</b> <code>${escapeHTML(progress?.status ?? "idle")}</code>`,