@nordbyte/nordrelay 0.5.0 → 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 (38) hide show
  1. package/README.md +16 -10
  2. package/dist/access-control.js +2 -0
  3. package/dist/agent-updates.js +43 -8
  4. package/dist/bot-ui.js +1 -0
  5. package/dist/bot.js +108 -1063
  6. package/dist/channel-actions.js +8 -8
  7. package/dist/operations.js +63 -9
  8. package/dist/relay-artifact-service.js +126 -0
  9. package/dist/relay-external-activity-monitor.js +216 -0
  10. package/dist/relay-queue-service.js +66 -0
  11. package/dist/relay-runtime-types.js +1 -0
  12. package/dist/relay-runtime.js +77 -359
  13. package/dist/support-bundle.js +205 -0
  14. package/dist/telegram-agent-commands.js +212 -0
  15. package/dist/telegram-artifact-commands.js +139 -0
  16. package/dist/telegram-command-menu.js +1 -0
  17. package/dist/telegram-command-types.js +1 -0
  18. package/dist/telegram-diagnostics-command.js +102 -0
  19. package/dist/telegram-general-commands.js +52 -0
  20. package/dist/telegram-operational-commands.js +153 -0
  21. package/dist/telegram-preference-commands.js +198 -0
  22. package/dist/telegram-queue-commands.js +278 -0
  23. package/dist/telegram-support-command.js +53 -0
  24. package/dist/telegram-update-commands.js +6 -1
  25. package/dist/web-api-contract.js +79 -31
  26. package/dist/web-api-types.js +1 -0
  27. package/dist/web-dashboard-access-routes.js +163 -0
  28. package/dist/web-dashboard-artifact-routes.js +65 -0
  29. package/dist/web-dashboard-assets.js +2 -0
  30. package/dist/web-dashboard-http.js +143 -0
  31. package/dist/web-dashboard-pages.js +257 -0
  32. package/dist/web-dashboard-runtime-routes.js +92 -0
  33. package/dist/web-dashboard-session-routes.js +209 -0
  34. package/dist/web-dashboard.js +43 -882
  35. package/dist/webui-assets/dashboard.css +74 -4
  36. package/dist/webui-assets/dashboard.js +163 -24
  37. package/dist/zip-writer.js +83 -0
  38. package/package.json +10 -4
@@ -52,13 +52,13 @@ export function renderAgentUpdatePickerAction(descriptors) {
52
52
  "Agent updates:",
53
53
  ...available.map((descriptor) => `${descriptor.label}: /update ${descriptor.id}`),
54
54
  "",
55
- "Use /update jobs to list running and recent agent updates.",
55
+ "Use /update install <agent> for missing CLIs and /update jobs to list running and recent agent updates.",
56
56
  ].join("\n"),
57
57
  html: [
58
58
  "<b>Agent updates:</b>",
59
59
  ...available.map((descriptor) => `<b>${escapeHTML(descriptor.label)}:</b> <code>/update ${escapeHTML(descriptor.id)}</code>`),
60
60
  "",
61
- "Use <code>/update jobs</code> to list running and recent agent updates.",
61
+ "Use <code>/update install &lt;agent&gt;</code> for missing CLIs and <code>/update jobs</code> to list running and recent agent updates.",
62
62
  ].join("\n"),
63
63
  buttons,
64
64
  };
@@ -86,13 +86,13 @@ export function renderAgentUpdateJobsAction(jobs) {
86
86
  return {
87
87
  plain: [
88
88
  "Agent update jobs:",
89
- ...limited.map((job) => `${job.id}: ${job.agentLabel} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
89
+ ...limited.map((job) => `${job.id}: ${job.agentLabel} ${job.operation ?? "update"} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
90
90
  "",
91
91
  "Use /update log <id>, /update cancel <id>, or /update input <id> <text>.",
92
92
  ].join("\n"),
93
93
  html: [
94
94
  "<b>Agent update jobs:</b>",
95
- ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
95
+ ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} ${escapeHTML(job.operation ?? "update")} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
96
96
  "",
97
97
  "Use <code>/update log &lt;id&gt;</code>, <code>/update cancel &lt;id&gt;</code>, or <code>/update input &lt;id&gt; &lt;text&gt;</code>.",
98
98
  ].join("\n"),
@@ -106,7 +106,7 @@ export function renderAgentUpdateJobAction(job) {
106
106
  const tail = trimLine(job.outputTail || "(waiting for output)", 1200);
107
107
  return {
108
108
  plain: [
109
- `${job.agentLabel} update ${job.status}.`,
109
+ `${job.agentLabel} ${job.operation ?? "update"} ${job.status}.`,
110
110
  `ID: ${job.id}`,
111
111
  `Method: ${job.method}`,
112
112
  `Command: ${command}`,
@@ -120,7 +120,7 @@ export function renderAgentUpdateJobAction(job) {
120
120
  tail,
121
121
  ].filter(Boolean).join("\n"),
122
122
  html: [
123
- `<b>${escapeHTML(job.agentLabel)} update ${escapeHTML(job.status)}.</b>`,
123
+ `<b>${escapeHTML(job.agentLabel)} ${escapeHTML(job.operation ?? "update")} ${escapeHTML(job.status)}.</b>`,
124
124
  `<b>ID:</b> <code>${escapeHTML(job.id)}</code>`,
125
125
  `<b>Method:</b> <code>${escapeHTML(job.method)}</code>`,
126
126
  `<b>Command:</b> <code>${escapeHTML(command)}</code>`,
@@ -145,7 +145,7 @@ export function renderAgentUpdateLogAction(result) {
145
145
  const tail = trimLine(result.plain || "(empty)", 3000);
146
146
  return {
147
147
  plain: [
148
- `${result.job.agentLabel} update log`,
148
+ `${result.job.agentLabel} ${result.job.operation ?? "update"} log`,
149
149
  `ID: ${result.job.id}`,
150
150
  `Status: ${result.job.status}`,
151
151
  `File: ${result.job.logPath}`,
@@ -153,7 +153,7 @@ export function renderAgentUpdateLogAction(result) {
153
153
  tail,
154
154
  ].join("\n"),
155
155
  html: [
156
- `<b>${escapeHTML(result.job.agentLabel)} update log</b>`,
156
+ `<b>${escapeHTML(result.job.agentLabel)} ${escapeHTML(result.job.operation ?? "update")} log</b>`,
157
157
  `<b>ID:</b> <code>${escapeHTML(result.job.id)}</code>`,
158
158
  `<b>Status:</b> <code>${escapeHTML(result.job.status)}</code>`,
159
159
  `<b>File:</b> <code>${escapeHTML(result.job.logPath)}</code>`,
@@ -3,7 +3,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
6
+ import { describeCodexCli, findExecutableOnPath, resolveCodexCli } from "./codex-cli.js";
7
7
  import { findLatestDatabase } from "./codex-state.js";
8
8
  import { describeClaudeCodeCli, resolveClaudeCodeCli } from "./claude-code-cli.js";
9
9
  import { describeHermesCli, resolveHermesCli } from "./hermes-cli.js";
@@ -294,9 +294,9 @@ function buildNpmSelfUpdateCommands() {
294
294
  ];
295
295
  }
296
296
  function resolveNpmCommand() {
297
- const npmExecPath = process.env.npm_execpath;
298
- if (npmExecPath && existsSync(npmExecPath)) {
299
- return `${shellQuote(process.execPath)} ${shellQuote(npmExecPath)}`;
297
+ const npm = resolveNpmSpawnCommand();
298
+ if (npm) {
299
+ return [npm.command, ...npm.argsPrefix].map(shellQuote).join(" ");
300
300
  }
301
301
  return "npm";
302
302
  }
@@ -306,6 +306,7 @@ function detectCliVersion(commandPath) {
306
306
  }
307
307
  const result = spawnSync(commandPath, ["--version"], {
308
308
  encoding: "utf8",
309
+ shell: isWindowsShellScript(commandPath),
309
310
  timeout: 3000,
310
311
  windowsHide: true,
311
312
  });
@@ -320,13 +321,15 @@ function detectCliVersion(commandPath) {
320
321
  }
321
322
  function buildHermesVersionCheck(installedLabel) {
322
323
  if (installedLabel === "not installed") {
324
+ const latest = detectLatestNpmVersion(HERMES_PACKAGE_NAME);
323
325
  return {
324
326
  label: "Hermes",
325
327
  packageName: HERMES_PACKAGE_NAME,
326
328
  installedLabel: "not installed",
327
329
  installedVersion: null,
328
- latestVersion: null,
330
+ latestVersion: latest.version,
329
331
  status: "not-installed",
332
+ detail: latest.error,
330
333
  };
331
334
  }
332
335
  const lines = installedLabel.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
@@ -345,14 +348,15 @@ function buildHermesVersionCheck(installedLabel) {
345
348
  }
346
349
  function buildVersionCheck(options) {
347
350
  if (options.notInstalled) {
351
+ const latest = options.skipLatest ? { version: null, error: undefined } : detectLatestNpmVersion(options.packageName);
348
352
  return {
349
353
  label: options.label,
350
354
  packageName: options.packageName,
351
355
  installedLabel: "not installed",
352
356
  installedVersion: null,
353
- latestVersion: null,
357
+ latestVersion: latest.version,
354
358
  status: "not-installed",
355
- detail: options.detail,
359
+ detail: [options.detail, latest.error].filter(Boolean).join(" ") || undefined,
356
360
  };
357
361
  }
358
362
  if (options.skipLatest) {
@@ -393,14 +397,19 @@ function detectLatestNpmVersion(packageName) {
393
397
  if (cached) {
394
398
  return cached;
395
399
  }
396
- const result = spawnSync("npm", ["view", packageName, "version", "--registry=https://registry.npmjs.org"], {
400
+ const npm = resolveNpmSpawnCommand();
401
+ if (!npm) {
402
+ return { version: null, error: "npm was not found on PATH; latest-version lookup is unavailable" };
403
+ }
404
+ const result = spawnSync(npm.command, [...npm.argsPrefix, "view", packageName, "version", "--registry=https://registry.npmjs.org"], {
397
405
  encoding: "utf8",
406
+ shell: npm.shell,
398
407
  timeout: 5000,
399
408
  windowsHide: true,
400
409
  });
401
410
  const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
402
411
  if (result.error) {
403
- return { version: null, error: result.error.message };
412
+ return { version: null, error: `${npm.display}: ${result.error.message}` };
404
413
  }
405
414
  if (result.status !== 0) {
406
415
  return { version: null, error: output || `npm exited ${result.status ?? "unknown"}` };
@@ -409,6 +418,51 @@ function detectLatestNpmVersion(packageName) {
409
418
  writeVersionCache(packageName, resolved.version);
410
419
  return resolved;
411
420
  }
421
+ export function resolveNpmSpawnCommand(env = process.env) {
422
+ const npmExecPath = env.npm_execpath?.trim();
423
+ if (npmExecPath && existsSync(npmExecPath)) {
424
+ return {
425
+ command: process.execPath,
426
+ argsPrefix: [npmExecPath],
427
+ display: `${process.execPath} ${npmExecPath}`,
428
+ shell: false,
429
+ };
430
+ }
431
+ const pathMatch = findExecutableOnPath("npm", env.PATH);
432
+ if (pathMatch) {
433
+ return {
434
+ command: pathMatch,
435
+ argsPrefix: [],
436
+ display: pathMatch,
437
+ shell: isWindowsShellScript(pathMatch),
438
+ };
439
+ }
440
+ for (const candidate of commonNpmCandidates(env)) {
441
+ if (!existsSync(candidate)) {
442
+ continue;
443
+ }
444
+ return {
445
+ command: candidate,
446
+ argsPrefix: [],
447
+ display: candidate,
448
+ shell: isWindowsShellScript(candidate),
449
+ };
450
+ }
451
+ return null;
452
+ }
453
+ function commonNpmCandidates(env) {
454
+ const names = process.platform === "win32" ? ["npm.cmd", "npm.bat", "npm"] : ["npm"];
455
+ const directories = [
456
+ path.dirname(process.execPath),
457
+ env.APPDATA ? path.join(env.APPDATA, "npm") : undefined,
458
+ env.ProgramFiles ? path.join(env.ProgramFiles, "nodejs") : undefined,
459
+ env["ProgramFiles(x86)"] ? path.join(env["ProgramFiles(x86)"], "nodejs") : undefined,
460
+ ].filter((value) => Boolean(value));
461
+ return directories.flatMap((directory) => names.map((name) => path.join(directory, name)));
462
+ }
463
+ function isWindowsShellScript(filePath) {
464
+ return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(filePath);
465
+ }
412
466
  function readVersionCache(packageName) {
413
467
  const ttlMs = parseVersionCacheTtlMs();
414
468
  if (ttlMs <= 0) {
@@ -0,0 +1,126 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { collectRecentWorkspaceArtifacts, createArtifactZipBundle, getArtifactTurnReport, listRecentArtifactReports, persistWorkspaceArtifactReport, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
4
+ const MAX_TEXT_PREVIEW_BYTES = 256 * 1024;
5
+ export class RelayArtifactService {
6
+ config;
7
+ constructor(config) {
8
+ this.config = config;
9
+ }
10
+ async list(workspace, limit = 20) {
11
+ return (await listRecentArtifactReports(workspace, limit, this.config.maxFileSize)).map(artifactDto);
12
+ }
13
+ async get(workspace, turnId) {
14
+ return getArtifactTurnReport(workspace, turnId, this.config.maxFileSize);
15
+ }
16
+ async delete(workspace, turnId) {
17
+ return removeArtifactTurn(workspace, turnId);
18
+ }
19
+ async createZip(workspace, turnId) {
20
+ const report = await this.get(workspace, turnId);
21
+ if (!report) {
22
+ return null;
23
+ }
24
+ const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
25
+ maxFileSize: this.config.maxFileSize,
26
+ bundleName: `nordrelay-artifacts-${turnId}.zip`,
27
+ });
28
+ return bundle ? { path: bundle.localPath, name: bundle.name } : null;
29
+ }
30
+ async preview(workspace, turnId, relativePath) {
31
+ const report = await this.get(workspace, turnId);
32
+ const artifact = report?.artifacts.find((candidate) => candidate.relativePath.split(path.sep).join("/") === relativePath);
33
+ if (!artifact) {
34
+ return null;
35
+ }
36
+ const extension = path.extname(artifact.name).toLowerCase();
37
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
38
+ return {
39
+ kind: "image",
40
+ name: artifact.name,
41
+ sizeBytes: artifact.sizeBytes,
42
+ };
43
+ }
44
+ if (!isPreviewableTextFile(extension, artifact.sizeBytes)) {
45
+ return {
46
+ kind: "unsupported",
47
+ name: artifact.name,
48
+ sizeBytes: artifact.sizeBytes,
49
+ detail: artifact.sizeBytes > MAX_TEXT_PREVIEW_BYTES ? "File is too large for inline preview." : "File type is not previewable.",
50
+ };
51
+ }
52
+ const buffer = await readFile(artifact.localPath);
53
+ const truncated = buffer.byteLength > MAX_TEXT_PREVIEW_BYTES;
54
+ return {
55
+ kind: "text",
56
+ name: artifact.name,
57
+ sizeBytes: artifact.sizeBytes,
58
+ truncated,
59
+ text: buffer.subarray(0, MAX_TEXT_PREVIEW_BYTES).toString("utf8"),
60
+ };
61
+ }
62
+ async persistWorkspaceArtifactsForTurn(workspace, turnId, startedAt) {
63
+ const report = await collectRecentWorkspaceArtifacts(workspace, {
64
+ since: startedAt,
65
+ until: new Date(),
66
+ maxFileSize: this.config.maxFileSize,
67
+ limit: 20,
68
+ ignoreDirs: this.config.artifactIgnoreDirs,
69
+ ignoreGlobs: this.config.artifactIgnoreGlobs,
70
+ });
71
+ if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
72
+ return;
73
+ }
74
+ await persistWorkspaceArtifactReport(workspace, turnId, report);
75
+ }
76
+ }
77
+ function artifactDto(report) {
78
+ return {
79
+ turnId: report.turnId,
80
+ updatedAt: report.updatedAt.toISOString(),
81
+ source: report.source,
82
+ fileCount: report.artifacts.length,
83
+ totalSizeBytes: totalArtifactSize(report.artifacts),
84
+ skippedCount: report.skippedCount,
85
+ omittedCount: report.omittedCount,
86
+ artifacts: report.artifacts.map((artifact) => ({
87
+ name: artifact.name,
88
+ relativePath: artifact.relativePath.split(path.sep).join("/"),
89
+ sizeBytes: artifact.sizeBytes,
90
+ })),
91
+ };
92
+ }
93
+ function isPreviewableTextFile(extension, sizeBytes) {
94
+ if (sizeBytes > MAX_TEXT_PREVIEW_BYTES * 4) {
95
+ return false;
96
+ }
97
+ return [
98
+ "",
99
+ ".c",
100
+ ".conf",
101
+ ".cpp",
102
+ ".css",
103
+ ".csv",
104
+ ".env",
105
+ ".go",
106
+ ".html",
107
+ ".java",
108
+ ".js",
109
+ ".json",
110
+ ".jsx",
111
+ ".log",
112
+ ".md",
113
+ ".py",
114
+ ".rb",
115
+ ".rs",
116
+ ".sh",
117
+ ".sql",
118
+ ".toml",
119
+ ".ts",
120
+ ".tsx",
121
+ ".txt",
122
+ ".xml",
123
+ ".yaml",
124
+ ".yml",
125
+ ].includes(extension);
126
+ }
@@ -0,0 +1,216 @@
1
+ import {} from "./agent.js";
2
+ import { getExternalSnapshotForSession } from "./agent-activity.js";
3
+ import { friendlyErrorText } from "./error-messages.js";
4
+ import {} from "./web-state.js";
5
+ export class RelayExternalActivityMonitor {
6
+ options;
7
+ mirror = null;
8
+ running = false;
9
+ constructor(options) {
10
+ this.options = options;
11
+ }
12
+ snapshot() {
13
+ return this.mirror ? { ...this.mirror } : null;
14
+ }
15
+ task() {
16
+ if (!this.mirror) {
17
+ return null;
18
+ }
19
+ const startedAt = this.mirror.startedAt ?? new Date().toISOString();
20
+ const startedMs = new Date(startedAt).getTime();
21
+ return {
22
+ id: this.mirror.turnId ?? "cli",
23
+ source: "cli",
24
+ status: this.mirror.latestStatus?.includes("failed")
25
+ ? "failed"
26
+ : this.mirror.latestStatus?.includes("aborted")
27
+ ? "aborted"
28
+ : this.mirror.latestStatus?.includes("finished") || this.mirror.latestStatus?.includes("completed")
29
+ ? "completed"
30
+ : "running",
31
+ threadId: this.mirror.threadId,
32
+ startedAt,
33
+ updatedAt: new Date().toISOString(),
34
+ durationMs: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0,
35
+ outputChars: 0,
36
+ tools: [],
37
+ detail: this.mirror.latestStatus ?? this.mirror.rolloutPath,
38
+ };
39
+ }
40
+ async monitorSafe() {
41
+ if (this.running) {
42
+ return;
43
+ }
44
+ this.running = true;
45
+ try {
46
+ await this.monitor();
47
+ }
48
+ catch (error) {
49
+ this.options.broadcastStatus(friendlyErrorText(error), "error");
50
+ }
51
+ finally {
52
+ this.running = false;
53
+ }
54
+ }
55
+ async monitor() {
56
+ const session = await this.options.getSession();
57
+ const info = this.options.publicInfo(session);
58
+ if (!info.capabilities.externalActivity || !info.threadId || session.isProcessing()) {
59
+ return;
60
+ }
61
+ const snapshot = getExternalSnapshotForSession(session, this.options.config, {
62
+ afterLine: this.mirror?.threadId === info.threadId ? this.mirror.lastLine : Number.MAX_SAFE_INTEGER,
63
+ }) ?? getExternalSnapshotForSession(session, this.options.config, {
64
+ maxEvents: 0,
65
+ });
66
+ if (!snapshot) {
67
+ return;
68
+ }
69
+ if (!this.mirror || this.mirror.threadId !== snapshot.threadId || this.mirror.rolloutPath !== snapshot.sourcePath) {
70
+ this.mirror = {
71
+ threadId: snapshot.threadId,
72
+ rolloutPath: snapshot.sourcePath,
73
+ lastLine: snapshot.lineCount,
74
+ turnId: snapshot.activity.turnId,
75
+ startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
76
+ };
77
+ if (snapshot.activity.active) {
78
+ this.startExternalTurn(snapshot);
79
+ }
80
+ return;
81
+ }
82
+ const mirror = this.mirror;
83
+ if (snapshot.activity.active) {
84
+ if (mirror.turnId !== snapshot.activity.turnId) {
85
+ mirror.turnId = snapshot.activity.turnId;
86
+ mirror.startedAt = snapshot.activity.startedAt?.toISOString() ?? null;
87
+ mirror.latestAgentLine = undefined;
88
+ this.startExternalTurn(snapshot);
89
+ }
90
+ this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine));
91
+ mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
92
+ mirror.latestStatus = externalStatusLine(snapshot, this.options.queueLength());
93
+ this.options.broadcastStatus(mirror.latestStatus, "info");
94
+ return;
95
+ }
96
+ const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
97
+ if (terminalEvent && terminalEvent.lineNumber > mirror.lastLine) {
98
+ const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
99
+ const finalText = finalAgent?.text ?? snapshot.latestAgentMessage;
100
+ const finalLine = finalAgent?.lineNumber ?? snapshot.lineCount;
101
+ if (finalText && finalLine !== mirror.latestAgentLine) {
102
+ this.options.chatStore.append({
103
+ threadId: snapshot.threadId,
104
+ role: "agent",
105
+ text: finalText,
106
+ source: "cli",
107
+ turnId: terminalEvent.turnId ?? undefined,
108
+ });
109
+ this.options.broadcast({ type: "text_delta", id: terminalEvent.turnId ?? "cli", delta: finalText });
110
+ mirror.latestAgentLine = finalLine;
111
+ }
112
+ const externalStartedAt = mirror.startedAt ? new Date(mirror.startedAt) : snapshot.activity.startedAt;
113
+ this.options.broadcast({
114
+ type: "turn_complete",
115
+ id: terminalEvent.turnId ?? "cli",
116
+ at: terminalEvent.timestamp?.toISOString() ?? new Date().toISOString(),
117
+ });
118
+ this.options.appendActivity({
119
+ source: "cli",
120
+ status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
121
+ type: "cli_turn_finished",
122
+ threadId: snapshot.threadId,
123
+ workspace: info.workspace,
124
+ agentId: info.agentId,
125
+ prompt: snapshot.latestUserMessage ?? undefined,
126
+ detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
127
+ durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
128
+ });
129
+ if (externalStartedAt && terminalEvent.turnId) {
130
+ await this.options.persistWorkspaceArtifactsForTurn(info.workspace, terminalEvent.turnId, externalStartedAt);
131
+ }
132
+ mirror.latestStatus = `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`;
133
+ this.options.broadcastStatus(`${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
134
+ this.options.broadcast({ type: "chat_history", messages: await this.options.chatHistory() });
135
+ await this.options.drainQueue();
136
+ }
137
+ mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
138
+ }
139
+ startExternalTurn(snapshot) {
140
+ const prompt = snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`;
141
+ this.options.chatStore.append({
142
+ threadId: snapshot.threadId,
143
+ role: "user",
144
+ text: prompt,
145
+ source: "cli",
146
+ turnId: snapshot.activity.turnId ?? undefined,
147
+ timestamp: snapshot.activity.startedAt?.toISOString(),
148
+ });
149
+ this.options.broadcast({
150
+ type: "turn_start",
151
+ id: snapshot.activity.turnId ?? "cli",
152
+ prompt,
153
+ at: snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString(),
154
+ source: "cli",
155
+ });
156
+ this.options.appendActivity({
157
+ source: "cli",
158
+ status: "running",
159
+ type: "cli_turn_started",
160
+ threadId: snapshot.threadId,
161
+ prompt,
162
+ detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
163
+ });
164
+ }
165
+ broadcastExternalEvents(snapshot, events) {
166
+ for (const event of events) {
167
+ if (event.kind === "tool" && event.status === "started") {
168
+ this.options.broadcast({
169
+ type: "tool_start",
170
+ id: snapshot.activity.turnId ?? "cli",
171
+ toolCallId: `cli-${event.lineNumber}`,
172
+ toolName: event.toolName ?? "tool",
173
+ });
174
+ this.options.appendActivity({
175
+ source: "cli",
176
+ status: "running",
177
+ type: "cli_tool_started",
178
+ threadId: snapshot.threadId,
179
+ detail: event.toolName ?? "tool",
180
+ });
181
+ }
182
+ if (event.kind === "tool" && event.status === "finished") {
183
+ this.options.broadcast({
184
+ type: "tool_end",
185
+ id: snapshot.activity.turnId ?? "cli",
186
+ toolCallId: `cli-${event.lineNumber}`,
187
+ isError: false,
188
+ });
189
+ }
190
+ }
191
+ }
192
+ }
193
+ function externalStatusLine(snapshot, queueLength) {
194
+ const elapsed = snapshot.activity.startedAt
195
+ ? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
196
+ : "-";
197
+ const tool = snapshot.latestToolName ?? "-";
198
+ return `${snapshot.agentLabel} CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
199
+ }
200
+ function durationFromDates(start, end) {
201
+ if (!start || !end) {
202
+ return undefined;
203
+ }
204
+ return Math.max(0, end.getTime() - start.getTime());
205
+ }
206
+ function formatDuration(seconds) {
207
+ if (!Number.isFinite(seconds) || seconds < 0) {
208
+ return "-";
209
+ }
210
+ if (seconds < 60) {
211
+ return `${Math.round(seconds)}s`;
212
+ }
213
+ const minutes = Math.floor(seconds / 60);
214
+ const remainder = Math.round(seconds % 60);
215
+ return `${minutes}m ${remainder}s`;
216
+ }
@@ -0,0 +1,66 @@
1
+ export class RelayQueueService {
2
+ promptStore;
3
+ contextKey;
4
+ constructor(promptStore, contextKey) {
5
+ this.promptStore = promptStore;
6
+ this.contextKey = contextKey;
7
+ }
8
+ list() {
9
+ return this.rawList().map(queueItemDto);
10
+ }
11
+ rawList() {
12
+ return this.promptStore.list(this.contextKey);
13
+ }
14
+ length() {
15
+ return this.rawList().length;
16
+ }
17
+ isPaused() {
18
+ return this.promptStore.isPaused(this.contextKey);
19
+ }
20
+ enqueue(envelope) {
21
+ return this.promptStore.enqueue(this.contextKey, envelope);
22
+ }
23
+ enqueueFront(item) {
24
+ this.promptStore.enqueueFront(this.contextKey, item);
25
+ }
26
+ dequeue() {
27
+ return this.promptStore.dequeue(this.contextKey);
28
+ }
29
+ setLastPrompt(envelope) {
30
+ this.promptStore.setLastPrompt(this.contextKey, envelope);
31
+ }
32
+ getLastPrompt() {
33
+ return this.promptStore.getLastPrompt(this.contextKey);
34
+ }
35
+ apply(action, id) {
36
+ if (action === "pause")
37
+ this.promptStore.pause(this.contextKey);
38
+ if (action === "resume")
39
+ this.promptStore.resume(this.contextKey);
40
+ if (action === "clear")
41
+ this.promptStore.clear(this.contextKey);
42
+ if (id && action === "cancel")
43
+ this.promptStore.remove(this.contextKey, id);
44
+ if (id && action === "top")
45
+ this.promptStore.moveToTop(this.contextKey, id);
46
+ if (id && action === "up")
47
+ this.promptStore.moveUp(this.contextKey, id);
48
+ if (id && action === "down")
49
+ this.promptStore.moveDown(this.contextKey, id);
50
+ if (id && action === "run") {
51
+ const item = this.promptStore.remove(this.contextKey, id);
52
+ if (item)
53
+ this.promptStore.enqueueFront(this.contextKey, item);
54
+ }
55
+ }
56
+ }
57
+ export function queueItemDto(item) {
58
+ return {
59
+ id: item.id,
60
+ description: item.description,
61
+ createdAt: new Date(item.createdAt).toISOString(),
62
+ attempts: item.attempts ?? 0,
63
+ notBefore: item.notBefore ? new Date(item.notBefore).toISOString() : undefined,
64
+ lastError: item.lastError,
65
+ };
66
+ }
@@ -0,0 +1 @@
1
+ export {};