@nordbyte/nordrelay 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,58 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { createArtifactZipBundle, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
4
5
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
5
6
  import { CODEX_AGENT_CAPABILITIES, CODEX_REASONING_EFFORTS, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
6
7
  import { enabledAgents } from "./agent-factory.js";
7
8
  import { checkAuthStatus } from "./codex-auth.js";
9
+ import { getThreadRolloutSnapshot, } from "./codex-state.js";
8
10
  import { friendlyErrorText } from "./error-messages.js";
9
- import { getConnectorHealth, getVersionChecks, readFormattedLogTail } from "./operations.js";
11
+ import { getConnectorHealth, getVersionChecks, readFormattedLogTail, spawnConnectorRestart } from "./operations.js";
10
12
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
11
13
  import { renderSessionInfoPlain } from "./session-format.js";
12
14
  import { SessionRegistry } from "./session-registry.js";
13
15
  import { transcribeAudio } from "./voice.js";
16
+ import { WebActivityStore, WebChatStore, } from "./web-state.js";
14
17
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
15
- const WEB_CONTEXT_KEY = "0";
18
+ const WEB_CONTEXT_KEY = "web:dashboard";
16
19
  const MAX_WEB_SESSION_PAGE_SIZE = 50;
20
+ const MAX_CHAT_HISTORY = 250;
21
+ const MAX_TEXT_PREVIEW_BYTES = 256 * 1024;
17
22
  export class RelayRuntime {
18
23
  config;
19
24
  registry;
20
25
  promptStore;
26
+ chatStore;
27
+ activityStore;
21
28
  subscribers = new Set();
29
+ externalMonitor;
22
30
  draining = false;
23
31
  currentTurnId = null;
24
32
  accumulatedText = "";
33
+ currentTurnStartedAt = 0;
34
+ externalMirror = null;
25
35
  constructor(config) {
26
36
  this.config = config;
27
- this.registry = new SessionRegistry(config);
37
+ this.registry = new SessionRegistry(config, {
38
+ fileName: "web-contexts.json",
39
+ sqliteKey: "web-contexts",
40
+ });
28
41
  this.promptStore = new PromptStore(config.workspace, config.stateBackend);
42
+ this.chatStore = new WebChatStore(config.workspace, config.stateBackend, MAX_CHAT_HISTORY);
43
+ this.activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
44
+ if (config.codexExternalBusyCheckMs > 0) {
45
+ this.externalMonitor = setInterval(() => {
46
+ void this.monitorExternalActivity().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
47
+ }, config.codexExternalBusyCheckMs);
48
+ this.externalMonitor.unref?.();
49
+ }
29
50
  }
30
51
  subscribe(callback) {
31
52
  this.subscribers.add(callback);
32
53
  void this.snapshot().then((data) => callback({ type: "snapshot", data })).catch(() => { });
54
+ void this.chatHistory().then((messages) => callback({ type: "chat_history", messages })).catch(() => { });
55
+ callback({ type: "activity_update", events: this.activity({ limit: 50 }) });
33
56
  return () => this.subscribers.delete(callback);
34
57
  }
35
58
  async snapshot() {
@@ -39,6 +62,7 @@ export class RelayRuntime {
39
62
  session: info,
40
63
  sessionText: renderSessionInfoPlain(info),
41
64
  queue: this.queue(),
65
+ queuePaused: this.queuePaused(),
42
66
  processing: session.isProcessing(),
43
67
  enabledAgents: enabledAgents(this.config),
44
68
  workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
@@ -51,6 +75,53 @@ export class RelayRuntime {
51
75
  snapshot: await this.snapshot(),
52
76
  };
53
77
  }
78
+ async diagnostics() {
79
+ return {
80
+ health: await getConnectorHealth(),
81
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath }),
82
+ snapshot: await this.snapshot(),
83
+ runtime: {
84
+ stateBackend: this.config.stateBackend,
85
+ sourceWorkspace: this.config.workspace,
86
+ queuePaused: this.promptStore.isPaused(WEB_CONTEXT_KEY),
87
+ externalMirror: this.externalMirror ? { ...this.externalMirror } : null,
88
+ },
89
+ };
90
+ }
91
+ async controlOptions() {
92
+ const session = await this.getSession(true);
93
+ const info = this.publicInfo(session);
94
+ const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
95
+ return {
96
+ models: capabilities.modelSelection ? session.listModels() : [],
97
+ reasoningLabel: agentReasoningLabel(info.agentId),
98
+ reasoningOptions: info.agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS,
99
+ launchProfiles: capabilities.launchProfiles
100
+ ? this.config.launchProfiles.map((profile) => ({
101
+ id: profile.id,
102
+ label: profile.label,
103
+ behavior: `${profile.sandboxMode} / ${profile.approvalPolicy}`,
104
+ unsafe: profile.unsafe,
105
+ }))
106
+ : [],
107
+ workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
108
+ capabilities,
109
+ };
110
+ }
111
+ async chatHistory(limit = 200) {
112
+ const session = await this.getSession(true);
113
+ return this.chatStore.list(this.publicInfo(session).threadId, limit);
114
+ }
115
+ async clearChatHistory() {
116
+ const session = await this.getSession(true);
117
+ const removed = this.chatStore.clear(this.publicInfo(session).threadId);
118
+ const messages = await this.chatHistory();
119
+ this.broadcast({ type: "chat_history", messages });
120
+ return { removed, messages };
121
+ }
122
+ activity(options = {}) {
123
+ return this.activityStore.list(options);
124
+ }
54
125
  async listSessions(limit = 80, query = "") {
55
126
  return this.filteredSessions(await this.getSession(true), query, Math.max(1, limit * 3)).slice(0, limit);
56
127
  }
@@ -101,10 +172,32 @@ export class RelayRuntime {
101
172
  return this.publicInfo(session);
102
173
  }
103
174
  async newSession(options = {}) {
104
- const session = await this.getSession(true);
175
+ const session = options.agentId ? await this.registry.switchAgent(WEB_CONTEXT_KEY, options.agentId) : await this.getSession(true);
105
176
  this.ensureIdle(session);
177
+ if (options.reasoningEffort) {
178
+ const reasoningOptions = session.getInfo().agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
179
+ if (!reasoningOptions.includes(options.reasoningEffort)) {
180
+ throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${options.reasoningEffort}`);
181
+ }
182
+ session.setReasoningEffort(options.reasoningEffort);
183
+ }
184
+ if (options.launchProfileId && (session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).launchProfiles) {
185
+ session.setLaunchProfile(options.launchProfileId);
186
+ }
187
+ if (typeof options.fastMode === "boolean" && (session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
188
+ session.setFastMode(options.fastMode);
189
+ }
106
190
  const info = await session.newThread(options.workspace, options.model);
107
191
  this.updateSession(session);
192
+ this.appendActivity({
193
+ source: "web",
194
+ status: "info",
195
+ type: "session_new",
196
+ threadId: info.threadId,
197
+ workspace: info.workspace,
198
+ agentId: info.agentId,
199
+ detail: "New dashboard session created.",
200
+ });
108
201
  return this.publicInfo(session);
109
202
  }
110
203
  async switchSession(threadId) {
@@ -112,6 +205,16 @@ export class RelayRuntime {
112
205
  this.ensureIdle(session);
113
206
  const info = await session.switchSession(threadId);
114
207
  this.updateSession(session);
208
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
209
+ this.appendActivity({
210
+ source: "web",
211
+ status: "info",
212
+ type: "session_switch",
213
+ threadId: info.threadId,
214
+ workspace: info.workspace,
215
+ agentId: info.agentId,
216
+ detail: "Dashboard switched session.",
217
+ });
115
218
  return this.publicInfo(session);
116
219
  }
117
220
  async attachSession(threadId) {
@@ -240,6 +343,17 @@ export class RelayRuntime {
240
343
  const session = await this.getSession(false);
241
344
  if (session.isProcessing()) {
242
345
  const queued = this.promptStore.enqueue(WEB_CONTEXT_KEY, envelope);
346
+ const info = this.publicInfo(session);
347
+ this.appendActivity({
348
+ source: "web",
349
+ status: "queued",
350
+ type: "prompt_queued",
351
+ threadId: info.threadId,
352
+ workspace: info.workspace,
353
+ agentId: info.agentId,
354
+ prompt: envelope.description,
355
+ detail: `Queued at position ${this.promptStore.list(WEB_CONTEXT_KEY).length}.`,
356
+ });
243
357
  this.broadcastQueue();
244
358
  return { queued: true, queueId: queued.id };
245
359
  }
@@ -251,6 +365,9 @@ export class RelayRuntime {
251
365
  queue() {
252
366
  return this.promptStore.list(WEB_CONTEXT_KEY).map(queueItemDto);
253
367
  }
368
+ queuePaused() {
369
+ return this.promptStore.isPaused(WEB_CONTEXT_KEY);
370
+ }
254
371
  queueAction(action, id) {
255
372
  if (action === "pause")
256
373
  this.promptStore.pause(WEB_CONTEXT_KEY);
@@ -272,6 +389,14 @@ export class RelayRuntime {
272
389
  this.promptStore.enqueueFront(WEB_CONTEXT_KEY, item);
273
390
  void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
274
391
  }
392
+ this.appendActivity({
393
+ source: "web",
394
+ status: "info",
395
+ type: "queue_updated",
396
+ threadId: null,
397
+ workspace: this.config.workspace,
398
+ detail: id ? `${action}: ${id}` : action,
399
+ });
275
400
  this.broadcastQueue();
276
401
  return this.queue();
277
402
  }
@@ -298,6 +423,38 @@ export class RelayRuntime {
298
423
  });
299
424
  return bundle ? { path: bundle.localPath, name: bundle.name } : null;
300
425
  }
426
+ async artifactPreview(turnId, relativePath) {
427
+ const report = await this.artifact(turnId);
428
+ const artifact = report?.artifacts.find((candidate) => candidate.relativePath.split(path.sep).join("/") === relativePath);
429
+ if (!artifact) {
430
+ return null;
431
+ }
432
+ const extension = path.extname(artifact.name).toLowerCase();
433
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
434
+ return {
435
+ kind: "image",
436
+ name: artifact.name,
437
+ sizeBytes: artifact.sizeBytes,
438
+ };
439
+ }
440
+ if (!isPreviewableTextFile(extension, artifact.sizeBytes)) {
441
+ return {
442
+ kind: "unsupported",
443
+ name: artifact.name,
444
+ sizeBytes: artifact.sizeBytes,
445
+ detail: artifact.sizeBytes > MAX_TEXT_PREVIEW_BYTES ? "File is too large for inline preview." : "File type is not previewable.",
446
+ };
447
+ }
448
+ const buffer = await readFile(artifact.localPath);
449
+ const truncated = buffer.byteLength > MAX_TEXT_PREVIEW_BYTES;
450
+ return {
451
+ kind: "text",
452
+ name: artifact.name,
453
+ sizeBytes: artifact.sizeBytes,
454
+ truncated,
455
+ text: buffer.subarray(0, MAX_TEXT_PREVIEW_BYTES).toString("utf8"),
456
+ };
457
+ }
301
458
  async logs(target = "connector", lines = 100) {
302
459
  if (target === "update") {
303
460
  const { getUpdateLogPath } = await import("./operations.js");
@@ -305,10 +462,160 @@ export class RelayRuntime {
305
462
  }
306
463
  return readFormattedLogTail(lines);
307
464
  }
465
+ restartConnector() {
466
+ spawnConnectorRestart();
467
+ this.broadcastStatus("Restart requested. The dashboard may disconnect briefly.", "warn");
468
+ this.appendActivity({
469
+ source: "web",
470
+ status: "info",
471
+ type: "restart_requested",
472
+ threadId: null,
473
+ workspace: this.config.workspace,
474
+ detail: "Dashboard requested a connector restart.",
475
+ });
476
+ return { ok: true, message: "Restart requested." };
477
+ }
308
478
  dispose() {
479
+ if (this.externalMonitor) {
480
+ clearInterval(this.externalMonitor);
481
+ }
309
482
  this.registry.disposeAll();
310
483
  this.subscribers.clear();
311
484
  }
485
+ async monitorExternalActivity() {
486
+ const session = await this.getSession(true);
487
+ const info = this.publicInfo(session);
488
+ if (!info.capabilities.externalActivity || info.agentId !== "codex" || !info.threadId || session.isProcessing()) {
489
+ return;
490
+ }
491
+ const snapshot = getThreadRolloutSnapshot(info.threadId, {
492
+ afterLine: this.externalMirror?.threadId === info.threadId ? this.externalMirror.lastLine : Number.MAX_SAFE_INTEGER,
493
+ staleAfterMs: this.config.codexExternalBusyStaleMs,
494
+ }) ?? getThreadRolloutSnapshot(info.threadId, {
495
+ staleAfterMs: this.config.codexExternalBusyStaleMs,
496
+ maxEvents: 0,
497
+ });
498
+ if (!snapshot) {
499
+ return;
500
+ }
501
+ if (!this.externalMirror || this.externalMirror.threadId !== snapshot.threadId || this.externalMirror.rolloutPath !== snapshot.rolloutPath) {
502
+ this.externalMirror = {
503
+ threadId: snapshot.threadId,
504
+ rolloutPath: snapshot.rolloutPath,
505
+ lastLine: snapshot.lineCount,
506
+ turnId: snapshot.activity.turnId,
507
+ startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
508
+ };
509
+ if (snapshot.activity.active) {
510
+ this.startExternalTurn(snapshot);
511
+ }
512
+ return;
513
+ }
514
+ const mirror = this.externalMirror;
515
+ if (snapshot.activity.active) {
516
+ if (mirror.turnId !== snapshot.activity.turnId) {
517
+ mirror.turnId = snapshot.activity.turnId;
518
+ mirror.startedAt = snapshot.activity.startedAt?.toISOString() ?? null;
519
+ mirror.latestAgentLine = undefined;
520
+ this.startExternalTurn(snapshot);
521
+ }
522
+ this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine));
523
+ mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
524
+ mirror.latestStatus = externalStatusLine(snapshot, this.queue().length);
525
+ this.broadcastStatus(mirror.latestStatus, "info");
526
+ return;
527
+ }
528
+ const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
529
+ if (terminalEvent && terminalEvent.lineNumber > mirror.lastLine) {
530
+ const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
531
+ const finalText = finalAgent?.text ?? snapshot.latestAgentMessage;
532
+ const finalLine = finalAgent?.lineNumber ?? snapshot.lineCount;
533
+ if (finalText && finalLine !== mirror.latestAgentLine) {
534
+ this.chatStore.append({
535
+ threadId: snapshot.threadId,
536
+ role: "agent",
537
+ text: finalText,
538
+ source: "cli",
539
+ turnId: terminalEvent.turnId ?? undefined,
540
+ });
541
+ this.broadcast({ type: "text_delta", id: terminalEvent.turnId ?? "cli", delta: finalText });
542
+ mirror.latestAgentLine = finalLine;
543
+ }
544
+ const externalStartedAt = mirror.startedAt ? new Date(mirror.startedAt) : snapshot.activity.startedAt;
545
+ this.broadcast({
546
+ type: "turn_complete",
547
+ id: terminalEvent.turnId ?? "cli",
548
+ at: terminalEvent.timestamp?.toISOString() ?? new Date().toISOString(),
549
+ });
550
+ this.appendActivity({
551
+ source: "cli",
552
+ status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
553
+ type: "cli_turn_finished",
554
+ threadId: snapshot.threadId,
555
+ workspace: info.workspace,
556
+ agentId: info.agentId,
557
+ prompt: snapshot.latestUserMessage ?? undefined,
558
+ detail: `Codex CLI task ${terminalEvent.status ?? "finished"}.`,
559
+ durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
560
+ });
561
+ this.broadcastStatus(`Codex CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
562
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
563
+ }
564
+ mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
565
+ }
566
+ startExternalTurn(snapshot) {
567
+ const prompt = snapshot.latestUserMessage ?? "Codex CLI task";
568
+ this.chatStore.append({
569
+ threadId: snapshot.threadId,
570
+ role: "user",
571
+ text: prompt,
572
+ source: "cli",
573
+ turnId: snapshot.activity.turnId ?? undefined,
574
+ timestamp: snapshot.activity.startedAt?.toISOString(),
575
+ });
576
+ this.broadcast({
577
+ type: "turn_start",
578
+ id: snapshot.activity.turnId ?? "cli",
579
+ prompt,
580
+ at: snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString(),
581
+ source: "cli",
582
+ });
583
+ this.appendActivity({
584
+ source: "cli",
585
+ status: "running",
586
+ type: "cli_turn_started",
587
+ threadId: snapshot.threadId,
588
+ prompt,
589
+ detail: `Rollout: ${snapshot.rolloutPath}`,
590
+ });
591
+ }
592
+ broadcastExternalEvents(snapshot, events) {
593
+ for (const event of events) {
594
+ if (event.kind === "tool" && event.status === "started") {
595
+ this.broadcast({
596
+ type: "tool_start",
597
+ id: snapshot.activity.turnId ?? "cli",
598
+ toolCallId: `cli-${event.lineNumber}`,
599
+ toolName: event.toolName ?? "tool",
600
+ });
601
+ this.appendActivity({
602
+ source: "cli",
603
+ status: "running",
604
+ type: "cli_tool_started",
605
+ threadId: snapshot.threadId,
606
+ detail: event.toolName ?? "tool",
607
+ });
608
+ }
609
+ if (event.kind === "tool" && event.status === "finished") {
610
+ this.broadcast({
611
+ type: "tool_end",
612
+ id: snapshot.activity.turnId ?? "cli",
613
+ toolCallId: `cli-${event.lineNumber}`,
614
+ isError: false,
615
+ });
616
+ }
617
+ }
618
+ }
312
619
  async getSession(deferThreadStart) {
313
620
  return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
314
621
  }
@@ -338,9 +645,28 @@ export class RelayRuntime {
338
645
  }
339
646
  const turnId = randomUUID().slice(0, 12);
340
647
  this.currentTurnId = turnId;
648
+ this.currentTurnStartedAt = Date.now();
341
649
  this.accumulatedText = "";
342
650
  this.promptStore.setLastPrompt(WEB_CONTEXT_KEY, envelope);
343
- this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: new Date().toISOString() });
651
+ const startedAt = new Date().toISOString();
652
+ this.chatStore.append({
653
+ threadId: info.threadId ?? "pending",
654
+ role: "user",
655
+ text: envelope.description,
656
+ source: "web",
657
+ turnId,
658
+ timestamp: startedAt,
659
+ });
660
+ this.appendActivity({
661
+ source: "web",
662
+ status: "running",
663
+ type: "prompt_started",
664
+ threadId: info.threadId,
665
+ workspace: info.workspace,
666
+ agentId: info.agentId,
667
+ prompt: envelope.description,
668
+ });
669
+ this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: "web" });
344
670
  const callbacks = {
345
671
  onTextDelta: (delta) => {
346
672
  this.accumulatedText += delta;
@@ -356,10 +682,50 @@ export class RelayRuntime {
356
682
  try {
357
683
  await session.prompt(envelope.input, callbacks);
358
684
  this.updateSession(session);
685
+ if (this.accumulatedText.trim()) {
686
+ this.chatStore.append({
687
+ threadId: info.threadId ?? "pending",
688
+ role: "agent",
689
+ text: this.accumulatedText,
690
+ source: "web",
691
+ turnId,
692
+ });
693
+ }
694
+ this.appendActivity({
695
+ source: "web",
696
+ status: "completed",
697
+ type: "prompt_completed",
698
+ threadId: info.threadId,
699
+ workspace: info.workspace,
700
+ agentId: info.agentId,
701
+ prompt: envelope.description,
702
+ durationMs: Date.now() - this.currentTurnStartedAt,
703
+ });
359
704
  this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
705
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
360
706
  }
361
707
  catch (error) {
362
- this.broadcast({ type: "turn_error", id: turnId, error: friendlyErrorText(error), at: new Date().toISOString() });
708
+ const errorText = friendlyErrorText(error);
709
+ this.chatStore.append({
710
+ threadId: info.threadId ?? "pending",
711
+ role: "system",
712
+ text: `Error: ${errorText}`,
713
+ source: "web",
714
+ turnId,
715
+ });
716
+ this.appendActivity({
717
+ source: "web",
718
+ status: "failed",
719
+ type: "prompt_failed",
720
+ threadId: info.threadId,
721
+ workspace: info.workspace,
722
+ agentId: info.agentId,
723
+ prompt: envelope.description,
724
+ detail: errorText,
725
+ durationMs: Date.now() - this.currentTurnStartedAt,
726
+ });
727
+ this.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
728
+ this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
363
729
  throw error;
364
730
  }
365
731
  finally {
@@ -391,8 +757,13 @@ export class RelayRuntime {
391
757
  this.registry.updateMetadata(WEB_CONTEXT_KEY, session);
392
758
  this.broadcast({ type: "session_update", session: this.publicInfo(session) });
393
759
  }
760
+ appendActivity(input) {
761
+ const event = this.activityStore.append(input);
762
+ this.broadcast({ type: "activity_update", events: this.activity({ limit: 50 }) });
763
+ return event;
764
+ }
394
765
  broadcastQueue() {
395
- this.broadcast({ type: "queue_update", queue: this.queue() });
766
+ this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
396
767
  }
397
768
  broadcastStatus(message, level = "info") {
398
769
  this.broadcast({ type: "status", message, level, at: new Date().toISOString() });
@@ -444,6 +815,30 @@ function artifactDto(report) {
444
815
  })),
445
816
  };
446
817
  }
818
+ function externalStatusLine(snapshot, queueLength) {
819
+ const elapsed = snapshot.activity.startedAt
820
+ ? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
821
+ : "-";
822
+ const tool = snapshot.latestToolName ?? "-";
823
+ return `Codex CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
824
+ }
825
+ function durationFromDates(start, end) {
826
+ if (!start || !end) {
827
+ return undefined;
828
+ }
829
+ return Math.max(0, end.getTime() - start.getTime());
830
+ }
831
+ function formatDuration(seconds) {
832
+ if (!Number.isFinite(seconds) || seconds < 0) {
833
+ return "-";
834
+ }
835
+ if (seconds < 60) {
836
+ return `${Math.round(seconds)}s`;
837
+ }
838
+ const minutes = Math.floor(seconds / 60);
839
+ const remainder = Math.round(seconds % 60);
840
+ return `${minutes}m ${remainder}s`;
841
+ }
447
842
  function normalizeMimeType(value, name) {
448
843
  const configured = value?.trim();
449
844
  if (configured) {
@@ -477,3 +872,37 @@ function uploadFileDtos(files) {
477
872
  sizeBytes: file.sizeBytes,
478
873
  }));
479
874
  }
875
+ function isPreviewableTextFile(extension, sizeBytes) {
876
+ if (sizeBytes > MAX_TEXT_PREVIEW_BYTES * 4) {
877
+ return false;
878
+ }
879
+ return [
880
+ "",
881
+ ".c",
882
+ ".conf",
883
+ ".cpp",
884
+ ".css",
885
+ ".csv",
886
+ ".env",
887
+ ".go",
888
+ ".html",
889
+ ".java",
890
+ ".js",
891
+ ".json",
892
+ ".jsx",
893
+ ".log",
894
+ ".md",
895
+ ".py",
896
+ ".rb",
897
+ ".rs",
898
+ ".sh",
899
+ ".sql",
900
+ ".toml",
901
+ ".ts",
902
+ ".tsx",
903
+ ".txt",
904
+ ".xml",
905
+ ".yaml",
906
+ ".yml",
907
+ ].includes(extension);
908
+ }
@@ -8,12 +8,12 @@ export class SessionRegistry {
8
8
  metadata = new Map();
9
9
  store;
10
10
  onRemoveCallback;
11
- constructor(config) {
11
+ constructor(config, options = {}) {
12
12
  this.config = config;
13
13
  this.store = createDocumentStore({
14
14
  workspace: config.workspace,
15
- fileName: "contexts.json",
16
- sqliteKey: "contexts",
15
+ fileName: options.fileName ?? "contexts.json",
16
+ sqliteKey: options.sqliteKey ?? "contexts",
17
17
  backend: config.stateBackend,
18
18
  });
19
19
  this.loadPersistedMetadata();