agent-relay-server 0.4.38 → 0.4.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.4.38",
3
+ "version": "0.4.39",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -6,6 +6,7 @@
6
6
  const DEFAULT_INBOX_COMPOSE = { toMode: "agent", to: "", body: "", channel: "", subject: "", claimable: false };
7
7
  const DEFAULT_PAIR_MESSAGE = { pairId: "", from: "", body: "", subject: "" };
8
8
  const DEFAULT_PAIR_INVITE = { requesterId: "", targetId: "", objective: "" };
9
+ const DEFAULT_AGENT_SPAWN = { provider: "codex", approvalMode: "guarded", cwd: "", label: "" };
9
10
  const CLOSED_TASK_STATUSES = new Set(["done", "failed", "canceled"]);
10
11
  const WAITING_TASK_STATUSES = new Set(["open", "blocked"]);
11
12
  const STATUS_SORT_ORDER = { online: 0, idle: 1, busy: 2, offline: 3 };
@@ -15,6 +16,7 @@
15
16
  claude: "claude-sol",
16
17
  user: "ti-user",
17
18
  system: "ti-server",
19
+ channel: "ti-messages",
18
20
  agent: "ti-robot",
19
21
  };
20
22
  const AGENT_TYPE_TITLES = {
@@ -22,6 +24,7 @@
22
24
  claude: "Claude agent",
23
25
  user: "Human operator",
24
26
  system: "System",
27
+ channel: "Channel",
25
28
  agent: "Agent",
26
29
  };
27
30
 
@@ -76,9 +79,13 @@
76
79
  selectedAgent: "",
77
80
  agentDetailOpen: false,
78
81
  agentDetailId: "",
82
+ channelDetailOpen: false,
83
+ channelDetailId: "",
79
84
  selectedInboxThread: "",
80
85
  replyTo: null,
81
86
  composeOpen: false,
87
+ agentSpawnOpen: false,
88
+ agentDirectoryBrowser: { open: false, loading: false, path: "", parent: "", home: "", cwd: "", entries: [], error: "" },
82
89
  pairInviteOpen: false,
83
90
  pairMessageOpen: false,
84
91
  threadOpen: false,
@@ -90,6 +97,7 @@
90
97
  authNeeded: false,
91
98
 
92
99
  compose: { ...DEFAULT_COMPOSE },
100
+ agentSpawn: { ...DEFAULT_AGENT_SPAWN },
93
101
  pairInvite: { ...DEFAULT_PAIR_INVITE },
94
102
  pairMessage: { ...DEFAULT_PAIR_MESSAGE },
95
103
  inboxCompose: { ...DEFAULT_INBOX_COMPOSE },
@@ -496,11 +504,14 @@
496
504
  function createComputedDescriptors() {
497
505
  return {
498
506
  onlineCount: { get: getOnlineCount },
507
+ busyAgentCount: { get: getBusyAgentCount },
499
508
  hiddenBuiltInAgentCount: { get: getHiddenBuiltInAgentCount },
500
509
  sortedAgents: { get: getSortedAgents },
501
510
  pairsByAgentId: { get: getPairsByAgentId },
502
511
  selectedAgentDetail: { get: getSelectedAgentDetail },
503
512
  agentDetailMessages: { get: getAgentDetailMessages },
513
+ selectedChannelDetail: { get: getSelectedChannelDetail },
514
+ channelDetailMessages: { get: getChannelDetailMessages },
504
515
  pairMessagePair: { get: getPairMessagePair },
505
516
  allInboxThreads: { get: getAllInboxThreads },
506
517
  inboxThreads: { get: getInboxThreads },
@@ -513,6 +524,7 @@
513
524
  workQueueItems: { get: getWorkQueueItems },
514
525
  channelCards: { get: getChannelCards },
515
526
  integrationCards: { get: getIntegrationCards },
527
+ openPairCount: { get: getOpenPairCount },
516
528
  filteredMessages: { get: getFilteredMessages },
517
529
  groupedMessages: { get: getGroupedMessages },
518
530
  filteredTasks: { get: getFilteredTasks },
@@ -530,6 +542,10 @@
530
542
  return visibleAgents(this).filter((agent) => agent.status !== "offline").length;
531
543
  }
532
544
 
545
+ function getBusyAgentCount() {
546
+ return this.agents.filter((agent) => agent.status === "busy").length;
547
+ }
548
+
533
549
  function getHiddenBuiltInAgentCount() {
534
550
  return this.showBuiltIns ? 0 : this.agents.filter(isBuiltInAgent).length;
535
551
  }
@@ -578,6 +594,29 @@
578
594
  .slice(0, 8);
579
595
  }
580
596
 
597
+ function getSelectedChannelDetail() {
598
+ if (!this.channelDetailId) return null;
599
+ return (this.channels || []).find((channel) => channel.id === this.channelDetailId) || null;
600
+ }
601
+
602
+ function getChannelDetailMessages() {
603
+ const channel = this.selectedChannelDetail;
604
+ if (!channel) return [];
605
+ return this.messages
606
+ .filter((msg) => messageMatchesChannel(msg, channel))
607
+ .slice()
608
+ .sort((a, b) => b.id - a.id)
609
+ .slice(0, 8);
610
+ }
611
+
612
+ function messageMatchesChannel(message, channel) {
613
+ if (!message || !channel) return false;
614
+ const channelKeys = [channel.id, channel.type, ...(channel.topicChannels || [])].filter(Boolean);
615
+ if (message.channel && channelKeys.includes(message.channel)) return true;
616
+ if (channel.agentId && (message.from === channel.agentId || message.to === channel.agentId)) return true;
617
+ return false;
618
+ }
619
+
581
620
  function getPairMessagePair() {
582
621
  return this.pairs.find((pair) => pair.id === this.pairMessage.pairId) || null;
583
622
  }
@@ -1071,11 +1110,47 @@
1071
1110
  });
1072
1111
  }
1073
1112
 
1113
+ function getOpenPairCount() {
1114
+ return (this.pairs || []).filter(isOpenPair).length;
1115
+ }
1116
+
1117
+ function isOpenPair(pair) {
1118
+ return pair?.status === "active" || pair?.status === "pending";
1119
+ }
1120
+
1074
1121
  function getComposeAgents() {
1075
1122
  const list = visibleAgents(this);
1076
1123
  return this.showOffline ? list : list.filter((agent) => agent.status !== "offline");
1077
1124
  }
1078
1125
 
1126
+ function composeTargetAllowsClaimable() {
1127
+ return matchingComposeRecipientCount(this, this.compose.to) > 1;
1128
+ }
1129
+
1130
+ function inboxComposeTargetAllowsClaimable() {
1131
+ return matchingComposeRecipientCount(this, inboxComposeTarget(this)) > 1;
1132
+ }
1133
+
1134
+ function matchingComposeRecipientCount(vm, target) {
1135
+ if (!target) return 0;
1136
+ const candidates = vm.composeAgents.filter((agent) => !isBuiltInAgent(agent) && !isChannelAgent(agent));
1137
+ if (target === "broadcast") return candidates.length;
1138
+ if (vm.agentsById[target]) return 1;
1139
+ if (target.startsWith("tag:")) {
1140
+ const tag = target.slice(4);
1141
+ return candidates.filter((agent) => (agent.tags || []).includes(tag)).length;
1142
+ }
1143
+ if (target.startsWith("cap:")) {
1144
+ const cap = target.slice(4);
1145
+ return candidates.filter((agent) => (agent.capabilities || []).includes(cap)).length;
1146
+ }
1147
+ if (target.startsWith("label:")) {
1148
+ const label = target.slice(6);
1149
+ return candidates.filter((agent) => agent.label === label).length;
1150
+ }
1151
+ return 1;
1152
+ }
1153
+
1079
1154
  function getUniqueLabels() {
1080
1155
  return [...new Set(visibleAgents(this).filter((agent) => agent.label).map((agent) => agent.label))];
1081
1156
  }
@@ -1089,13 +1164,22 @@
1089
1164
  }
1090
1165
 
1091
1166
  function visibleAgents(vm) {
1092
- return vm.showBuiltIns ? [...vm.agents] : vm.agents.filter((agent) => !isBuiltInAgent(agent));
1167
+ const nonChannelAgents = vm.agents.filter((agent) => !isChannelAgent(agent));
1168
+ return vm.showBuiltIns ? nonChannelAgents : nonChannelAgents.filter((agent) => !isBuiltInAgent(agent));
1093
1169
  }
1094
1170
 
1095
1171
  function isBuiltInAgent(agent) {
1096
1172
  return agent?.meta?.builtin === true || agent?.id === HUMAN_AGENT_ID || agent?.id === "system";
1097
1173
  }
1098
1174
 
1175
+ function isChannelAgent(agent) {
1176
+ return agent?.meta?.kind === "channel" || agent?.tags?.includes("channel");
1177
+ }
1178
+
1179
+ function agentSupportsControlActions(agent) {
1180
+ return Boolean(agent && !isBuiltInAgent(agent) && !isChannelAgent(agent));
1181
+ }
1182
+
1099
1183
  function applyAgentPreset(vm, list) {
1100
1184
  switch (vm.agentPresetFilter) {
1101
1185
  case "active":
@@ -1249,6 +1333,9 @@
1249
1333
  agentChannels,
1250
1334
  channelPresence,
1251
1335
  integrationPresence,
1336
+ composeTargetAllowsClaimable,
1337
+ inboxComposeTargetAllowsClaimable,
1338
+ agentSupportsControlActions,
1252
1339
  timeAgo,
1253
1340
  fmtTime,
1254
1341
  healthAlertClass,
@@ -1368,6 +1455,7 @@
1368
1455
  function agentType(agent) {
1369
1456
  if (agent?.id === HUMAN_AGENT_ID) return "user";
1370
1457
  if (agent?.id === "system") return "system";
1458
+ if (isChannelAgent(agent)) return "channel";
1371
1459
 
1372
1460
  const values = [
1373
1461
  ...(agent?.tags || []),
@@ -1573,6 +1661,12 @@
1573
1661
  openPairInvite,
1574
1662
  closePairInvite,
1575
1663
  doCreatePair,
1664
+ openAgentSpawn,
1665
+ closeAgentSpawn,
1666
+ doSpawnAgent,
1667
+ openAgentDirectoryBrowser,
1668
+ browseAgentDirectory,
1669
+ selectAgentDirectory,
1576
1670
  doSendPairMessage,
1577
1671
  doAcceptPair,
1578
1672
  doRejectPair,
@@ -1783,7 +1877,7 @@
1783
1877
  }
1784
1878
 
1785
1879
  function resetInboxComposeTarget() {
1786
- this.inboxCompose = { ...this.inboxCompose, to: "" };
1880
+ this.inboxCompose = { ...this.inboxCompose, to: "", claimable: false };
1787
1881
  }
1788
1882
 
1789
1883
  function inboxComposeTarget(vm) {
@@ -1809,14 +1903,14 @@
1809
1903
  };
1810
1904
  if (this.inboxCompose.channel) payload.channel = this.inboxCompose.channel;
1811
1905
  if (this.inboxCompose.subject) payload.subject = this.inboxCompose.subject;
1812
- if (this.inboxCompose.claimable) payload.claimable = true;
1906
+ if (this.inboxCompose.claimable && inboxComposeTargetAllowsClaimable.call(this)) payload.claimable = true;
1813
1907
  await this.api("POST", "/messages", payload);
1814
1908
  this.recordOperatorActivity({
1815
- title: this.inboxCompose.claimable ? "Claimable task sent" : "Message sent",
1909
+ title: payload.claimable ? "Claimable task sent" : "Message sent",
1816
1910
  body: this.inboxCompose.subject || this.inboxCompose.body,
1817
1911
  meta: "to " + this.displayTarget(target),
1818
- icon: this.inboxCompose.claimable ? "ti-hand-grab" : "ti-send",
1819
- kind: this.inboxCompose.claimable ? "task" : "operator",
1912
+ icon: payload.claimable ? "ti-hand-grab" : "ti-send",
1913
+ kind: payload.claimable ? "task" : "operator",
1820
1914
  view: inboxPeer(payload) ? "inbox" : "messages",
1821
1915
  peer: inboxPeer(payload),
1822
1916
  });
@@ -1832,7 +1926,7 @@
1832
1926
  this.replyTo = { id: msg.id, from: msg.from };
1833
1927
  this.compose = {
1834
1928
  ...DEFAULT_COMPOSE,
1835
- from: this.view === "inbox" ? HUMAN_AGENT_ID : "",
1929
+ from: HUMAN_AGENT_ID,
1836
1930
  to: replyTarget,
1837
1931
  channel: msg.channel || "",
1838
1932
  };
@@ -1852,7 +1946,7 @@
1852
1946
  if (vm.compose.channel) payload.channel = vm.compose.channel;
1853
1947
  if (vm.compose.subject) payload.subject = vm.compose.subject;
1854
1948
  if (vm.replyTo) payload.replyTo = vm.replyTo.id;
1855
- if (vm.compose.claimable) payload.claimable = true;
1949
+ if (vm.compose.claimable && composeTargetAllowsClaimable.call(vm)) payload.claimable = true;
1856
1950
  return payload;
1857
1951
  }
1858
1952
 
@@ -1876,7 +1970,7 @@
1876
1970
  });
1877
1971
  this.composeOpen = false;
1878
1972
  this.replyTo = null;
1879
- this.compose = { ...DEFAULT_COMPOSE };
1973
+ this.compose = { ...DEFAULT_COMPOSE, from: HUMAN_AGENT_ID };
1880
1974
  await this.fetchMessages();
1881
1975
  } catch (e) {
1882
1976
  alert("Send failed: " + e.message);
@@ -2313,6 +2407,77 @@
2313
2407
  }
2314
2408
  }
2315
2409
 
2410
+ function openAgentSpawn() {
2411
+ this.agentSpawn = { ...DEFAULT_AGENT_SPAWN };
2412
+ this.agentDirectoryBrowser = { open: false, loading: false, path: "", parent: "", home: "", cwd: "", entries: [], error: "" };
2413
+ this.agentSpawnOpen = true;
2414
+ this.$nextTick(() => this.$refs?.agentSpawnCwd?.focus());
2415
+ }
2416
+
2417
+ function closeAgentSpawn() {
2418
+ this.agentSpawnOpen = false;
2419
+ this.agentSpawn = { ...DEFAULT_AGENT_SPAWN };
2420
+ }
2421
+
2422
+ async function openAgentDirectoryBrowser() {
2423
+ this.agentDirectoryBrowser = { ...this.agentDirectoryBrowser, open: true };
2424
+ await this.browseAgentDirectory(this.agentSpawn.cwd || "");
2425
+ }
2426
+
2427
+ async function browseAgentDirectory(path) {
2428
+ this.agentDirectoryBrowser = { ...this.agentDirectoryBrowser, loading: true, error: "" };
2429
+ try {
2430
+ const query = path ? "?path=" + encodeURIComponent(path) : "";
2431
+ const listing = await this.api("GET", "/agents/spawn/directories" + query);
2432
+ this.agentDirectoryBrowser = {
2433
+ open: true,
2434
+ loading: false,
2435
+ path: listing.path || "",
2436
+ parent: listing.parent || "",
2437
+ home: listing.home || "",
2438
+ cwd: listing.cwd || "",
2439
+ entries: listing.entries || [],
2440
+ error: "",
2441
+ };
2442
+ this.agentSpawn = { ...this.agentSpawn, cwd: listing.path || this.agentSpawn.cwd };
2443
+ } catch (e) {
2444
+ this.agentDirectoryBrowser = { ...this.agentDirectoryBrowser, loading: false, error: e.message };
2445
+ }
2446
+ }
2447
+
2448
+ function selectAgentDirectory(path) {
2449
+ this.agentSpawn = { ...this.agentSpawn, cwd: path || this.agentDirectoryBrowser.path };
2450
+ this.agentDirectoryBrowser = { ...this.agentDirectoryBrowser, open: false };
2451
+ }
2452
+
2453
+ async function doSpawnAgent() {
2454
+ if (this.agentSpawn.provider !== "codex") {
2455
+ alert("Only Codex live sessions can be spawned from the dashboard right now.");
2456
+ return;
2457
+ }
2458
+ try {
2459
+ const payload = {
2460
+ provider: "codex",
2461
+ approvalMode: this.agentSpawn.approvalMode || "guarded",
2462
+ };
2463
+ if (this.agentSpawn.cwd) payload.cwd = this.agentSpawn.cwd;
2464
+ if (this.agentSpawn.label) payload.label = this.agentSpawn.label;
2465
+ const result = await this.api("POST", "/agents/spawn", payload);
2466
+ this.recordOperatorActivity({
2467
+ title: "Codex agent spawn requested",
2468
+ body: result?.cwd || payload.cwd || "",
2469
+ meta: result?.pid ? "pid " + result.pid : "starting",
2470
+ icon: "ti-plus",
2471
+ kind: "state",
2472
+ view: "agents",
2473
+ });
2474
+ this.closeAgentSpawn();
2475
+ await Promise.all([this.fetchAgents(), this.fetchActivityEvents()]);
2476
+ } catch (e) {
2477
+ alert("Spawn failed: " + e.message);
2478
+ }
2479
+ }
2480
+
2316
2481
  function closePairMessage() {
2317
2482
  this.pairMessageOpen = false;
2318
2483
  this.pairMessage = { ...DEFAULT_PAIR_MESSAGE };
@@ -2409,6 +2574,16 @@
2409
2574
  this.agentDetailOpen = false;
2410
2575
  },
2411
2576
 
2577
+ openChannelDetail(channel) {
2578
+ if (!channel) return;
2579
+ this.channelDetailId = channel.id;
2580
+ this.channelDetailOpen = true;
2581
+ },
2582
+
2583
+ closeChannelDetail() {
2584
+ this.channelDetailOpen = false;
2585
+ },
2586
+
2412
2587
  openRename(agent) {
2413
2588
  this.renameModal = { show: true, agentId: agent.id, label: agent.label || "" };
2414
2589
  this.$nextTick(() => this.$refs.renameInput?.focus());
@@ -2438,6 +2613,26 @@
2438
2613
  alert("Delete failed: " + e.message);
2439
2614
  }
2440
2615
  },
2616
+
2617
+ async doAgentAction(agent, action) {
2618
+ if (!agent || !action) return;
2619
+ try {
2620
+ const result = await this.api("POST", "/agents/" + encodeURIComponent(agent.id) + "/actions", { action });
2621
+ this.recordOperatorActivity({
2622
+ title: action === "restart" ? "Agent restart requested" : "Agent shutdown requested",
2623
+ body: this.displayName(agent),
2624
+ meta: agent.id,
2625
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
2626
+ kind: "state",
2627
+ view: "agents",
2628
+ agentId: agent.id,
2629
+ messageId: result?.message?.id,
2630
+ });
2631
+ await Promise.all([this.fetchMessages(), this.fetchActivityEvents()]);
2632
+ } catch (e) {
2633
+ alert("Agent action failed: " + e.message);
2634
+ }
2635
+ },
2441
2636
  };
2442
2637
  }
2443
2638
 
@@ -2495,7 +2690,7 @@
2495
2690
 
2496
2691
  function renderStatusChart() {
2497
2692
  const { labels, series } = countAgentStatuses(this.agents);
2498
- const colorMap = { online: "#48bb78", idle: "#48bb78", busy: "#ecc94b", offline: "#718096" };
2693
+ const colorMap = { idle: "#4299e1", busy: "#ecc94b" };
2499
2694
 
2500
2695
  if (this.chartInstances.status) {
2501
2696
  this.chartInstances.status.updateOptions({
@@ -2522,10 +2717,12 @@
2522
2717
  }
2523
2718
 
2524
2719
  function countAgentStatuses(agents) {
2525
- const counts = { online: 0, idle: 0, busy: 0, offline: 0 };
2526
- for (const agent of agents) counts[agent.status] = (counts[agent.status] || 0) + 1;
2720
+ const counts = { idle: 0, busy: 0 };
2721
+ for (const agent of agents) {
2722
+ if (agent.status in counts) counts[agent.status] += 1;
2723
+ }
2527
2724
 
2528
- const labels = Object.keys(counts).filter((key) => counts[key] > 0);
2725
+ const labels = Object.keys(counts);
2529
2726
  return { labels, series: labels.map((key) => counts[key]) };
2530
2727
  }
2531
2728
 
package/public/index.html CHANGED
@@ -205,18 +205,21 @@
205
205
  </a>
206
206
  <a href="#" class="nav-link" :class="{ active: view === 'agents' }" @click.prevent="switchView('agents')">
207
207
  <i class="ti ti-robot"></i>Agents
208
- <span class="badge bg-warning text-white ms-auto" x-show="attentionAgentCount > 0" x-text="attentionAgentCount"></span>
209
- <span class="badge bg-success text-white ms-1" x-text="onlineCount"></span>
208
+ <span class="ms-auto d-inline-flex gap-1 align-items-center">
209
+ <span class="badge bg-warning text-white" x-show="attentionAgentCount > 0" x-text="attentionAgentCount"></span>
210
+ <span class="badge bg-success text-white" x-show="onlineCount > 0" x-text="onlineCount"></span>
211
+ </span>
210
212
  </a>
211
213
  <a href="#" class="nav-link" :class="{ active: view === 'channels' }" @click.prevent="switchView('channels')">
212
214
  <i class="ti ti-messages"></i>Channels
213
215
  <span class="badge bg-success text-white ms-auto" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length"></span>
214
- <span class="badge bg-secondary text-white ms-1" x-text="channelCards.length"></span>
215
216
  </a>
216
217
  <a href="#" class="nav-link" :class="{ active: view === 'integrations' }" @click.prevent="switchView('integrations')">
217
218
  <i class="ti ti-plug-connected"></i>Integrations
218
- <span class="badge bg-warning text-white ms-auto" x-show="integrationCards.some((item) => (item.taskStats?.waitingTasks || 0) > 0)" x-text="integrationCards.reduce((total, item) => total + (item.taskStats?.waitingTasks || 0), 0)"></span>
219
- <span class="badge bg-secondary text-white ms-1" x-text="integrationCards.length"></span>
219
+ <span class="ms-auto d-inline-flex gap-1 align-items-center">
220
+ <span class="badge bg-warning text-white" x-show="integrationCards.some((item) => (item.taskStats?.waitingTasks || 0) > 0)" x-text="integrationCards.reduce((total, item) => total + (item.taskStats?.waitingTasks || 0), 0)"></span>
221
+ <span class="badge bg-secondary text-white" x-show="integrationCards.length > 0" x-text="integrationCards.length"></span>
222
+ </span>
220
223
  </a>
221
224
  <a href="#" class="nav-link" :class="{ active: view === 'inbox' }" @click.prevent="switchView('inbox')">
222
225
  <i class="ti ti-inbox"></i>Inbox
@@ -228,8 +231,10 @@
228
231
  </a>
229
232
  <a href="#" class="nav-link" :class="{ active: view === 'pairs' }" @click.prevent="switchView('pairs')">
230
233
  <i class="ti ti-link"></i>Pairs
231
- <span class="badge bg-warning text-white ms-auto" x-show="attentionSummary.pendingPairInvites > 0" x-text="attentionSummary.pendingPairInvites"></span>
232
- <span class="badge bg-primary text-white ms-1" x-text="pairs.length"></span>
234
+ <span class="ms-auto d-inline-flex gap-1 align-items-center">
235
+ <span class="badge bg-warning text-white" x-show="attentionSummary.pendingPairInvites > 0" x-text="attentionSummary.pendingPairInvites"></span>
236
+ <span class="badge bg-primary text-white" x-show="openPairCount > 0" x-text="openPairCount"></span>
237
+ </span>
233
238
  </a>
234
239
  <a href="#" class="nav-link" :class="{ active: view === 'messages' }" @click.prevent="switchView('messages')">
235
240
  <i class="ti ti-messages"></i>Messages
@@ -240,8 +245,10 @@
240
245
  </a>
241
246
  <a href="#" class="nav-link" :class="{ active: view === 'tasks' }" @click.prevent="switchView('tasks')">
242
247
  <i class="ti ti-checkup-list"></i>Tasks
243
- <span class="badge bg-warning text-white ms-auto" x-show="attentionSummary.claimableTasks > 0" x-text="attentionSummary.claimableTasks"></span>
244
- <span class="badge bg-secondary text-white ms-1" x-text="stats.openTasks ?? 0"></span>
248
+ <span class="ms-auto d-inline-flex gap-1 align-items-center">
249
+ <span class="badge bg-warning text-white" x-show="attentionSummary.claimableTasks > 0" x-text="attentionSummary.claimableTasks"></span>
250
+ <span class="badge bg-secondary text-white" x-show="(stats.openTasks ?? 0) > 0" x-text="stats.openTasks ?? 0"></span>
251
+ </span>
245
252
  </a>
246
253
  <a href="#" class="nav-link" :class="{ active: view === 'analytics' }" @click.prevent="switchView('analytics')">
247
254
  <i class="ti ti-chart-area-line"></i>Analytics
@@ -525,6 +532,9 @@
525
532
  <div class="d-flex align-items-center mb-3 gap-3 flex-wrap">
526
533
  <h2 class="page-title mb-0">Agents</h2>
527
534
  <div class="ms-auto d-flex gap-2 align-items-center">
535
+ <button class="btn btn-sm btn-primary" @click="openAgentSpawn()" title="Create Codex live agent">
536
+ <i class="ti ti-plus"></i>
537
+ </button>
528
538
  <select class="form-select form-select-sm" style="width:auto; min-width: 150px" x-model="agentPresetFilter">
529
539
  <option value="">View: All</option>
530
540
  <option value="active">View: Active agents</option>
@@ -663,10 +673,10 @@
663
673
  <div class="row g-3">
664
674
  <template x-for="channel in channelCards" :key="channel.id">
665
675
  <div class="col-md-6 col-xl-4">
666
- <div class="card">
676
+ <div class="card" style="cursor:pointer" @click="if (!window.getSelection().toString()) openChannelDetail(channel)">
667
677
  <div class="card-body">
668
678
  <div class="d-flex align-items-start gap-2">
669
- <span class="agent-type-icon agent mt-0">
679
+ <span class="agent-type-icon channel mt-0">
670
680
  <i class="ti" :class="channelPresence(channel).icon"></i>
671
681
  </span>
672
682
  <div class="flex-grow-1 min-width-0">
@@ -696,8 +706,8 @@
696
706
  <span x-text="'Last seen: ' + timeAgo(channel.lastSeen)"></span>
697
707
  </div>
698
708
  </div>
699
- <button class="btn btn-sm btn-ghost-secondary p-1" title="Show agent" @click="openAgentDetail(agentsById[channel.agentId])">
700
- <i class="ti ti-robot"></i>
709
+ <button class="btn btn-sm btn-ghost-secondary p-1" title="Show channel" @click.stop="openChannelDetail(channel)">
710
+ <i class="ti ti-messages"></i>
701
711
  </button>
702
712
  </div>
703
713
  </div>
@@ -844,7 +854,7 @@
844
854
  </select>
845
855
  </div>
846
856
  <div class="col-md-3">
847
- <select class="form-select form-select-sm" x-model="inboxCompose.to">
857
+ <select class="form-select form-select-sm" x-model="inboxCompose.to" @change="if (!inboxComposeTargetAllowsClaimable()) inboxCompose.claimable = false">
848
858
  <option value="">Target</option>
849
859
  <template x-for="option in inboxComposeTargetOptions" :key="option.value">
850
860
  <option :value="option.value" x-text="option.label"></option>
@@ -857,7 +867,7 @@
857
867
  <div class="col-md-2">
858
868
  <input type="text" class="form-control form-control-sm" placeholder="Channel" x-model="inboxCompose.channel">
859
869
  </div>
860
- <div class="col-md-2 d-flex gap-2 align-items-center">
870
+ <div class="col-md-2 d-flex gap-2 align-items-center" x-show="inboxComposeTargetAllowsClaimable()">
861
871
  <label class="form-check mb-0">
862
872
  <input type="checkbox" class="form-check-input" x-model="inboxCompose.claimable">
863
873
  <span class="form-check-label small">Claimable</span>
@@ -1471,16 +1481,16 @@
1471
1481
  <div class="col-sm-6 col-lg-3">
1472
1482
  <div class="card">
1473
1483
  <div class="card-body text-center">
1474
- <div class="text-secondary small">Total Agents</div>
1475
- <div class="h2 mb-0" x-text="stats.agents ?? 0"></div>
1484
+ <div class="text-secondary small">Agents</div>
1485
+ <div class="h2 mb-0" x-text="onlineCount"></div>
1476
1486
  </div>
1477
1487
  </div>
1478
1488
  </div>
1479
1489
  <div class="col-sm-6 col-lg-3">
1480
1490
  <div class="card">
1481
1491
  <div class="card-body text-center">
1482
- <div class="text-secondary small">Online Now</div>
1483
- <div class="h2 mb-0 text-success" x-text="onlineCount"></div>
1492
+ <div class="text-secondary small">Busy Agents</div>
1493
+ <div class="h2 mb-0 text-warning" x-text="busyAgentCount"></div>
1484
1494
  </div>
1485
1495
  </div>
1486
1496
  </div>
@@ -1488,7 +1498,7 @@
1488
1498
  <div class="card">
1489
1499
  <div class="card-body text-center">
1490
1500
  <div class="text-secondary small">Messages (24h)</div>
1491
- <div class="h2 mb-0 text-info" x-text="stats.messagesLast24h ?? 0"></div>
1501
+ <div class="h2 mb-0 text-info" x-text="stats.messagesLast24h ?? 0"></div>
1492
1502
  </div>
1493
1503
  </div>
1494
1504
  </div>
@@ -1612,6 +1622,16 @@
1612
1622
  <button class="btn btn-sm btn-ghost-secondary" @click="openRename(selectedAgentDetail)">
1613
1623
  <i class="ti ti-pencil me-1"></i>Rename
1614
1624
  </button>
1625
+ <template x-if="agentSupportsControlActions(selectedAgentDetail)">
1626
+ <button class="btn btn-sm btn-ghost-secondary" @click="openConfirm('Restart Agent', 'Restart agent ' + displayName(selectedAgentDetail) + '?', () => doAgentAction(selectedAgentDetail, 'restart'))">
1627
+ <i class="ti ti-refresh me-1"></i>Restart
1628
+ </button>
1629
+ </template>
1630
+ <template x-if="agentSupportsControlActions(selectedAgentDetail)">
1631
+ <button class="btn btn-sm btn-ghost-danger" @click="openConfirm('Shutdown Agent', 'Shut down agent ' + displayName(selectedAgentDetail) + '?', () => doAgentAction(selectedAgentDetail, 'shutdown'))">
1632
+ <i class="ti ti-power me-1"></i>Shutdown
1633
+ </button>
1634
+ </template>
1615
1635
  </div>
1616
1636
 
1617
1637
  <div class="p-3 border-bottom">
@@ -1766,6 +1786,141 @@
1766
1786
  </div>
1767
1787
  </template>
1768
1788
 
1789
+ <!-- ==================== CHANNEL DETAIL DRAWER ==================== -->
1790
+ <template x-if="selectedChannelDetail">
1791
+ <div>
1792
+ <div class="agent-drawer-backdrop" x-show="channelDetailOpen" x-cloak @click="closeChannelDetail()"></div>
1793
+ <aside class="agent-drawer" x-show="channelDetailOpen" x-cloak>
1794
+ <div class="p-3 border-bottom d-flex align-items-start gap-2">
1795
+ <span class="agent-type-icon channel mt-1" title="Channel">
1796
+ <i class="ti ti-messages"></i>
1797
+ </span>
1798
+ <div class="flex-grow-1 min-width-0">
1799
+ <div class="d-flex align-items-center gap-2">
1800
+ <span class="fw-bold text-truncate" x-text="selectedChannelDetail.name"></span>
1801
+ <span class="badge" :class="'bg-' + channelPresence(selectedChannelDetail).tone + '-lt'">
1802
+ <i class="ti me-1" :class="channelPresence(selectedChannelDetail).icon"></i><span x-text="channelPresence(selectedChannelDetail).label"></span>
1803
+ </span>
1804
+ </div>
1805
+ <div class="text-secondary small text-truncate" x-text="selectedChannelDetail.id"></div>
1806
+ </div>
1807
+ <button class="btn btn-sm btn-ghost-secondary p-1" @click="closeChannelDetail()" title="Close">
1808
+ <i class="ti ti-x"></i>
1809
+ </button>
1810
+ </div>
1811
+
1812
+ <div class="p-3 border-bottom d-flex gap-2 flex-wrap">
1813
+ <button class="btn btn-sm btn-primary" @click="compose = { ...compose, from: 'user', to: selectedChannelDetail.target || selectedChannelDetail.agentId, channel: selectedChannelDetail.id, body: '', claimable: false }; composeOpen = true">
1814
+ <i class="ti ti-send me-1"></i>Message
1815
+ </button>
1816
+ <button class="btn btn-sm btn-ghost-secondary" @click="channelFilter = selectedChannelDetail.id; switchView('messages'); closeChannelDetail()">
1817
+ <i class="ti ti-filter me-1"></i>Messages
1818
+ </button>
1819
+ <template x-if="agentsById[selectedChannelDetail.agentId]">
1820
+ <button class="btn btn-sm btn-ghost-secondary" @click="openAgentDetail(agentsById[selectedChannelDetail.agentId]); closeChannelDetail()">
1821
+ <i class="ti ti-robot me-1"></i>Backing agent
1822
+ </button>
1823
+ </template>
1824
+ </div>
1825
+
1826
+ <div class="p-3 border-bottom">
1827
+ <h3 class="card-title mb-3">Route</h3>
1828
+ <div class="detail-row mb-2">
1829
+ <div class="text-secondary small">Target</div>
1830
+ <div class="small text-break" x-text="displayTarget(selectedChannelDetail.target || selectedChannelDetail.agentId)"></div>
1831
+ </div>
1832
+ <div class="detail-row mb-2">
1833
+ <div class="text-secondary small">Backing agent</div>
1834
+ <div class="small text-break user-select-all" x-text="selectedChannelDetail.agentId"></div>
1835
+ </div>
1836
+ <div class="detail-row mb-2">
1837
+ <div class="text-secondary small">Transport</div>
1838
+ <div class="d-flex gap-1 flex-wrap">
1839
+ <span class="badge bg-info-lt" x-text="selectedChannelDetail.type"></span>
1840
+ <span class="badge bg-cyan-lt" x-text="selectedChannelDetail.transport || selectedChannelDetail.type"></span>
1841
+ <span class="badge bg-secondary-lt" x-text="selectedChannelDetail.direction"></span>
1842
+ </div>
1843
+ </div>
1844
+ <div class="detail-row mb-2">
1845
+ <div class="text-secondary small">Last seen</div>
1846
+ <div class="small" x-text="timeAgo(selectedChannelDetail.lastSeen)"></div>
1847
+ </div>
1848
+ </div>
1849
+
1850
+ <div class="p-3 border-bottom">
1851
+ <h3 class="card-title mb-3">Capabilities</h3>
1852
+ <div class="detail-row mb-2">
1853
+ <div class="text-secondary small">Capabilities</div>
1854
+ <div class="d-flex gap-1 flex-wrap">
1855
+ <template x-for="capability in (selectedChannelDetail.capabilities || [])" :key="capability">
1856
+ <span class="badge bg-purple-lt" x-text="capability"></span>
1857
+ </template>
1858
+ <template x-if="!(selectedChannelDetail.capabilities || []).length">
1859
+ <span class="text-secondary small">-</span>
1860
+ </template>
1861
+ </div>
1862
+ </div>
1863
+ <div class="detail-row mb-2">
1864
+ <div class="text-secondary small">Topics</div>
1865
+ <div class="d-flex gap-1 flex-wrap">
1866
+ <template x-for="topic in (selectedChannelDetail.topicChannels || [])" :key="topic">
1867
+ <span class="badge bg-warning-lt" x-text="'#' + topic"></span>
1868
+ </template>
1869
+ <template x-if="!(selectedChannelDetail.topicChannels || []).length">
1870
+ <span class="text-secondary small">-</span>
1871
+ </template>
1872
+ </div>
1873
+ </div>
1874
+ <div class="detail-row mb-2">
1875
+ <div class="text-secondary small">Tags</div>
1876
+ <div class="d-flex gap-1 flex-wrap">
1877
+ <template x-for="tag in (selectedChannelDetail.tags || [])" :key="tag">
1878
+ <span class="badge bg-cyan-lt" x-text="tag"></span>
1879
+ </template>
1880
+ <template x-if="!(selectedChannelDetail.tags || []).length">
1881
+ <span class="text-secondary small">-</span>
1882
+ </template>
1883
+ </div>
1884
+ </div>
1885
+ </div>
1886
+
1887
+ <div class="p-3 border-bottom">
1888
+ <h3 class="card-title mb-3">Metadata</h3>
1889
+ <template x-for="entry in Object.entries(selectedChannelDetail.meta || {})" :key="entry[0]">
1890
+ <div class="detail-row mb-2">
1891
+ <div class="text-secondary small" x-text="entry[0]"></div>
1892
+ <div class="small text-break" x-text="typeof entry[1] === 'object' ? JSON.stringify(entry[1]) : String(entry[1])"></div>
1893
+ </div>
1894
+ </template>
1895
+ <template x-if="!Object.keys(selectedChannelDetail.meta || {}).length">
1896
+ <div class="text-secondary small">No metadata</div>
1897
+ </template>
1898
+ </div>
1899
+
1900
+ <div class="p-3">
1901
+ <h3 class="card-title mb-3">Recent Messages</h3>
1902
+ <div class="list-group list-group-flush">
1903
+ <template x-for="m in channelDetailMessages" :key="m.id">
1904
+ <div class="list-group-item px-0">
1905
+ <div class="d-flex align-items-center gap-2 mb-1">
1906
+ <span class="fw-bold small" x-text="displayTarget(m.from)"></span>
1907
+ <i class="ti ti-arrow-right text-secondary" style="font-size:12px"></i>
1908
+ <span class="small" x-text="displayTarget(m.to)"></span>
1909
+ <span class="badge bg-warning-lt" x-show="m.channel" x-text="'#' + m.channel"></span>
1910
+ <span class="text-secondary small ms-auto" x-text="'#' + m.id"></span>
1911
+ </div>
1912
+ <div class="text-secondary small text-truncate" x-text="messagePreview(m)"></div>
1913
+ </div>
1914
+ </template>
1915
+ <template x-if="channelDetailMessages.length === 0">
1916
+ <div class="text-secondary small">No recent channel messages loaded</div>
1917
+ </template>
1918
+ </div>
1919
+ </div>
1920
+ </aside>
1921
+ </div>
1922
+ </template>
1923
+
1769
1924
  <!-- ==================== COMPOSE MODAL ==================== -->
1770
1925
  <div class="modal modal-blur" :class="{ show: composeOpen }" :style="composeOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="composeOpen = false">
1771
1926
  <div class="modal-dialog modal-lg modal-dialog-centered">
@@ -1787,14 +1942,15 @@
1787
1942
  <label class="form-label">From</label>
1788
1943
  <select class="form-select" x-model="compose.from">
1789
1944
  <option value="">Select sender…</option>
1790
- <template x-for="a in composeAgents" :key="a.id">
1945
+ <option value="user">User [user]</option>
1946
+ <template x-for="a in composeAgents.filter((agent) => agent.id !== 'user')" :key="a.id">
1791
1947
  <option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
1792
1948
  </template>
1793
1949
  </select>
1794
1950
  </div>
1795
1951
  <div class="col-md-6">
1796
1952
  <label class="form-label">To</label>
1797
- <select class="form-select" x-model="compose.to">
1953
+ <select class="form-select" x-model="compose.to" @change="if (!composeTargetAllowsClaimable()) compose.claimable = false">
1798
1954
  <option value="">Select recipient…</option>
1799
1955
  <option value="broadcast">📢 Broadcast (all)</option>
1800
1956
  <optgroup label="Labels">
@@ -1831,7 +1987,7 @@
1831
1987
  <label class="form-label">Message</label>
1832
1988
  <textarea class="form-control" rows="5" x-model="compose.body" placeholder="Type your message…" x-ref="composeBody"></textarea>
1833
1989
  </div>
1834
- <div class="col-12">
1990
+ <div class="col-12" x-show="composeTargetAllowsClaimable()">
1835
1991
  <label class="form-check">
1836
1992
  <input type="checkbox" class="form-check-input" x-model="compose.claimable">
1837
1993
  <span class="form-check-label">Claimable task (only one agent can claim)</span>
@@ -1850,6 +2006,88 @@
1850
2006
  </div>
1851
2007
  <div class="modal-backdrop fade show" x-show="composeOpen" x-cloak></div>
1852
2008
 
2009
+ <!-- ==================== AGENT SPAWN MODAL ==================== -->
2010
+ <div class="modal modal-blur" :class="{ show: agentSpawnOpen }" :style="agentSpawnOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="closeAgentSpawn()">
2011
+ <div class="modal-dialog modal-md modal-dialog-centered">
2012
+ <div class="modal-content">
2013
+ <div class="modal-header">
2014
+ <h5 class="modal-title">Create Agent</h5>
2015
+ <button class="btn-close" @click="closeAgentSpawn()"></button>
2016
+ </div>
2017
+ <div class="modal-body">
2018
+ <div class="row g-3">
2019
+ <div class="col-12">
2020
+ <label class="form-label">Provider</label>
2021
+ <select class="form-select" x-model="agentSpawn.provider">
2022
+ <option value="codex">Codex Live</option>
2023
+ </select>
2024
+ </div>
2025
+ <div class="col-12">
2026
+ <label class="form-label">Permission model</label>
2027
+ <select class="form-select" x-model="agentSpawn.approvalMode">
2028
+ <option value="guarded">Guarded</option>
2029
+ <option value="read-only">Read-only</option>
2030
+ <option value="open">Open</option>
2031
+ </select>
2032
+ </div>
2033
+ <div class="col-12">
2034
+ <label class="form-label">Working directory <span class="text-secondary">(optional)</span></label>
2035
+ <div class="input-group">
2036
+ <input type="text" class="form-control" x-model="agentSpawn.cwd" x-ref="agentSpawnCwd" placeholder="Defaults to the relay server working directory">
2037
+ <button class="btn btn-ghost-secondary" @click="openAgentDirectoryBrowser()" title="Browse host directories">
2038
+ <i class="ti ti-folder-open me-1"></i>Browse
2039
+ </button>
2040
+ </div>
2041
+ <div class="border rounded mt-2" x-show="agentDirectoryBrowser.open" x-cloak>
2042
+ <div class="d-flex align-items-center gap-2 p-2 border-bottom">
2043
+ <button class="btn btn-sm btn-ghost-secondary" :disabled="!agentDirectoryBrowser.parent || agentDirectoryBrowser.loading" @click="browseAgentDirectory(agentDirectoryBrowser.parent)" title="Parent directory">
2044
+ <i class="ti ti-arrow-up"></i>
2045
+ </button>
2046
+ <button class="btn btn-sm btn-ghost-secondary" :disabled="!agentDirectoryBrowser.home || agentDirectoryBrowser.loading" @click="browseAgentDirectory(agentDirectoryBrowser.home)" title="Home directory">
2047
+ <i class="ti ti-home"></i>
2048
+ </button>
2049
+ <button class="btn btn-sm btn-ghost-secondary" :disabled="!agentDirectoryBrowser.cwd || agentDirectoryBrowser.loading" @click="browseAgentDirectory(agentDirectoryBrowser.cwd)" title="Server directory">
2050
+ <i class="ti ti-server"></i>
2051
+ </button>
2052
+ <div class="small text-secondary text-truncate flex-grow-1" x-text="agentDirectoryBrowser.path || 'Loading...'"></div>
2053
+ <button class="btn btn-sm btn-primary" :disabled="!agentDirectoryBrowser.path" @click="selectAgentDirectory(agentDirectoryBrowser.path)">Select</button>
2054
+ </div>
2055
+ <div class="p-2" style="max-height: 240px; overflow:auto">
2056
+ <template x-if="agentDirectoryBrowser.loading">
2057
+ <div class="text-secondary small py-3 text-center">Loading directories...</div>
2058
+ </template>
2059
+ <template x-if="agentDirectoryBrowser.error">
2060
+ <div class="alert alert-danger py-2 mb-2" x-text="agentDirectoryBrowser.error"></div>
2061
+ </template>
2062
+ <template x-for="entry in agentDirectoryBrowser.entries" :key="entry.path">
2063
+ <button type="button" class="list-group-item list-group-item-action d-flex align-items-center gap-2 w-100 text-start border-0 rounded" @click="browseAgentDirectory(entry.path)">
2064
+ <i class="ti ti-folder text-warning"></i>
2065
+ <span class="text-truncate" x-text="entry.name"></span>
2066
+ </button>
2067
+ </template>
2068
+ <template x-if="!agentDirectoryBrowser.loading && !agentDirectoryBrowser.error && agentDirectoryBrowser.entries.length === 0">
2069
+ <div class="text-secondary small py-3 text-center">No child directories</div>
2070
+ </template>
2071
+ </div>
2072
+ </div>
2073
+ </div>
2074
+ <div class="col-12">
2075
+ <label class="form-label">Label <span class="text-secondary">(optional)</span></label>
2076
+ <input type="text" class="form-control" x-model="agentSpawn.label" placeholder="e.g. backend helper">
2077
+ </div>
2078
+ </div>
2079
+ </div>
2080
+ <div class="modal-footer">
2081
+ <button class="btn btn-ghost-secondary" @click="closeAgentSpawn()">Cancel</button>
2082
+ <button class="btn btn-primary" @click="doSpawnAgent()">
2083
+ <i class="ti ti-plus me-1"></i>Create
2084
+ </button>
2085
+ </div>
2086
+ </div>
2087
+ </div>
2088
+ </div>
2089
+ <div class="modal-backdrop fade show" x-show="agentSpawnOpen" x-cloak></div>
2090
+
1853
2091
  <!-- ==================== PAIR INVITE MODAL ==================== -->
1854
2092
  <div class="modal modal-blur" :class="{ show: pairInviteOpen }" :style="pairInviteOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="closePairInvite()">
1855
2093
  <div class="modal-dialog modal-lg modal-dialog-centered">
@@ -0,0 +1,137 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
+
5
+ export type CodexSpawnApprovalMode = "open" | "guarded" | "read-only";
6
+
7
+ interface CodexSpawnInput {
8
+ cwd?: string;
9
+ approvalMode: CodexSpawnApprovalMode;
10
+ label?: string;
11
+ relayUrl: string;
12
+ token?: string;
13
+ dryRun?: boolean;
14
+ }
15
+
16
+ interface CodexSpawnResult {
17
+ provider: "codex";
18
+ pid?: number;
19
+ cwd: string;
20
+ approvalMode: CodexSpawnApprovalMode;
21
+ logPath: string;
22
+ command: string[];
23
+ dryRun?: boolean;
24
+ }
25
+
26
+ interface HostDirectoryEntry {
27
+ name: string;
28
+ path: string;
29
+ }
30
+
31
+ interface HostDirectoryListing {
32
+ path: string;
33
+ parent?: string;
34
+ home: string;
35
+ cwd: string;
36
+ entries: HostDirectoryEntry[];
37
+ }
38
+
39
+ export function normalizeCodexSpawnCwd(raw: string | undefined, fallback = process.cwd()): string {
40
+ const cwd = resolve(raw || fallback);
41
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
42
+ throw new Error(`cwd does not exist or is not a directory: ${cwd}`);
43
+ }
44
+ return cwd;
45
+ }
46
+
47
+ export function listHostDirectories(raw: string | undefined): HostDirectoryListing {
48
+ const cwd = process.cwd();
49
+ const home = homedir();
50
+ const path = normalizeCodexSpawnCwd(raw || cwd, cwd);
51
+ const entries = readdirSync(path, { withFileTypes: true })
52
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
53
+ .map((entry) => ({
54
+ name: entry.name,
55
+ path: join(path, entry.name),
56
+ }))
57
+ .sort((a, b) => a.name.localeCompare(b.name));
58
+ const parent = dirname(path);
59
+ return {
60
+ path,
61
+ parent: parent !== path ? parent : undefined,
62
+ home,
63
+ cwd,
64
+ entries,
65
+ };
66
+ }
67
+
68
+ export function codexSpawnCommand(): string[] {
69
+ const override = process.env.AGENT_RELAY_CODEX_RELAY_BIN;
70
+ if (override) return [override];
71
+
72
+ const repoLauncher = resolve(import.meta.dir, "../codex/bin/agent-relay-codex.ts");
73
+ if (existsSync(repoLauncher)) return ["bun", "run", repoLauncher, "start"];
74
+
75
+ const fromPath = findOnPath("codex-relay");
76
+ if (fromPath) return [fromPath];
77
+
78
+ return ["codex-relay"];
79
+ }
80
+
81
+ export function codexSpawnLogPath(cwd: string, now = Date.now()): string {
82
+ const project = basename(cwd).replace(/[^a-zA-Z0-9._-]+/g, "-") || "project";
83
+ return join(homedir(), ".agent-relay", "spawns", `codex-${project}-${now}.log`);
84
+ }
85
+
86
+ export function spawnCodexAgent(input: CodexSpawnInput): CodexSpawnResult {
87
+ const cwd = normalizeCodexSpawnCwd(input.cwd);
88
+ const command = [
89
+ ...codexSpawnCommand(),
90
+ "--headless",
91
+ "--relay-url",
92
+ input.relayUrl,
93
+ ];
94
+ const logPath = codexSpawnLogPath(cwd);
95
+ const env: Record<string, string | undefined> = {
96
+ ...process.env,
97
+ AGENT_RELAY_CODEX_HEADLESS: "1",
98
+ AGENT_RELAY_APPROVAL: input.approvalMode,
99
+ AGENT_RELAY_TAGS: mergeCsv(process.env.AGENT_RELAY_TAGS, ["headless", "dashboard-spawned"]),
100
+ AGENT_RELAY_LABEL: input.label || process.env.AGENT_RELAY_LABEL,
101
+ AGENT_RELAY_URL: input.relayUrl,
102
+ AGENT_RELAY_TOKEN: input.token || process.env.AGENT_RELAY_TOKEN,
103
+ };
104
+
105
+ if (input.dryRun) {
106
+ return { provider: "codex", cwd, approvalMode: input.approvalMode, logPath, command, dryRun: true };
107
+ }
108
+
109
+ mkdirSync(dirname(logPath), { recursive: true });
110
+ const log = Bun.file(logPath);
111
+ const child = Bun.spawn(command, {
112
+ cwd,
113
+ env,
114
+ stdin: "ignore",
115
+ stdout: log,
116
+ stderr: log,
117
+ });
118
+ child.unref();
119
+
120
+ return { provider: "codex", pid: child.pid, cwd, approvalMode: input.approvalMode, logPath, command };
121
+ }
122
+
123
+ function mergeCsv(raw: string | undefined, additions: string[]): string {
124
+ return [...new Set([
125
+ ...(raw || "").split(",").map((item) => item.trim()).filter(Boolean),
126
+ ...additions,
127
+ ])].join(",");
128
+ }
129
+
130
+ function findOnPath(command: string): string | null {
131
+ for (const dir of (process.env.PATH || "").split(delimiter)) {
132
+ if (!dir) continue;
133
+ const candidate = join(dir, command);
134
+ if (existsSync(candidate)) return candidate;
135
+ }
136
+ return null;
137
+ }
package/src/db.ts CHANGED
@@ -1328,6 +1328,44 @@ function findMessageByIdempotencyKey(from: string, key: string): Message | null
1328
1328
  return row ? rowToMessage(row) : null;
1329
1329
  }
1330
1330
 
1331
+ function isDeliveryAgent(agent: AgentCard): boolean {
1332
+ return agent.status !== "offline" &&
1333
+ agent.id !== "user" &&
1334
+ agent.id !== "system" &&
1335
+ agent.meta?.kind !== "channel";
1336
+ }
1337
+
1338
+ function matchingDeliveryAgents(target: string): AgentCard[] {
1339
+ if (!target) return [];
1340
+ const candidates = listAgents().filter(isDeliveryAgent);
1341
+ if (target === "broadcast") return candidates;
1342
+ const direct = getAgent(target);
1343
+ if (direct) return isDeliveryAgent(direct) ? [direct] : [];
1344
+ if (target.startsWith("tag:")) {
1345
+ const tag = target.slice(4);
1346
+ return candidates.filter((agent) => agent.tags.includes(tag));
1347
+ }
1348
+ if (target.startsWith("cap:")) {
1349
+ const cap = target.slice(4);
1350
+ return candidates.filter((agent) => agent.capabilities.includes(cap));
1351
+ }
1352
+ if (target.startsWith("label:")) {
1353
+ const label = target.slice(6);
1354
+ return candidates.filter((agent) => agent.label === label);
1355
+ }
1356
+ return [];
1357
+ }
1358
+
1359
+ function claimableAllowedForTarget(target: string): boolean {
1360
+ return matchingDeliveryAgents(target).length > 1;
1361
+ }
1362
+
1363
+ function shouldStoreClaimable(input: SendMessageInput): boolean {
1364
+ if (!input.claimable) return false;
1365
+ if (input.type === "system" || typeof input.meta?.taskId === "number") return true;
1366
+ return claimableAllowedForTarget(input.to);
1367
+ }
1368
+
1331
1369
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
1332
1370
  const now = Date.now();
1333
1371
 
@@ -1354,6 +1392,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1354
1392
  VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $meta, $now)
1355
1393
  `);
1356
1394
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
1395
+ const claimable = shouldStoreClaimable(input);
1357
1396
 
1358
1397
  const id = db.transaction(() => {
1359
1398
  const result = insert.run({
@@ -1365,7 +1404,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1365
1404
  $body: input.body,
1366
1405
  $threadId: threadId,
1367
1406
  $replyTo: input.replyTo ?? null,
1368
- $claimable: input.claimable ? 1 : 0,
1407
+ $claimable: claimable ? 1 : 0,
1369
1408
  $idempotencyKey: input.idempotencyKey ?? null,
1370
1409
  $meta: JSON.stringify(input.meta ?? {}),
1371
1410
  $now: now,
package/src/routes.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  } from "./db";
53
53
  import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
54
54
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
55
+ import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
55
56
  import {
56
57
  getIntegrationAuth,
57
58
  hasIntegrationScope,
@@ -150,6 +151,9 @@ function parseQueryInt(
150
151
  }
151
152
 
152
153
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
154
+ const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
155
+ const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
156
+ const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
153
157
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
154
158
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
155
159
  const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
@@ -738,6 +742,119 @@ const deleteAgentById: Handler = (_req, params) => {
738
742
  return json({ ok: true });
739
743
  };
740
744
 
745
+ function agentCanReceiveControlAction(agent: AgentCard): boolean {
746
+ return agent.id !== "user" &&
747
+ agent.id !== "system" &&
748
+ agent.meta?.kind !== "channel" &&
749
+ !agent.tags.includes("channel");
750
+ }
751
+
752
+ const postAgentAction: Handler = async (req, params) => {
753
+ const parsed = await parseBody<unknown>(req);
754
+ if (!parsed.ok) return error(parsed.error, parsed.status);
755
+ try {
756
+ if (!isRecord(parsed.body)) return error("action required");
757
+ const action = cleanEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
758
+ if (!action) return error("action required");
759
+ const agent = getAgent(params.id!);
760
+ if (!agent) return error("agent not found", 404);
761
+ if (!agentCanReceiveControlAction(agent)) return error("agent does not support dashboard control actions", 400);
762
+
763
+ const title = action === "restart" ? "Agent restart requested" : "Agent shutdown requested";
764
+ const msg = sendMessage({
765
+ from: "system",
766
+ to: agent.id,
767
+ type: "system",
768
+ subject: title,
769
+ body: action === "restart"
770
+ ? "Dashboard requested that this agent restart its relay-managed session now."
771
+ : "Dashboard requested that this agent shut down its relay-managed session now.",
772
+ meta: {
773
+ agentControl: {
774
+ action,
775
+ requestedBy: "dashboard",
776
+ requestedAt: Date.now(),
777
+ },
778
+ delivery: "interrupt",
779
+ priority: "urgent",
780
+ },
781
+ });
782
+ emitNewMessage(msg);
783
+ auditEvent({
784
+ clientId: "server-agent-" + agent.id + "-action-" + action + "-" + msg.id,
785
+ kind: "state",
786
+ title,
787
+ body: action,
788
+ meta: agent.id,
789
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
790
+ view: "agents",
791
+ messageId: msg.id,
792
+ agentId: agent.id,
793
+ metadata: { action },
794
+ });
795
+ return json({ ok: true, action, message: msg }, 202);
796
+ } catch (e) {
797
+ if (e instanceof ValidationError) return error(e.message, 400);
798
+ throw e;
799
+ }
800
+ };
801
+
802
+ const postAgentSpawn: Handler = async (req) => {
803
+ const parsed = await parseBody<unknown>(req);
804
+ if (!parsed.ok) return error(parsed.error, parsed.status);
805
+ try {
806
+ if (!isRecord(parsed.body)) return error("provider required");
807
+ const provider = cleanEnum(parsed.body.provider, "provider", VALID_AGENT_SPAWN_PROVIDERS);
808
+ if (provider !== "codex") return error("provider must be codex");
809
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
810
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
811
+ const label = cleanString(parsed.body.label, "label", { max: 120 });
812
+ const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
813
+ const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
814
+ const result = spawnCodexAgent({
815
+ cwd,
816
+ approvalMode,
817
+ label,
818
+ relayUrl,
819
+ token: token || undefined,
820
+ dryRun: process.env.AGENT_RELAY_SPAWN_DRY_RUN === "1",
821
+ });
822
+ auditEvent({
823
+ clientId: "server-agent-spawn-codex-" + Date.now(),
824
+ kind: "state",
825
+ title: "Codex agent spawn requested",
826
+ body: result.cwd,
827
+ meta: result.pid ? `pid ${result.pid}` : "dry run",
828
+ icon: "ti-plus",
829
+ view: "agents",
830
+ metadata: {
831
+ provider: result.provider,
832
+ approvalMode: result.approvalMode,
833
+ pid: result.pid ?? null,
834
+ logPath: result.logPath,
835
+ dryRun: result.dryRun === true,
836
+ },
837
+ });
838
+ return json(result, 202);
839
+ } catch (e) {
840
+ if (e instanceof ValidationError) return error(e.message, 400);
841
+ if (e instanceof Error) return error(e.message, 400);
842
+ throw e;
843
+ }
844
+ };
845
+
846
+ const getHostDirectories: Handler = (req) => {
847
+ try {
848
+ const url = new URL(req.url);
849
+ const path = cleanString(url.searchParams.get("path") ?? undefined, "path", { max: 500 });
850
+ return json(listHostDirectories(path));
851
+ } catch (e) {
852
+ if (e instanceof ValidationError) return error(e.message, 400);
853
+ if (e instanceof Error) return error(e.message, 400);
854
+ throw e;
855
+ }
856
+ };
857
+
741
858
  // --- Message routes ---
742
859
 
743
860
  const VALID_MSG_TYPES = ["message", "system"];
@@ -1412,14 +1529,17 @@ function route(method: string, path: string, handler: Handler): Route {
1412
1529
 
1413
1530
  const routes: Route[] = [
1414
1531
  route("POST", "/api/agents", postAgent),
1532
+ route("POST", "/api/agents/spawn", postAgentSpawn),
1415
1533
  route("GET", "/api/agents", getAgents),
1416
1534
  route("GET", "/api/agents/find", findAgents),
1535
+ route("GET", "/api/agents/spawn/directories", getHostDirectories),
1417
1536
  route("GET", "/api/agents/:id", getAgentById),
1418
1537
  route("PATCH", "/api/agents/:id/status", patchAgentStatus),
1419
1538
  route("PATCH", "/api/agents/:id/ready", patchAgentReady),
1420
1539
  route("PATCH", "/api/agents/:id/label", patchAgentLabel),
1421
1540
  route("PATCH", "/api/agents/:id/tags", patchAgentTags),
1422
1541
  route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
1542
+ route("POST", "/api/agents/:id/actions", postAgentAction),
1423
1543
  route("DELETE", "/api/agents/:id", deleteAgentById),
1424
1544
 
1425
1545
  route("POST", "/api/system/broadcast", postSystemBroadcast),