@nordbyte/nordrelay 0.5.0 → 0.5.2

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 (44) hide show
  1. package/.env.example +2 -0
  2. package/README.md +23 -14
  3. package/dist/access-control.js +2 -0
  4. package/dist/agent-updates.js +61 -10
  5. package/dist/bot-ui.js +1 -0
  6. package/dist/bot.js +142 -1065
  7. package/dist/channel-actions.js +8 -8
  8. package/dist/codex-cli.js +1 -1
  9. package/dist/config-metadata.js +2 -0
  10. package/dist/operations.js +233 -122
  11. package/dist/relay-artifact-service.js +126 -0
  12. package/dist/relay-external-activity-monitor.js +216 -0
  13. package/dist/relay-queue-service.js +66 -0
  14. package/dist/relay-runtime-types.js +1 -0
  15. package/dist/relay-runtime.js +119 -371
  16. package/dist/state-backend.js +3 -0
  17. package/dist/support-bundle.js +221 -0
  18. package/dist/telegram-agent-commands.js +212 -0
  19. package/dist/telegram-artifact-commands.js +139 -0
  20. package/dist/telegram-command-menu.js +1 -0
  21. package/dist/telegram-command-types.js +1 -0
  22. package/dist/telegram-diagnostics-command.js +102 -0
  23. package/dist/telegram-general-commands.js +52 -0
  24. package/dist/telegram-operational-commands.js +153 -0
  25. package/dist/telegram-preference-commands.js +198 -0
  26. package/dist/telegram-queue-commands.js +278 -0
  27. package/dist/telegram-support-command.js +53 -0
  28. package/dist/telegram-update-commands.js +6 -1
  29. package/dist/web-api-contract.js +79 -31
  30. package/dist/web-api-types.js +1 -0
  31. package/dist/web-dashboard-access-routes.js +163 -0
  32. package/dist/web-dashboard-artifact-routes.js +65 -0
  33. package/dist/web-dashboard-assets.js +2 -0
  34. package/dist/web-dashboard-http.js +143 -0
  35. package/dist/web-dashboard-pages.js +257 -0
  36. package/dist/web-dashboard-runtime-routes.js +92 -0
  37. package/dist/web-dashboard-session-routes.js +209 -0
  38. package/dist/web-dashboard.js +44 -882
  39. package/dist/webui-assets/dashboard.css +74 -4
  40. package/dist/webui-assets/dashboard.js +163 -24
  41. package/dist/zip-writer.js +83 -0
  42. package/package.json +10 -4
  43. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  44. package/plugins/nordrelay/scripts/nordrelay.mjs +258 -5
@@ -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,15 +38,16 @@ 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;
42
- externalMonitorRunning = false;
43
47
  currentTurnId = null;
44
48
  accumulatedText = "";
45
49
  currentTurnStartedAt = 0;
46
50
  currentProgress = null;
47
- externalMirror = null;
48
51
  constructor(config) {
49
52
  this.config = config;
50
53
  this.registry = new SessionRegistry(config, {
@@ -56,12 +59,27 @@ export class RelayRuntime {
56
59
  this.activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
57
60
  this.auditStore = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
58
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);
59
64
  this.agentUpdates = new AgentUpdateManager({
60
65
  onUpdate: (job) => this.broadcast({ type: "agent_update", job }),
61
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
+ });
62
80
  if (config.codexExternalBusyCheckMs > 0) {
63
81
  this.externalMonitor = setInterval(() => {
64
- void this.monitorExternalActivitySafe();
82
+ void this.externalActivityMonitor.monitorSafe();
65
83
  }, config.codexExternalBusyCheckMs);
66
84
  this.externalMonitor.unref?.();
67
85
  }
@@ -87,10 +105,16 @@ export class RelayRuntime {
87
105
  };
88
106
  }
89
107
  async status() {
108
+ const cliOptions = this.cliPathOptions();
109
+ const [health, versionChecks, snapshot] = await Promise.all([
110
+ getConnectorHealth(cliOptions),
111
+ getVersionChecks(cliOptions),
112
+ this.snapshot(),
113
+ ]);
90
114
  return {
91
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
92
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
93
- snapshot: await this.snapshot(),
115
+ health,
116
+ versionChecks,
117
+ snapshot,
94
118
  };
95
119
  }
96
120
  async bootstrapStatus() {
@@ -103,10 +127,16 @@ export class RelayRuntime {
103
127
  };
104
128
  }
105
129
  async version() {
130
+ const cliOptions = this.cliPathOptions();
131
+ const [health, state, versionChecks] = await Promise.all([
132
+ getConnectorHealth(cliOptions),
133
+ readConnectorState(),
134
+ getVersionChecks(cliOptions),
135
+ ]);
106
136
  return {
107
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
108
- state: await readConnectorState(),
109
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
137
+ health,
138
+ state,
139
+ versionChecks,
110
140
  };
111
141
  }
112
142
  updateConnector() {
@@ -132,18 +162,18 @@ export class RelayRuntime {
132
162
  agentUpdateJobs() {
133
163
  return this.agentUpdates.list();
134
164
  }
135
- startAgentUpdate(agentId) {
165
+ startAgentUpdate(agentId, operation = "update") {
136
166
  const job = this.agentUpdates.start(agentId, {
137
167
  piCliPath: this.config.piCliPath,
138
168
  hermesCliPath: this.config.hermesCliPath,
139
169
  openClawCliPath: this.config.openClawCliPath,
140
170
  claudeCodeCliPath: this.config.claudeCodeCliPath,
141
- });
142
- this.broadcastStatus(`${job.agentLabel} update started. Log: ${job.logPath}`, "warn");
171
+ }, operation);
172
+ this.broadcastStatus(`${job.agentLabel} ${operation} started. Log: ${job.logPath}`, "warn");
143
173
  this.appendActivity({
144
174
  source: "web",
145
175
  status: "info",
146
- type: "agent_update_started",
176
+ type: operation === "install" ? "agent_install_started" : "agent_update_started",
147
177
  agentId,
148
178
  threadId: null,
149
179
  workspace: this.config.workspace,
@@ -154,7 +184,7 @@ export class RelayRuntime {
154
184
  status: "ok",
155
185
  contextKey: WEB_CONTEXT_KEY,
156
186
  agentId,
157
- description: `update ${agentId}`,
187
+ description: `${operation} ${agentId}`,
158
188
  detail: job.summary,
159
189
  });
160
190
  return job;
@@ -181,22 +211,32 @@ export class RelayRuntime {
181
211
  return this.agentUpdates.cancel(id);
182
212
  }
183
213
  async diagnostics() {
214
+ const cliOptions = this.cliPathOptions();
215
+ const [health, versionChecks, snapshot, session] = await Promise.all([
216
+ getConnectorHealth(cliOptions),
217
+ getVersionChecks(cliOptions),
218
+ this.snapshot(),
219
+ this.getSession(true),
220
+ ]);
184
221
  return {
185
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
186
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
187
- snapshot: await this.snapshot(),
222
+ health,
223
+ versionChecks,
224
+ snapshot,
188
225
  runtime: {
189
226
  stateBackend: this.config.stateBackend,
190
227
  sourceWorkspace: this.config.workspace,
191
- queuePaused: this.promptStore.isPaused(WEB_CONTEXT_KEY),
192
- externalMirror: this.externalMirror ? { ...this.externalMirror } : null,
193
- agentDiagnostics: getAgentDiagnostics(await this.getSession(true), this.config),
228
+ queuePaused: this.queueService.isPaused(),
229
+ externalMirror: this.externalActivityMonitor.snapshot(),
230
+ agentDiagnostics: getAgentDiagnostics(session, this.config),
194
231
  },
195
232
  };
196
233
  }
197
234
  async adapterHealth() {
198
- const health = await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
199
- const versions = await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
235
+ const cliOptions = this.cliPathOptions();
236
+ const [health, versions] = await Promise.all([
237
+ getConnectorHealth(cliOptions),
238
+ getVersionChecks(cliOptions),
239
+ ]);
200
240
  return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
201
241
  const enabled = enabledAgents(this.config).includes(descriptor.id);
202
242
  const auth = descriptor.capabilities.auth && enabled
@@ -244,7 +284,7 @@ export class RelayRuntime {
244
284
  tasks() {
245
285
  return {
246
286
  current: this.currentProgress ? { ...this.currentProgress, tools: [...this.currentProgress.tools] } : null,
247
- external: this.externalTask(),
287
+ external: this.externalActivityMonitor.task(),
248
288
  queue: this.queue(),
249
289
  queuePaused: this.queuePaused(),
250
290
  recent: this.activity({ limit: 20 }),
@@ -253,6 +293,32 @@ export class RelayRuntime {
253
293
  audit(limit = 50) {
254
294
  return this.auditStore.list(limit);
255
295
  }
296
+ async supportBundle() {
297
+ const bundle = await createSupportBundle({
298
+ config: this.config,
299
+ diagnostics: await this.diagnostics(),
300
+ adapterHealth: await this.adapterHealth(),
301
+ auditEvents: this.auditStore.list(100),
302
+ agentUpdateJobs: this.agentUpdates.list(),
303
+ source: "web",
304
+ });
305
+ this.appendActivity({
306
+ source: "web",
307
+ status: "info",
308
+ type: "diagnostics_bundle_exported",
309
+ threadId: null,
310
+ workspace: this.config.workspace,
311
+ detail: bundle.path,
312
+ });
313
+ this.appendAudit({
314
+ action: "command",
315
+ status: "ok",
316
+ contextKey: WEB_CONTEXT_KEY,
317
+ description: "export diagnostics bundle",
318
+ detail: bundle.path,
319
+ });
320
+ return bundle;
321
+ }
256
322
  locks() {
257
323
  return this.lockStore.list();
258
324
  }
@@ -463,7 +529,7 @@ export class RelayRuntime {
463
529
  return this.activityStore.list(options).map((event) => this.enrichActivityEvent(event, currentInfo));
464
530
  }
465
531
  async retry() {
466
- const cached = this.promptStore.getLastPrompt(WEB_CONTEXT_KEY);
532
+ const cached = this.queueService.getLastPrompt();
467
533
  if (!cached) {
468
534
  throw new Error("Nothing to retry. Send a message first.");
469
535
  }
@@ -758,7 +824,7 @@ export class RelayRuntime {
758
824
  const session = await this.getSession(false);
759
825
  const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
760
826
  if (session.isProcessing() || external?.activity.active) {
761
- const queued = this.promptStore.enqueue(WEB_CONTEXT_KEY, envelope);
827
+ const queued = this.queueService.enqueue(envelope);
762
828
  const info = this.publicInfo(session);
763
829
  this.appendActivity({
764
830
  source: "web",
@@ -770,7 +836,7 @@ export class RelayRuntime {
770
836
  prompt: envelope.description,
771
837
  detail: external?.activity.active
772
838
  ? `Queued because ${external.agentLabel} CLI is still processing another task.`
773
- : `Queued at position ${this.promptStore.list(WEB_CONTEXT_KEY).length}.`,
839
+ : `Queued at position ${this.queueService.length()}.`,
774
840
  });
775
841
  this.appendAudit({
776
842
  action: "prompt_queued",
@@ -783,7 +849,7 @@ export class RelayRuntime {
783
849
  description: envelope.description,
784
850
  });
785
851
  if (external?.activity.active) {
786
- this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.promptStore.list(WEB_CONTEXT_KEY).length} queued.`, "info");
852
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queueService.length()} queued.`, "info");
787
853
  }
788
854
  this.broadcastQueue();
789
855
  return { queued: true, queueId: queued.id };
@@ -794,30 +860,14 @@ export class RelayRuntime {
794
860
  return { queued: false };
795
861
  }
796
862
  queue() {
797
- return this.promptStore.list(WEB_CONTEXT_KEY).map(queueItemDto);
863
+ return this.queueService.list();
798
864
  }
799
865
  queuePaused() {
800
- return this.promptStore.isPaused(WEB_CONTEXT_KEY);
866
+ return this.queueService.isPaused();
801
867
  }
802
868
  queueAction(action, id) {
803
- if (action === "pause")
804
- this.promptStore.pause(WEB_CONTEXT_KEY);
805
- if (action === "resume")
806
- this.promptStore.resume(WEB_CONTEXT_KEY);
807
- if (action === "clear")
808
- this.promptStore.clear(WEB_CONTEXT_KEY);
809
- if (id && action === "cancel")
810
- this.promptStore.remove(WEB_CONTEXT_KEY, id);
811
- if (id && action === "top")
812
- this.promptStore.moveToTop(WEB_CONTEXT_KEY, id);
813
- if (id && action === "up")
814
- this.promptStore.moveUp(WEB_CONTEXT_KEY, id);
815
- if (id && action === "down")
816
- this.promptStore.moveDown(WEB_CONTEXT_KEY, id);
869
+ this.queueService.apply(action, id);
817
870
  if (id && action === "run") {
818
- const item = this.promptStore.remove(WEB_CONTEXT_KEY, id);
819
- if (item)
820
- this.promptStore.enqueueFront(WEB_CONTEXT_KEY, item);
821
871
  void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
822
872
  }
823
873
  this.appendActivity({
@@ -839,58 +889,23 @@ export class RelayRuntime {
839
889
  }
840
890
  async artifacts() {
841
891
  const session = await this.getSession(true);
842
- return (await listRecentArtifactReports(session.getInfo().workspace, 20, this.config.maxFileSize)).map(artifactDto);
892
+ return this.artifactService.list(session.getInfo().workspace, 20);
843
893
  }
844
894
  async artifact(turnId) {
845
895
  const session = await this.getSession(true);
846
- return getArtifactTurnReport(session.getInfo().workspace, turnId, this.config.maxFileSize);
896
+ return this.artifactService.get(session.getInfo().workspace, turnId);
847
897
  }
848
898
  async deleteArtifact(turnId) {
849
899
  const session = await this.getSession(true);
850
- return removeArtifactTurn(session.getInfo().workspace, turnId);
900
+ return this.artifactService.delete(session.getInfo().workspace, turnId);
851
901
  }
852
902
  async createArtifactZip(turnId) {
853
- const report = await this.artifact(turnId);
854
- if (!report) {
855
- return null;
856
- }
857
- const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
858
- maxFileSize: this.config.maxFileSize,
859
- bundleName: `nordrelay-artifacts-${turnId}.zip`,
860
- });
861
- return bundle ? { path: bundle.localPath, name: bundle.name } : null;
903
+ const session = await this.getSession(true);
904
+ return this.artifactService.createZip(session.getInfo().workspace, turnId);
862
905
  }
863
906
  async artifactPreview(turnId, relativePath) {
864
- const report = await this.artifact(turnId);
865
- const artifact = report?.artifacts.find((candidate) => candidate.relativePath.split(path.sep).join("/") === relativePath);
866
- if (!artifact) {
867
- return null;
868
- }
869
- const extension = path.extname(artifact.name).toLowerCase();
870
- if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
871
- return {
872
- kind: "image",
873
- name: artifact.name,
874
- sizeBytes: artifact.sizeBytes,
875
- };
876
- }
877
- if (!isPreviewableTextFile(extension, artifact.sizeBytes)) {
878
- return {
879
- kind: "unsupported",
880
- name: artifact.name,
881
- sizeBytes: artifact.sizeBytes,
882
- detail: artifact.sizeBytes > MAX_TEXT_PREVIEW_BYTES ? "File is too large for inline preview." : "File type is not previewable.",
883
- };
884
- }
885
- const buffer = await readFile(artifact.localPath);
886
- const truncated = buffer.byteLength > MAX_TEXT_PREVIEW_BYTES;
887
- return {
888
- kind: "text",
889
- name: artifact.name,
890
- sizeBytes: artifact.sizeBytes,
891
- truncated,
892
- text: buffer.subarray(0, MAX_TEXT_PREVIEW_BYTES).toString("utf8"),
893
- };
907
+ const session = await this.getSession(true);
908
+ return this.artifactService.preview(session.getInfo().workspace, turnId, relativePath);
894
909
  }
895
910
  async logs(target = "connector", lines = 100) {
896
911
  if (target === "update") {
@@ -934,158 +949,6 @@ export class RelayRuntime {
934
949
  this.registry.disposeAll();
935
950
  this.subscribers.clear();
936
951
  }
937
- async monitorExternalActivity() {
938
- const session = await this.getSession(true);
939
- const info = this.publicInfo(session);
940
- if (!info.capabilities.externalActivity || !info.threadId || session.isProcessing()) {
941
- return;
942
- }
943
- const snapshot = getExternalSnapshotForSession(session, this.config, {
944
- afterLine: this.externalMirror?.threadId === info.threadId ? this.externalMirror.lastLine : Number.MAX_SAFE_INTEGER,
945
- }) ?? getExternalSnapshotForSession(session, this.config, {
946
- maxEvents: 0,
947
- });
948
- if (!snapshot) {
949
- return;
950
- }
951
- if (!this.externalMirror || this.externalMirror.threadId !== snapshot.threadId || this.externalMirror.rolloutPath !== snapshot.sourcePath) {
952
- this.externalMirror = {
953
- threadId: snapshot.threadId,
954
- rolloutPath: snapshot.sourcePath,
955
- lastLine: snapshot.lineCount,
956
- turnId: snapshot.activity.turnId,
957
- startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
958
- };
959
- if (snapshot.activity.active) {
960
- this.startExternalTurn(snapshot);
961
- }
962
- return;
963
- }
964
- const mirror = this.externalMirror;
965
- if (snapshot.activity.active) {
966
- if (mirror.turnId !== snapshot.activity.turnId) {
967
- mirror.turnId = snapshot.activity.turnId;
968
- mirror.startedAt = snapshot.activity.startedAt?.toISOString() ?? null;
969
- mirror.latestAgentLine = undefined;
970
- this.startExternalTurn(snapshot);
971
- }
972
- this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine));
973
- mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
974
- mirror.latestStatus = externalStatusLine(snapshot, this.queue().length);
975
- this.broadcastStatus(mirror.latestStatus, "info");
976
- return;
977
- }
978
- const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
979
- if (terminalEvent && terminalEvent.lineNumber > mirror.lastLine) {
980
- const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
981
- const finalText = finalAgent?.text ?? snapshot.latestAgentMessage;
982
- const finalLine = finalAgent?.lineNumber ?? snapshot.lineCount;
983
- if (finalText && finalLine !== mirror.latestAgentLine) {
984
- this.chatStore.append({
985
- threadId: snapshot.threadId,
986
- role: "agent",
987
- text: finalText,
988
- source: "cli",
989
- turnId: terminalEvent.turnId ?? undefined,
990
- });
991
- this.broadcast({ type: "text_delta", id: terminalEvent.turnId ?? "cli", delta: finalText });
992
- mirror.latestAgentLine = finalLine;
993
- }
994
- const externalStartedAt = mirror.startedAt ? new Date(mirror.startedAt) : snapshot.activity.startedAt;
995
- this.broadcast({
996
- type: "turn_complete",
997
- id: terminalEvent.turnId ?? "cli",
998
- at: terminalEvent.timestamp?.toISOString() ?? new Date().toISOString(),
999
- });
1000
- this.appendActivity({
1001
- source: "cli",
1002
- status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
1003
- type: "cli_turn_finished",
1004
- threadId: snapshot.threadId,
1005
- workspace: info.workspace,
1006
- agentId: info.agentId,
1007
- prompt: snapshot.latestUserMessage ?? undefined,
1008
- detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
1009
- durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
1010
- });
1011
- if (externalStartedAt && terminalEvent.turnId) {
1012
- await this.persistWorkspaceArtifactsForTurn(info.workspace, terminalEvent.turnId, externalStartedAt);
1013
- }
1014
- mirror.latestStatus = `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`;
1015
- this.broadcastStatus(`${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
1016
- this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
1017
- await this.drainQueue();
1018
- }
1019
- mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
1020
- }
1021
- async monitorExternalActivitySafe() {
1022
- if (this.externalMonitorRunning) {
1023
- return;
1024
- }
1025
- this.externalMonitorRunning = true;
1026
- try {
1027
- await this.monitorExternalActivity();
1028
- }
1029
- catch (error) {
1030
- this.broadcastStatus(friendlyErrorText(error), "error");
1031
- }
1032
- finally {
1033
- this.externalMonitorRunning = false;
1034
- }
1035
- }
1036
- startExternalTurn(snapshot) {
1037
- const prompt = snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`;
1038
- this.chatStore.append({
1039
- threadId: snapshot.threadId,
1040
- role: "user",
1041
- text: prompt,
1042
- source: "cli",
1043
- turnId: snapshot.activity.turnId ?? undefined,
1044
- timestamp: snapshot.activity.startedAt?.toISOString(),
1045
- });
1046
- this.broadcast({
1047
- type: "turn_start",
1048
- id: snapshot.activity.turnId ?? "cli",
1049
- prompt,
1050
- at: snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString(),
1051
- source: "cli",
1052
- });
1053
- this.appendActivity({
1054
- source: "cli",
1055
- status: "running",
1056
- type: "cli_turn_started",
1057
- threadId: snapshot.threadId,
1058
- prompt,
1059
- detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
1060
- });
1061
- }
1062
- broadcastExternalEvents(snapshot, events) {
1063
- for (const event of events) {
1064
- if (event.kind === "tool" && event.status === "started") {
1065
- this.broadcast({
1066
- type: "tool_start",
1067
- id: snapshot.activity.turnId ?? "cli",
1068
- toolCallId: `cli-${event.lineNumber}`,
1069
- toolName: event.toolName ?? "tool",
1070
- });
1071
- this.appendActivity({
1072
- source: "cli",
1073
- status: "running",
1074
- type: "cli_tool_started",
1075
- threadId: snapshot.threadId,
1076
- detail: event.toolName ?? "tool",
1077
- });
1078
- }
1079
- if (event.kind === "tool" && event.status === "finished") {
1080
- this.broadcast({
1081
- type: "tool_end",
1082
- id: snapshot.activity.turnId ?? "cli",
1083
- toolCallId: `cli-${event.lineNumber}`,
1084
- isError: false,
1085
- });
1086
- }
1087
- }
1088
- }
1089
952
  async getSession(deferThreadStart) {
1090
953
  return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
1091
954
  }
@@ -1104,6 +967,14 @@ export class RelayRuntime {
1104
967
  });
1105
968
  return { session, dispose: true };
1106
969
  }
970
+ cliPathOptions() {
971
+ return {
972
+ piCliPath: this.config.piCliPath,
973
+ hermesCliPath: this.config.hermesCliPath,
974
+ openClawCliPath: this.config.openClawCliPath,
975
+ claudeCodeCliPath: this.config.claudeCodeCliPath,
976
+ };
977
+ }
1107
978
  async ensureActiveThread(session) {
1108
979
  if (!session.hasActiveThread()) {
1109
980
  await session.newThread();
@@ -1199,7 +1070,7 @@ export class RelayRuntime {
1199
1070
  outputChars: 0,
1200
1071
  tools: [],
1201
1072
  };
1202
- this.promptStore.setLastPrompt(WEB_CONTEXT_KEY, envelope);
1073
+ this.queueService.setLastPrompt(envelope);
1203
1074
  const startedDate = new Date();
1204
1075
  const startedAt = startedDate.toISOString();
1205
1076
  this.chatStore.append({
@@ -1257,7 +1128,7 @@ export class RelayRuntime {
1257
1128
  try {
1258
1129
  await session.prompt(envelope.input, callbacks);
1259
1130
  this.updateSession(session);
1260
- await this.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
1131
+ await this.artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
1261
1132
  if (this.accumulatedText.trim()) {
1262
1133
  this.chatStore.append({
1263
1134
  threadId: info.threadId ?? "pending",
@@ -1335,7 +1206,7 @@ export class RelayRuntime {
1335
1206
  }
1336
1207
  }
1337
1208
  async drainQueue() {
1338
- if (this.draining || this.promptStore.isPaused(WEB_CONTEXT_KEY)) {
1209
+ if (this.draining || this.queueService.isPaused()) {
1339
1210
  return;
1340
1211
  }
1341
1212
  this.draining = true;
@@ -1344,10 +1215,10 @@ export class RelayRuntime {
1344
1215
  while (!session.isProcessing()) {
1345
1216
  const external = getExternalSnapshotForSession(session, this.config, { maxEvents: 0 });
1346
1217
  if (external?.activity.active) {
1347
- this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queue().length} queued.`, "info");
1218
+ this.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${this.queueService.length()} queued.`, "info");
1348
1219
  return;
1349
1220
  }
1350
- const next = this.promptStore.dequeue(WEB_CONTEXT_KEY);
1221
+ const next = this.queueService.dequeue();
1351
1222
  this.broadcastQueue();
1352
1223
  if (!next) {
1353
1224
  return;
@@ -1417,31 +1288,6 @@ export class RelayRuntime {
1417
1288
  }
1418
1289
  this.updateCurrentProgress({ currentTool: toolName, lastTool: toolName });
1419
1290
  }
1420
- externalTask() {
1421
- if (!this.externalMirror) {
1422
- return null;
1423
- }
1424
- const startedAt = this.externalMirror.startedAt ?? new Date().toISOString();
1425
- const startedMs = new Date(startedAt).getTime();
1426
- return {
1427
- id: this.externalMirror.turnId ?? "cli",
1428
- source: "cli",
1429
- status: this.externalMirror.latestStatus?.includes("failed")
1430
- ? "failed"
1431
- : this.externalMirror.latestStatus?.includes("aborted")
1432
- ? "aborted"
1433
- : this.externalMirror.latestStatus?.includes("finished") || this.externalMirror.latestStatus?.includes("completed")
1434
- ? "completed"
1435
- : "running",
1436
- threadId: this.externalMirror.threadId,
1437
- startedAt,
1438
- updatedAt: new Date().toISOString(),
1439
- durationMs: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0,
1440
- outputChars: 0,
1441
- tools: [],
1442
- detail: this.externalMirror.latestStatus ?? this.externalMirror.rolloutPath,
1443
- };
1444
- }
1445
1291
  broadcastQueue() {
1446
1292
  this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
1447
1293
  }
@@ -1468,53 +1314,6 @@ export class RelayRuntime {
1468
1314
  capabilities: info.capabilities ?? CODEX_AGENT_CAPABILITIES,
1469
1315
  };
1470
1316
  }
1471
- async persistWorkspaceArtifactsForTurn(workspace, turnId, startedAt) {
1472
- const report = await collectRecentWorkspaceArtifacts(workspace, {
1473
- since: startedAt,
1474
- until: new Date(),
1475
- maxFileSize: this.config.maxFileSize,
1476
- limit: 20,
1477
- ignoreDirs: this.config.artifactIgnoreDirs,
1478
- ignoreGlobs: this.config.artifactIgnoreGlobs,
1479
- });
1480
- if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
1481
- return;
1482
- }
1483
- await persistWorkspaceArtifactReport(workspace, turnId, report);
1484
- }
1485
- }
1486
- function queueItemDto(item) {
1487
- return {
1488
- id: item.id,
1489
- description: item.description,
1490
- createdAt: new Date(item.createdAt).toISOString(),
1491
- attempts: item.attempts ?? 0,
1492
- notBefore: item.notBefore ? new Date(item.notBefore).toISOString() : undefined,
1493
- lastError: item.lastError,
1494
- };
1495
- }
1496
- function artifactDto(report) {
1497
- return {
1498
- turnId: report.turnId,
1499
- updatedAt: report.updatedAt.toISOString(),
1500
- source: report.source,
1501
- fileCount: report.artifacts.length,
1502
- totalSizeBytes: totalArtifactSize(report.artifacts),
1503
- skippedCount: report.skippedCount,
1504
- omittedCount: report.omittedCount,
1505
- artifacts: report.artifacts.map((artifact) => ({
1506
- name: artifact.name,
1507
- relativePath: artifact.relativePath.split(path.sep).join("/"),
1508
- sizeBytes: artifact.sizeBytes,
1509
- })),
1510
- };
1511
- }
1512
- function externalStatusLine(snapshot, queueLength) {
1513
- const elapsed = snapshot.activity.startedAt
1514
- ? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
1515
- : "-";
1516
- const tool = snapshot.latestToolName ?? "-";
1517
- return `${snapshot.agentLabel} CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
1518
1317
  }
1519
1318
  function cliHealthForAgent(agentId, health) {
1520
1319
  if (agentId === "pi") {
@@ -1572,23 +1371,6 @@ function hostLogoutCommand(info, config) {
1572
1371
  }
1573
1372
  return "codex logout";
1574
1373
  }
1575
- function durationFromDates(start, end) {
1576
- if (!start || !end) {
1577
- return undefined;
1578
- }
1579
- return Math.max(0, end.getTime() - start.getTime());
1580
- }
1581
- function formatDuration(seconds) {
1582
- if (!Number.isFinite(seconds) || seconds < 0) {
1583
- return "-";
1584
- }
1585
- if (seconds < 60) {
1586
- return `${Math.round(seconds)}s`;
1587
- }
1588
- const minutes = Math.floor(seconds / 60);
1589
- const remainder = Math.round(seconds % 60);
1590
- return `${minutes}m ${remainder}s`;
1591
- }
1592
1374
  function normalizeMimeType(value, name) {
1593
1375
  const configured = value?.trim();
1594
1376
  if (configured) {
@@ -1622,37 +1404,3 @@ function uploadFileDtos(files) {
1622
1404
  sizeBytes: file.sizeBytes,
1623
1405
  }));
1624
1406
  }
1625
- function isPreviewableTextFile(extension, sizeBytes) {
1626
- if (sizeBytes > MAX_TEXT_PREVIEW_BYTES * 4) {
1627
- return false;
1628
- }
1629
- return [
1630
- "",
1631
- ".c",
1632
- ".conf",
1633
- ".cpp",
1634
- ".css",
1635
- ".csv",
1636
- ".env",
1637
- ".go",
1638
- ".html",
1639
- ".java",
1640
- ".js",
1641
- ".json",
1642
- ".jsx",
1643
- ".log",
1644
- ".md",
1645
- ".py",
1646
- ".rb",
1647
- ".rs",
1648
- ".sh",
1649
- ".sql",
1650
- ".toml",
1651
- ".ts",
1652
- ".tsx",
1653
- ".txt",
1654
- ".xml",
1655
- ".yaml",
1656
- ".yml",
1657
- ].includes(extension);
1658
- }