@nordbyte/nordrelay 0.2.1 → 0.3.1

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