@nordbyte/nordrelay 0.4.1 → 0.5.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.
Files changed (57) hide show
  1. package/.env.example +155 -64
  2. package/README.md +81 -65
  3. package/dist/access-control.js +126 -115
  4. package/dist/agent-updates.js +62 -9
  5. package/dist/bot-rendering.js +838 -0
  6. package/dist/bot-ui.js +1 -0
  7. package/dist/bot.js +342 -2498
  8. package/dist/channel-actions.js +8 -8
  9. package/dist/channel-runtime.js +89 -0
  10. package/dist/config-metadata.js +238 -0
  11. package/dist/config.js +0 -58
  12. package/dist/index.js +8 -0
  13. package/dist/operations.js +63 -9
  14. package/dist/relay-artifact-service.js +126 -0
  15. package/dist/relay-external-activity-monitor.js +216 -0
  16. package/dist/relay-queue-service.js +66 -0
  17. package/dist/relay-runtime-types.js +1 -0
  18. package/dist/relay-runtime.js +96 -354
  19. package/dist/settings-service.js +2 -117
  20. package/dist/support-bundle.js +205 -0
  21. package/dist/telegram-access-commands.js +123 -0
  22. package/dist/telegram-access-middleware.js +129 -0
  23. package/dist/telegram-agent-commands.js +212 -0
  24. package/dist/telegram-artifact-commands.js +139 -0
  25. package/dist/telegram-channel-runtime.js +132 -0
  26. package/dist/telegram-command-menu.js +55 -0
  27. package/dist/telegram-command-types.js +1 -0
  28. package/dist/telegram-diagnostics-command.js +102 -0
  29. package/dist/telegram-general-commands.js +52 -0
  30. package/dist/telegram-operational-commands.js +153 -0
  31. package/dist/telegram-output.js +216 -0
  32. package/dist/telegram-preference-commands.js +198 -0
  33. package/dist/telegram-queue-commands.js +278 -0
  34. package/dist/telegram-support-command.js +53 -0
  35. package/dist/telegram-update-commands.js +93 -0
  36. package/dist/user-management.js +708 -0
  37. package/dist/web-api-contract.js +104 -0
  38. package/dist/web-api-types.js +1 -0
  39. package/dist/web-dashboard-access-routes.js +163 -0
  40. package/dist/web-dashboard-artifact-routes.js +65 -0
  41. package/dist/web-dashboard-assets.js +35 -2
  42. package/dist/web-dashboard-http.js +143 -0
  43. package/dist/web-dashboard-pages.js +257 -0
  44. package/dist/web-dashboard-runtime-routes.js +92 -0
  45. package/dist/web-dashboard-session-routes.js +209 -0
  46. package/dist/web-dashboard-ui.js +14 -14
  47. package/dist/web-dashboard.js +330 -707
  48. package/dist/webui-assets/dashboard.css +989 -0
  49. package/dist/webui-assets/dashboard.js +1750 -0
  50. package/dist/zip-writer.js +83 -0
  51. package/package.json +13 -4
  52. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  53. package/plugins/nordrelay/commands/remote.md +1 -1
  54. package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
  55. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
  56. package/dist/web-dashboard-client.js +0 -275
  57. package/dist/web-dashboard-style.js +0 -9
@@ -1,7 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { createArtifactZipBundle, collectRecentWorkspaceArtifacts, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, persistWorkspaceArtifactReport, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
3
+ import { ensureOutDir } from "./artifacts.js";
5
4
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
6
5
  import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
7
6
  import { getAgentDiagnostics, getExternalSnapshotForSession, } from "./agent-activity.js";
@@ -17,16 +16,19 @@ import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
17
16
  import { clearLogFile, getAgentUpdateLogPath, getConnectorHealth, getConnectorLogPath, getPackageVersion, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
18
17
  import { checkPiAuthStatus } from "./pi-auth.js";
19
18
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
19
+ import { RelayArtifactService } from "./relay-artifact-service.js";
20
+ import { RelayExternalActivityMonitor } from "./relay-external-activity-monitor.js";
21
+ import { RelayQueueService } from "./relay-queue-service.js";
20
22
  import { renderSessionInfoPlain, renderSessionUsageRows } from "./session-format.js";
21
23
  import { SessionLockStore } from "./session-locks.js";
22
24
  import { SessionRegistry } from "./session-registry.js";
25
+ import { createSupportBundle } from "./support-bundle.js";
23
26
  import { transcribeAudio } from "./voice.js";
24
27
  import { WebActivityStore, WebChatStore, } from "./web-state.js";
25
28
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
26
29
  const WEB_CONTEXT_KEY = "web:dashboard";
27
30
  const MAX_WEB_SESSION_PAGE_SIZE = 50;
28
31
  const MAX_CHAT_HISTORY = 250;
29
- const MAX_TEXT_PREVIEW_BYTES = 256 * 1024;
30
32
  export class RelayRuntime {
31
33
  config;
32
34
  registry;
@@ -36,6 +38,9 @@ export class RelayRuntime {
36
38
  auditStore;
37
39
  lockStore;
38
40
  agentUpdates;
41
+ queueService;
42
+ artifactService;
43
+ externalActivityMonitor;
39
44
  subscribers = new Set();
40
45
  externalMonitor;
41
46
  draining = false;
@@ -43,7 +48,6 @@ export class RelayRuntime {
43
48
  accumulatedText = "";
44
49
  currentTurnStartedAt = 0;
45
50
  currentProgress = null;
46
- externalMirror = null;
47
51
  constructor(config) {
48
52
  this.config = config;
49
53
  this.registry = new SessionRegistry(config, {
@@ -55,12 +59,27 @@ export class RelayRuntime {
55
59
  this.activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
56
60
  this.auditStore = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
57
61
  this.lockStore = new SessionLockStore(config.workspace, config.stateBackend);
62
+ this.queueService = new RelayQueueService(this.promptStore, WEB_CONTEXT_KEY);
63
+ this.artifactService = new RelayArtifactService(config);
58
64
  this.agentUpdates = new AgentUpdateManager({
59
65
  onUpdate: (job) => this.broadcast({ type: "agent_update", job }),
60
66
  });
67
+ this.externalActivityMonitor = new RelayExternalActivityMonitor({
68
+ config,
69
+ getSession: () => this.getSession(true),
70
+ publicInfo: (session) => this.publicInfo(session),
71
+ queueLength: () => this.queueService.length(),
72
+ chatStore: this.chatStore,
73
+ chatHistory: () => this.chatHistory(),
74
+ persistWorkspaceArtifactsForTurn: (workspace, turnId, startedAt) => this.artifactService.persistWorkspaceArtifactsForTurn(workspace, turnId, startedAt),
75
+ drainQueue: () => this.drainQueue(),
76
+ appendActivity: (input) => this.appendActivity(input),
77
+ broadcast: (event) => this.broadcast(event),
78
+ broadcastStatus: (message, level) => this.broadcastStatus(message, level),
79
+ });
61
80
  if (config.codexExternalBusyCheckMs > 0) {
62
81
  this.externalMonitor = setInterval(() => {
63
- void this.monitorExternalActivity().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
82
+ void this.externalActivityMonitor.monitorSafe();
64
83
  }, config.codexExternalBusyCheckMs);
65
84
  this.externalMonitor.unref?.();
66
85
  }
@@ -131,18 +150,18 @@ export class RelayRuntime {
131
150
  agentUpdateJobs() {
132
151
  return this.agentUpdates.list();
133
152
  }
134
- startAgentUpdate(agentId) {
153
+ startAgentUpdate(agentId, operation = "update") {
135
154
  const job = this.agentUpdates.start(agentId, {
136
155
  piCliPath: this.config.piCliPath,
137
156
  hermesCliPath: this.config.hermesCliPath,
138
157
  openClawCliPath: this.config.openClawCliPath,
139
158
  claudeCodeCliPath: this.config.claudeCodeCliPath,
140
- });
141
- this.broadcastStatus(`${job.agentLabel} update started. Log: ${job.logPath}`, "warn");
159
+ }, operation);
160
+ this.broadcastStatus(`${job.agentLabel} ${operation} started. Log: ${job.logPath}`, "warn");
142
161
  this.appendActivity({
143
162
  source: "web",
144
163
  status: "info",
145
- type: "agent_update_started",
164
+ type: operation === "install" ? "agent_install_started" : "agent_update_started",
146
165
  agentId,
147
166
  threadId: null,
148
167
  workspace: this.config.workspace,
@@ -153,7 +172,7 @@ export class RelayRuntime {
153
172
  status: "ok",
154
173
  contextKey: WEB_CONTEXT_KEY,
155
174
  agentId,
156
- description: `update ${agentId}`,
175
+ description: `${operation} ${agentId}`,
157
176
  detail: job.summary,
158
177
  });
159
178
  return job;
@@ -161,6 +180,18 @@ export class RelayRuntime {
161
180
  agentUpdateLog(id) {
162
181
  return this.agentUpdates.readLog(id);
163
182
  }
183
+ deleteAgentUpdateLog(id) {
184
+ const job = this.agentUpdates.deleteLog(id);
185
+ this.appendAudit({
186
+ action: "command",
187
+ status: "ok",
188
+ contextKey: WEB_CONTEXT_KEY,
189
+ agentId: job.agentId,
190
+ description: `delete update log ${id}`,
191
+ detail: job.logPath,
192
+ });
193
+ return job;
194
+ }
164
195
  sendAgentUpdateInput(id, input) {
165
196
  return this.agentUpdates.sendInput(id, input);
166
197
  }
@@ -175,8 +206,8 @@ export class RelayRuntime {
175
206
  runtime: {
176
207
  stateBackend: this.config.stateBackend,
177
208
  sourceWorkspace: this.config.workspace,
178
- queuePaused: this.promptStore.isPaused(WEB_CONTEXT_KEY),
179
- externalMirror: this.externalMirror ? { ...this.externalMirror } : null,
209
+ queuePaused: this.queueService.isPaused(),
210
+ externalMirror: this.externalActivityMonitor.snapshot(),
180
211
  agentDiagnostics: getAgentDiagnostics(await this.getSession(true), this.config),
181
212
  },
182
213
  };
@@ -224,18 +255,14 @@ export class RelayRuntime {
224
255
  }
225
256
  permissions() {
226
257
  return {
227
- telegramAllowAnyChat: this.config.telegramAllowAnyChat,
228
- telegramAdminUserIds: this.config.telegramAdminUserIds,
229
- telegramAllowedUserIds: this.config.telegramAllowedUserIds,
230
- telegramReadOnlyUserIds: this.config.telegramReadOnlyUserIds,
231
- telegramAllowedChatIds: this.config.telegramAllowedChatIds,
232
- telegramRolePolicies: this.config.telegramRolePolicies,
258
+ mode: "users",
259
+ message: "Access is managed by NordRelay users, groups, Telegram identities, and Telegram chat access records.",
233
260
  };
234
261
  }
235
262
  tasks() {
236
263
  return {
237
264
  current: this.currentProgress ? { ...this.currentProgress, tools: [...this.currentProgress.tools] } : null,
238
- external: this.externalTask(),
265
+ external: this.externalActivityMonitor.task(),
239
266
  queue: this.queue(),
240
267
  queuePaused: this.queuePaused(),
241
268
  recent: this.activity({ limit: 20 }),
@@ -244,6 +271,32 @@ export class RelayRuntime {
244
271
  audit(limit = 50) {
245
272
  return this.auditStore.list(limit);
246
273
  }
274
+ async supportBundle() {
275
+ const bundle = await createSupportBundle({
276
+ config: this.config,
277
+ diagnostics: await this.diagnostics(),
278
+ adapterHealth: await this.adapterHealth(),
279
+ auditEvents: this.auditStore.list(100),
280
+ agentUpdateJobs: this.agentUpdates.list(),
281
+ source: "web",
282
+ });
283
+ this.appendActivity({
284
+ source: "web",
285
+ status: "info",
286
+ type: "diagnostics_bundle_exported",
287
+ threadId: null,
288
+ workspace: this.config.workspace,
289
+ detail: bundle.path,
290
+ });
291
+ this.appendAudit({
292
+ action: "command",
293
+ status: "ok",
294
+ contextKey: WEB_CONTEXT_KEY,
295
+ description: "export diagnostics bundle",
296
+ detail: bundle.path,
297
+ });
298
+ return bundle;
299
+ }
247
300
  locks() {
248
301
  return this.lockStore.list();
249
302
  }
@@ -450,10 +503,11 @@ export class RelayRuntime {
450
503
  return { removed, messages };
451
504
  }
452
505
  activity(options = {}) {
453
- return this.activityStore.list(options).map((event) => this.enrichActivityEvent(event));
506
+ const currentInfo = this.registry.get(WEB_CONTEXT_KEY)?.getInfo();
507
+ return this.activityStore.list(options).map((event) => this.enrichActivityEvent(event, currentInfo));
454
508
  }
455
509
  async retry() {
456
- const cached = this.promptStore.getLastPrompt(WEB_CONTEXT_KEY);
510
+ const cached = this.queueService.getLastPrompt();
457
511
  if (!cached) {
458
512
  throw new Error("Nothing to retry. Send a message first.");
459
513
  }
@@ -748,7 +802,7 @@ export class RelayRuntime {
748
802
  const session = await this.getSession(false);
749
803
  const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
750
804
  if (session.isProcessing() || external?.activity.active) {
751
- const queued = this.promptStore.enqueue(WEB_CONTEXT_KEY, envelope);
805
+ const queued = this.queueService.enqueue(envelope);
752
806
  const info = this.publicInfo(session);
753
807
  this.appendActivity({
754
808
  source: "web",
@@ -760,7 +814,7 @@ export class RelayRuntime {
760
814
  prompt: envelope.description,
761
815
  detail: external?.activity.active
762
816
  ? `Queued because ${external.agentLabel} CLI is still processing another task.`
763
- : `Queued at position ${this.promptStore.list(WEB_CONTEXT_KEY).length}.`,
817
+ : `Queued at position ${this.queueService.length()}.`,
764
818
  });
765
819
  this.appendAudit({
766
820
  action: "prompt_queued",
@@ -773,7 +827,7 @@ export class RelayRuntime {
773
827
  description: envelope.description,
774
828
  });
775
829
  if (external?.activity.active) {
776
- this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.promptStore.list(WEB_CONTEXT_KEY).length} queued.`, "info");
830
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queueService.length()} queued.`, "info");
777
831
  }
778
832
  this.broadcastQueue();
779
833
  return { queued: true, queueId: queued.id };
@@ -784,30 +838,14 @@ export class RelayRuntime {
784
838
  return { queued: false };
785
839
  }
786
840
  queue() {
787
- return this.promptStore.list(WEB_CONTEXT_KEY).map(queueItemDto);
841
+ return this.queueService.list();
788
842
  }
789
843
  queuePaused() {
790
- return this.promptStore.isPaused(WEB_CONTEXT_KEY);
844
+ return this.queueService.isPaused();
791
845
  }
792
846
  queueAction(action, id) {
793
- if (action === "pause")
794
- this.promptStore.pause(WEB_CONTEXT_KEY);
795
- if (action === "resume")
796
- this.promptStore.resume(WEB_CONTEXT_KEY);
797
- if (action === "clear")
798
- this.promptStore.clear(WEB_CONTEXT_KEY);
799
- if (id && action === "cancel")
800
- this.promptStore.remove(WEB_CONTEXT_KEY, id);
801
- if (id && action === "top")
802
- this.promptStore.moveToTop(WEB_CONTEXT_KEY, id);
803
- if (id && action === "up")
804
- this.promptStore.moveUp(WEB_CONTEXT_KEY, id);
805
- if (id && action === "down")
806
- this.promptStore.moveDown(WEB_CONTEXT_KEY, id);
847
+ this.queueService.apply(action, id);
807
848
  if (id && action === "run") {
808
- const item = this.promptStore.remove(WEB_CONTEXT_KEY, id);
809
- if (item)
810
- this.promptStore.enqueueFront(WEB_CONTEXT_KEY, item);
811
849
  void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
812
850
  }
813
851
  this.appendActivity({
@@ -829,58 +867,23 @@ export class RelayRuntime {
829
867
  }
830
868
  async artifacts() {
831
869
  const session = await this.getSession(true);
832
- return (await listRecentArtifactReports(session.getInfo().workspace, 20, this.config.maxFileSize)).map(artifactDto);
870
+ return this.artifactService.list(session.getInfo().workspace, 20);
833
871
  }
834
872
  async artifact(turnId) {
835
873
  const session = await this.getSession(true);
836
- return getArtifactTurnReport(session.getInfo().workspace, turnId, this.config.maxFileSize);
874
+ return this.artifactService.get(session.getInfo().workspace, turnId);
837
875
  }
838
876
  async deleteArtifact(turnId) {
839
877
  const session = await this.getSession(true);
840
- return removeArtifactTurn(session.getInfo().workspace, turnId);
878
+ return this.artifactService.delete(session.getInfo().workspace, turnId);
841
879
  }
842
880
  async createArtifactZip(turnId) {
843
- const report = await this.artifact(turnId);
844
- if (!report) {
845
- return null;
846
- }
847
- const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
848
- maxFileSize: this.config.maxFileSize,
849
- bundleName: `nordrelay-artifacts-${turnId}.zip`,
850
- });
851
- return bundle ? { path: bundle.localPath, name: bundle.name } : null;
881
+ const session = await this.getSession(true);
882
+ return this.artifactService.createZip(session.getInfo().workspace, turnId);
852
883
  }
853
884
  async artifactPreview(turnId, relativePath) {
854
- const report = await this.artifact(turnId);
855
- const artifact = report?.artifacts.find((candidate) => candidate.relativePath.split(path.sep).join("/") === relativePath);
856
- if (!artifact) {
857
- return null;
858
- }
859
- const extension = path.extname(artifact.name).toLowerCase();
860
- if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
861
- return {
862
- kind: "image",
863
- name: artifact.name,
864
- sizeBytes: artifact.sizeBytes,
865
- };
866
- }
867
- if (!isPreviewableTextFile(extension, artifact.sizeBytes)) {
868
- return {
869
- kind: "unsupported",
870
- name: artifact.name,
871
- sizeBytes: artifact.sizeBytes,
872
- detail: artifact.sizeBytes > MAX_TEXT_PREVIEW_BYTES ? "File is too large for inline preview." : "File type is not previewable.",
873
- };
874
- }
875
- const buffer = await readFile(artifact.localPath);
876
- const truncated = buffer.byteLength > MAX_TEXT_PREVIEW_BYTES;
877
- return {
878
- kind: "text",
879
- name: artifact.name,
880
- sizeBytes: artifact.sizeBytes,
881
- truncated,
882
- text: buffer.subarray(0, MAX_TEXT_PREVIEW_BYTES).toString("utf8"),
883
- };
885
+ const session = await this.getSession(true);
886
+ return this.artifactService.preview(session.getInfo().workspace, turnId, relativePath);
884
887
  }
885
888
  async logs(target = "connector", lines = 100) {
886
889
  if (target === "update") {
@@ -924,143 +927,6 @@ export class RelayRuntime {
924
927
  this.registry.disposeAll();
925
928
  this.subscribers.clear();
926
929
  }
927
- async monitorExternalActivity() {
928
- const session = await this.getSession(true);
929
- const info = this.publicInfo(session);
930
- if (!info.capabilities.externalActivity || !info.threadId || session.isProcessing()) {
931
- return;
932
- }
933
- const snapshot = getExternalSnapshotForSession(session, this.config, {
934
- afterLine: this.externalMirror?.threadId === info.threadId ? this.externalMirror.lastLine : Number.MAX_SAFE_INTEGER,
935
- }) ?? getExternalSnapshotForSession(session, this.config, {
936
- maxEvents: 0,
937
- });
938
- if (!snapshot) {
939
- return;
940
- }
941
- if (!this.externalMirror || this.externalMirror.threadId !== snapshot.threadId || this.externalMirror.rolloutPath !== snapshot.sourcePath) {
942
- this.externalMirror = {
943
- threadId: snapshot.threadId,
944
- rolloutPath: snapshot.sourcePath,
945
- lastLine: snapshot.lineCount,
946
- turnId: snapshot.activity.turnId,
947
- startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
948
- };
949
- if (snapshot.activity.active) {
950
- this.startExternalTurn(snapshot);
951
- }
952
- return;
953
- }
954
- const mirror = this.externalMirror;
955
- if (snapshot.activity.active) {
956
- if (mirror.turnId !== snapshot.activity.turnId) {
957
- mirror.turnId = snapshot.activity.turnId;
958
- mirror.startedAt = snapshot.activity.startedAt?.toISOString() ?? null;
959
- mirror.latestAgentLine = undefined;
960
- this.startExternalTurn(snapshot);
961
- }
962
- this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine));
963
- mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
964
- mirror.latestStatus = externalStatusLine(snapshot, this.queue().length);
965
- this.broadcastStatus(mirror.latestStatus, "info");
966
- return;
967
- }
968
- const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
969
- if (terminalEvent && terminalEvent.lineNumber > mirror.lastLine) {
970
- const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
971
- const finalText = finalAgent?.text ?? snapshot.latestAgentMessage;
972
- const finalLine = finalAgent?.lineNumber ?? snapshot.lineCount;
973
- if (finalText && finalLine !== mirror.latestAgentLine) {
974
- this.chatStore.append({
975
- threadId: snapshot.threadId,
976
- role: "agent",
977
- text: finalText,
978
- source: "cli",
979
- turnId: terminalEvent.turnId ?? undefined,
980
- });
981
- this.broadcast({ type: "text_delta", id: terminalEvent.turnId ?? "cli", delta: finalText });
982
- mirror.latestAgentLine = finalLine;
983
- }
984
- const externalStartedAt = mirror.startedAt ? new Date(mirror.startedAt) : snapshot.activity.startedAt;
985
- this.broadcast({
986
- type: "turn_complete",
987
- id: terminalEvent.turnId ?? "cli",
988
- at: terminalEvent.timestamp?.toISOString() ?? new Date().toISOString(),
989
- });
990
- this.appendActivity({
991
- source: "cli",
992
- status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
993
- type: "cli_turn_finished",
994
- threadId: snapshot.threadId,
995
- workspace: info.workspace,
996
- agentId: info.agentId,
997
- prompt: snapshot.latestUserMessage ?? undefined,
998
- detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
999
- durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
1000
- });
1001
- if (externalStartedAt && terminalEvent.turnId) {
1002
- await this.persistWorkspaceArtifactsForTurn(info.workspace, terminalEvent.turnId, externalStartedAt);
1003
- }
1004
- mirror.latestStatus = `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`;
1005
- this.broadcastStatus(`${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
1006
- this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
1007
- await this.drainQueue();
1008
- }
1009
- mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
1010
- }
1011
- startExternalTurn(snapshot) {
1012
- const prompt = snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`;
1013
- this.chatStore.append({
1014
- threadId: snapshot.threadId,
1015
- role: "user",
1016
- text: prompt,
1017
- source: "cli",
1018
- turnId: snapshot.activity.turnId ?? undefined,
1019
- timestamp: snapshot.activity.startedAt?.toISOString(),
1020
- });
1021
- this.broadcast({
1022
- type: "turn_start",
1023
- id: snapshot.activity.turnId ?? "cli",
1024
- prompt,
1025
- at: snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString(),
1026
- source: "cli",
1027
- });
1028
- this.appendActivity({
1029
- source: "cli",
1030
- status: "running",
1031
- type: "cli_turn_started",
1032
- threadId: snapshot.threadId,
1033
- prompt,
1034
- detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
1035
- });
1036
- }
1037
- broadcastExternalEvents(snapshot, events) {
1038
- for (const event of events) {
1039
- if (event.kind === "tool" && event.status === "started") {
1040
- this.broadcast({
1041
- type: "tool_start",
1042
- id: snapshot.activity.turnId ?? "cli",
1043
- toolCallId: `cli-${event.lineNumber}`,
1044
- toolName: event.toolName ?? "tool",
1045
- });
1046
- this.appendActivity({
1047
- source: "cli",
1048
- status: "running",
1049
- type: "cli_tool_started",
1050
- threadId: snapshot.threadId,
1051
- detail: event.toolName ?? "tool",
1052
- });
1053
- }
1054
- if (event.kind === "tool" && event.status === "finished") {
1055
- this.broadcast({
1056
- type: "tool_end",
1057
- id: snapshot.activity.turnId ?? "cli",
1058
- toolCallId: `cli-${event.lineNumber}`,
1059
- isError: false,
1060
- });
1061
- }
1062
- }
1063
- }
1064
930
  async getSession(deferThreadStart) {
1065
931
  return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
1066
932
  }
@@ -1174,7 +1040,7 @@ export class RelayRuntime {
1174
1040
  outputChars: 0,
1175
1041
  tools: [],
1176
1042
  };
1177
- this.promptStore.setLastPrompt(WEB_CONTEXT_KEY, envelope);
1043
+ this.queueService.setLastPrompt(envelope);
1178
1044
  const startedDate = new Date();
1179
1045
  const startedAt = startedDate.toISOString();
1180
1046
  this.chatStore.append({
@@ -1232,7 +1098,7 @@ export class RelayRuntime {
1232
1098
  try {
1233
1099
  await session.prompt(envelope.input, callbacks);
1234
1100
  this.updateSession(session);
1235
- await this.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
1101
+ await this.artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
1236
1102
  if (this.accumulatedText.trim()) {
1237
1103
  this.chatStore.append({
1238
1104
  threadId: info.threadId ?? "pending",
@@ -1310,7 +1176,7 @@ export class RelayRuntime {
1310
1176
  }
1311
1177
  }
1312
1178
  async drainQueue() {
1313
- if (this.draining || this.promptStore.isPaused(WEB_CONTEXT_KEY)) {
1179
+ if (this.draining || this.queueService.isPaused()) {
1314
1180
  return;
1315
1181
  }
1316
1182
  this.draining = true;
@@ -1319,10 +1185,10 @@ export class RelayRuntime {
1319
1185
  while (!session.isProcessing()) {
1320
1186
  const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
1321
1187
  if (external?.activity.active) {
1322
- this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queue().length} queued.`, "info");
1188
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queueService.length()} queued.`, "info");
1323
1189
  return;
1324
1190
  }
1325
- const next = this.promptStore.dequeue(WEB_CONTEXT_KEY);
1191
+ const next = this.queueService.dequeue();
1326
1192
  this.broadcastQueue();
1327
1193
  if (!next) {
1328
1194
  return;
@@ -1346,11 +1212,10 @@ export class RelayRuntime {
1346
1212
  enrichActivityInput(input) {
1347
1213
  return this.enrichActivityFields(input);
1348
1214
  }
1349
- enrichActivityEvent(event) {
1350
- return this.enrichActivityFields(event);
1215
+ enrichActivityEvent(event, info) {
1216
+ return this.enrichActivityFields(event, info);
1351
1217
  }
1352
- enrichActivityFields(event) {
1353
- const info = this.registry.get(WEB_CONTEXT_KEY)?.getInfo();
1218
+ enrichActivityFields(event, info) {
1354
1219
  if (!info) {
1355
1220
  return !event.threadId && !event.workspace ? { ...event, workspace: this.config.workspace } : event;
1356
1221
  }
@@ -1393,31 +1258,6 @@ export class RelayRuntime {
1393
1258
  }
1394
1259
  this.updateCurrentProgress({ currentTool: toolName, lastTool: toolName });
1395
1260
  }
1396
- externalTask() {
1397
- if (!this.externalMirror) {
1398
- return null;
1399
- }
1400
- const startedAt = this.externalMirror.startedAt ?? new Date().toISOString();
1401
- const startedMs = new Date(startedAt).getTime();
1402
- return {
1403
- id: this.externalMirror.turnId ?? "cli",
1404
- source: "cli",
1405
- status: this.externalMirror.latestStatus?.includes("failed")
1406
- ? "failed"
1407
- : this.externalMirror.latestStatus?.includes("aborted")
1408
- ? "aborted"
1409
- : this.externalMirror.latestStatus?.includes("finished") || this.externalMirror.latestStatus?.includes("completed")
1410
- ? "completed"
1411
- : "running",
1412
- threadId: this.externalMirror.threadId,
1413
- startedAt,
1414
- updatedAt: new Date().toISOString(),
1415
- durationMs: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0,
1416
- outputChars: 0,
1417
- tools: [],
1418
- detail: this.externalMirror.latestStatus ?? this.externalMirror.rolloutPath,
1419
- };
1420
- }
1421
1261
  broadcastQueue() {
1422
1262
  this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
1423
1263
  }
@@ -1444,53 +1284,6 @@ export class RelayRuntime {
1444
1284
  capabilities: info.capabilities ?? CODEX_AGENT_CAPABILITIES,
1445
1285
  };
1446
1286
  }
1447
- async persistWorkspaceArtifactsForTurn(workspace, turnId, startedAt) {
1448
- const report = await collectRecentWorkspaceArtifacts(workspace, {
1449
- since: startedAt,
1450
- until: new Date(),
1451
- maxFileSize: this.config.maxFileSize,
1452
- limit: 20,
1453
- ignoreDirs: this.config.artifactIgnoreDirs,
1454
- ignoreGlobs: this.config.artifactIgnoreGlobs,
1455
- });
1456
- if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
1457
- return;
1458
- }
1459
- await persistWorkspaceArtifactReport(workspace, turnId, report);
1460
- }
1461
- }
1462
- function queueItemDto(item) {
1463
- return {
1464
- id: item.id,
1465
- description: item.description,
1466
- createdAt: new Date(item.createdAt).toISOString(),
1467
- attempts: item.attempts ?? 0,
1468
- notBefore: item.notBefore ? new Date(item.notBefore).toISOString() : undefined,
1469
- lastError: item.lastError,
1470
- };
1471
- }
1472
- function artifactDto(report) {
1473
- return {
1474
- turnId: report.turnId,
1475
- updatedAt: report.updatedAt.toISOString(),
1476
- source: report.source,
1477
- fileCount: report.artifacts.length,
1478
- totalSizeBytes: totalArtifactSize(report.artifacts),
1479
- skippedCount: report.skippedCount,
1480
- omittedCount: report.omittedCount,
1481
- artifacts: report.artifacts.map((artifact) => ({
1482
- name: artifact.name,
1483
- relativePath: artifact.relativePath.split(path.sep).join("/"),
1484
- sizeBytes: artifact.sizeBytes,
1485
- })),
1486
- };
1487
- }
1488
- function externalStatusLine(snapshot, queueLength) {
1489
- const elapsed = snapshot.activity.startedAt
1490
- ? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
1491
- : "-";
1492
- const tool = snapshot.latestToolName ?? "-";
1493
- return `${snapshot.agentLabel} CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
1494
1287
  }
1495
1288
  function cliHealthForAgent(agentId, health) {
1496
1289
  if (agentId === "pi") {
@@ -1548,23 +1341,6 @@ function hostLogoutCommand(info, config) {
1548
1341
  }
1549
1342
  return "codex logout";
1550
1343
  }
1551
- function durationFromDates(start, end) {
1552
- if (!start || !end) {
1553
- return undefined;
1554
- }
1555
- return Math.max(0, end.getTime() - start.getTime());
1556
- }
1557
- function formatDuration(seconds) {
1558
- if (!Number.isFinite(seconds) || seconds < 0) {
1559
- return "-";
1560
- }
1561
- if (seconds < 60) {
1562
- return `${Math.round(seconds)}s`;
1563
- }
1564
- const minutes = Math.floor(seconds / 60);
1565
- const remainder = Math.round(seconds % 60);
1566
- return `${minutes}m ${remainder}s`;
1567
- }
1568
1344
  function normalizeMimeType(value, name) {
1569
1345
  const configured = value?.trim();
1570
1346
  if (configured) {
@@ -1598,37 +1374,3 @@ function uploadFileDtos(files) {
1598
1374
  sizeBytes: file.sizeBytes,
1599
1375
  }));
1600
1376
  }
1601
- function isPreviewableTextFile(extension, sizeBytes) {
1602
- if (sizeBytes > MAX_TEXT_PREVIEW_BYTES * 4) {
1603
- return false;
1604
- }
1605
- return [
1606
- "",
1607
- ".c",
1608
- ".conf",
1609
- ".cpp",
1610
- ".css",
1611
- ".csv",
1612
- ".env",
1613
- ".go",
1614
- ".html",
1615
- ".java",
1616
- ".js",
1617
- ".json",
1618
- ".jsx",
1619
- ".log",
1620
- ".md",
1621
- ".py",
1622
- ".rb",
1623
- ".rs",
1624
- ".sh",
1625
- ".sql",
1626
- ".toml",
1627
- ".ts",
1628
- ".tsx",
1629
- ".txt",
1630
- ".xml",
1631
- ".yaml",
1632
- ".yml",
1633
- ].includes(extension);
1634
- }