agent-relay-server 0.4.39 → 0.5.0

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.39",
3
+ "version": "0.5.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -54,6 +54,7 @@
54
54
 
55
55
  agents: [],
56
56
  agentsById: {},
57
+ orchestrators: [],
57
58
  pairs: [],
58
59
  messages: [],
59
60
  tasks: [],
@@ -85,6 +86,14 @@
85
86
  replyTo: null,
86
87
  composeOpen: false,
87
88
  agentSpawnOpen: false,
89
+ orchestratorSpawnOpen: false,
90
+ spawnOrchId: "",
91
+ spawnProvider: "claude",
92
+ spawnCwd: "",
93
+ spawnLabel: "",
94
+ spawnApproval: "guarded",
95
+ spawnPrompt: "",
96
+ spawnDirListing: null,
88
97
  agentDirectoryBrowser: { open: false, loading: false, path: "", parent: "", home: "", cwd: "", entries: [], error: "" },
89
98
  pairInviteOpen: false,
90
99
  pairMessageOpen: false,
@@ -281,6 +290,8 @@
281
290
  es.addEventListener("message.claimed", (event) => handleMessageClaimed(this, parseEventData(event)));
282
291
  es.addEventListener("message.claim_released", (event) => handleMessageClaimReleased(this, parseEventData(event)));
283
292
  es.addEventListener("message.deleted", (event) => handleMessageDeleted(this, parseEventData(event)));
293
+ es.addEventListener("orchestrator.status", (event) => handleOrchestratorStatus(this, parseEventData(event)));
294
+ es.addEventListener("orchestrator.removed", (event) => handleOrchestratorRemoved(this, parseEventData(event)));
284
295
  registerTaskEvents(this, es);
285
296
  }
286
297
 
@@ -320,6 +331,14 @@
320
331
  refreshChartsIfVisible(vm);
321
332
  }
322
333
 
334
+ function handleOrchestratorStatus(vm, orch) {
335
+ upsertById(vm.orchestrators, orch);
336
+ }
337
+
338
+ function handleOrchestratorRemoved(vm, data) {
339
+ vm.orchestrators = vm.orchestrators.filter((o) => o.id !== data.id);
340
+ }
341
+
323
342
  function handleMessageClaimed(vm, data) {
324
343
  const msg = vm.messages.find((item) => item.id === data.messageId);
325
344
  if (!msg) return;
@@ -374,7 +393,7 @@
374
393
  },
375
394
 
376
395
  async refresh() {
377
- await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
396
+ await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchOrchestrators(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
378
397
  },
379
398
 
380
399
  async refreshLiveData() {
@@ -407,6 +426,12 @@
407
426
  } catch {}
408
427
  },
409
428
 
429
+ async fetchOrchestrators() {
430
+ try {
431
+ this.orchestrators = await this.api("GET", "/orchestrators");
432
+ } catch {}
433
+ },
434
+
410
435
  async fetchPairs() {
411
436
  try {
412
437
  let pairs;
@@ -535,9 +560,15 @@
535
560
  healthIssues: { get: getHealthIssues },
536
561
  healthDiagnostics: { get: getHealthDiagnostics },
537
562
  commandPaletteItems: { get: getCommandPaletteItems },
563
+ spawnAvailableProviders: { get: getSpawnAvailableProviders },
538
564
  };
539
565
  }
540
566
 
567
+ function getSpawnAvailableProviders() {
568
+ const orch = this.orchestrators.find((o) => o.id === this.spawnOrchId);
569
+ return orch ? orch.providers : ["claude", "codex"];
570
+ }
571
+
541
572
  function getOnlineCount() {
542
573
  return visibleAgents(this).filter((agent) => agent.status !== "offline").length;
543
574
  }
@@ -1667,6 +1698,12 @@
1667
1698
  openAgentDirectoryBrowser,
1668
1699
  browseAgentDirectory,
1669
1700
  selectAgentDirectory,
1701
+ openOrchestratorSpawn,
1702
+ openOrchestratorSpawnFor,
1703
+ browseOrchestratorDirs,
1704
+ submitOrchestratorSpawn,
1705
+ orchestratorAction,
1706
+ deleteOrchestrator,
1670
1707
  doSendPairMessage,
1671
1708
  doAcceptPair,
1672
1709
  doRejectPair,
@@ -2478,6 +2515,96 @@
2478
2515
  }
2479
2516
  }
2480
2517
 
2518
+ // --- Orchestrator methods ---
2519
+
2520
+ function openOrchestratorSpawn() {
2521
+ const online = this.orchestrators.filter((o) => o.status === "online");
2522
+ if (online.length === 0) return alert("No orchestrators online");
2523
+ this.spawnOrchId = online[0].id;
2524
+ this.spawnProvider = "claude";
2525
+ this.spawnCwd = "";
2526
+ this.spawnLabel = "";
2527
+ this.spawnApproval = "guarded";
2528
+ this.spawnPrompt = "";
2529
+ this.spawnDirListing = null;
2530
+ this.orchestratorSpawnOpen = true;
2531
+ }
2532
+
2533
+ function openOrchestratorSpawnFor(orchId) {
2534
+ this.spawnOrchId = orchId;
2535
+ this.spawnProvider = "claude";
2536
+ this.spawnCwd = "";
2537
+ this.spawnLabel = "";
2538
+ this.spawnApproval = "guarded";
2539
+ this.spawnPrompt = "";
2540
+ this.spawnDirListing = null;
2541
+ this.orchestratorSpawnOpen = true;
2542
+ }
2543
+
2544
+ async function browseOrchestratorDirs() {
2545
+ try {
2546
+ const query = this.spawnCwd ? "?path=" + encodeURIComponent(this.spawnCwd) : "";
2547
+ this.spawnDirListing = await this.api("GET", "/agents/spawn/directories" + query);
2548
+ } catch (e) {
2549
+ alert("Directory browse failed: " + e.message);
2550
+ }
2551
+ }
2552
+
2553
+ async function submitOrchestratorSpawn() {
2554
+ if (!this.spawnOrchId) return alert("Select an orchestrator");
2555
+ try {
2556
+ const payload = {
2557
+ provider: this.spawnProvider,
2558
+ approvalMode: this.spawnApproval,
2559
+ };
2560
+ if (this.spawnCwd) payload.cwd = this.spawnCwd;
2561
+ if (this.spawnLabel) payload.label = this.spawnLabel;
2562
+ if (this.spawnPrompt) payload.prompt = this.spawnPrompt;
2563
+ await this.api("POST", "/orchestrators/" + encodeURIComponent(this.spawnOrchId) + "/spawn", payload);
2564
+ this.orchestratorSpawnOpen = false;
2565
+ this.recordOperatorActivity({
2566
+ title: `${this.spawnProvider} agent spawn requested`,
2567
+ body: this.spawnCwd || "",
2568
+ meta: this.spawnOrchId,
2569
+ icon: "ti-plus",
2570
+ kind: "state",
2571
+ view: "orchestrators",
2572
+ });
2573
+ await Promise.all([this.fetchOrchestrators(), this.fetchActivityEvents()]);
2574
+ } catch (e) {
2575
+ alert("Spawn failed: " + e.message);
2576
+ }
2577
+ }
2578
+
2579
+ async function orchestratorAction(orchId, action, agentId) {
2580
+ const label = action === "restart" ? "Restart" : "Shutdown";
2581
+ if (!confirm(`${label} agent "${agentId || "all"}" on orchestrator "${orchId}"?`)) return;
2582
+ try {
2583
+ await this.api("POST", "/orchestrators/" + encodeURIComponent(orchId) + "/actions", { action, agentId });
2584
+ this.recordOperatorActivity({
2585
+ title: `Agent ${action} requested`,
2586
+ body: agentId || "all agents",
2587
+ meta: orchId,
2588
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
2589
+ kind: "state",
2590
+ view: "orchestrators",
2591
+ });
2592
+ await Promise.all([this.fetchOrchestrators(), this.fetchActivityEvents()]);
2593
+ } catch (e) {
2594
+ alert(`${label} failed: ` + e.message);
2595
+ }
2596
+ }
2597
+
2598
+ async function deleteOrchestrator(orchId) {
2599
+ if (!confirm(`Remove orchestrator "${orchId}"? This will NOT stop its managed agents.`)) return;
2600
+ try {
2601
+ await this.api("DELETE", "/orchestrators/" + encodeURIComponent(orchId));
2602
+ await this.fetchOrchestrators();
2603
+ } catch (e) {
2604
+ alert("Delete failed: " + e.message);
2605
+ }
2606
+ }
2607
+
2481
2608
  function closePairMessage() {
2482
2609
  this.pairMessageOpen = false;
2483
2610
  this.pairMessage = { ...DEFAULT_PAIR_MESSAGE };
package/public/index.html CHANGED
@@ -210,6 +210,12 @@
210
210
  <span class="badge bg-success text-white" x-show="onlineCount > 0" x-text="onlineCount"></span>
211
211
  </span>
212
212
  </a>
213
+ <a href="#" class="nav-link" :class="{ active: view === 'orchestrators' }" @click.prevent="switchView('orchestrators')">
214
+ <i class="ti ti-server-2"></i>Orchestrators
215
+ <span class="ms-auto d-inline-flex gap-1 align-items-center">
216
+ <span class="badge bg-success text-white" x-show="orchestrators.filter(o => o.status === 'online').length > 0" x-text="orchestrators.filter(o => o.status === 'online').length"></span>
217
+ </span>
218
+ </a>
213
219
  <a href="#" class="nav-link" :class="{ active: view === 'channels' }" @click.prevent="switchView('channels')">
214
220
  <i class="ti ti-messages"></i>Channels
215
221
  <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>
@@ -658,6 +664,185 @@
658
664
  </template>
659
665
  </div>
660
666
 
667
+ <!-- ==================== ORCHESTRATORS ==================== -->
668
+ <div x-show="view === 'orchestrators'" x-cloak class="fade-in">
669
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
670
+ <h2 class="page-title mb-0">Orchestrators</h2>
671
+ <span class="badge bg-success-lt" x-show="orchestrators.filter(o => o.status === 'online').length > 0"
672
+ x-text="orchestrators.filter(o => o.status === 'online').length + ' online'"></span>
673
+ <div class="ms-auto">
674
+ <button class="btn btn-sm btn-primary" @click="openOrchestratorSpawn()" :disabled="orchestrators.filter(o => o.status === 'online').length === 0">
675
+ <i class="ti ti-plus me-1"></i>Spawn Agent
676
+ </button>
677
+ </div>
678
+ </div>
679
+
680
+ <template x-if="orchestrators.length > 0">
681
+ <div class="row g-3">
682
+ <template x-for="orch in orchestrators" :key="orch.id">
683
+ <div class="col-12 col-lg-6">
684
+ <div class="card">
685
+ <div class="card-body">
686
+ <div class="d-flex align-items-center mb-3">
687
+ <i class="ti ti-server-2 me-2" style="font-size: 24px"></i>
688
+ <div>
689
+ <h3 class="mb-0" x-text="orch.hostname"></h3>
690
+ <small class="text-secondary" x-text="orch.id"></small>
691
+ </div>
692
+ <span class="ms-auto badge" :class="orch.status === 'online' ? 'bg-success' : 'bg-secondary'" x-text="orch.status"></span>
693
+ </div>
694
+
695
+ <div class="d-flex gap-3 mb-3 text-secondary" style="font-size: 13px">
696
+ <span><i class="ti ti-folder me-1"></i><span x-text="orch.baseDir"></span></span>
697
+ <span><i class="ti ti-key me-1"></i><span x-text="(orch.envKeys?.length || 0) + ' env vars'"></span></span>
698
+ </div>
699
+
700
+ <div class="d-flex gap-1 mb-3">
701
+ <template x-for="provider in orch.providers" :key="provider">
702
+ <span class="badge" :class="provider === 'claude' ? 'bg-orange-lt' : 'bg-blue-lt'" x-text="provider"></span>
703
+ </template>
704
+ </div>
705
+
706
+ <template x-if="orch.managedAgents?.length > 0">
707
+ <div>
708
+ <h4 class="mb-2" style="font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--tblr-secondary)">
709
+ Managed Agents (<span x-text="orch.managedAgents.length"></span>)
710
+ </h4>
711
+ <div class="list-group list-group-flush">
712
+ <template x-for="agent in orch.managedAgents" :key="agent.tmuxSession">
713
+ <div class="list-group-item px-0 py-2 d-flex align-items-center gap-2">
714
+ <span class="badge" :class="agent.provider === 'claude' ? 'bg-orange-lt' : 'bg-blue-lt'" x-text="agent.provider" style="font-size: 10px"></span>
715
+ <span x-text="agent.label || agent.tmuxSession" style="font-size: 13px"></span>
716
+ <small class="text-secondary" x-text="agent.cwd" style="font-size: 11px"></small>
717
+ <div class="ms-auto d-flex gap-1">
718
+ <button class="btn btn-sm btn-ghost-warning p-1" title="Restart"
719
+ @click="orchestratorAction(orch.id, 'restart', agent.agentId || agent.tmuxSession)">
720
+ <i class="ti ti-refresh" style="font-size: 14px"></i>
721
+ </button>
722
+ <button class="btn btn-sm btn-ghost-danger p-1" title="Shutdown"
723
+ @click="orchestratorAction(orch.id, 'shutdown', agent.agentId || agent.tmuxSession)">
724
+ <i class="ti ti-power" style="font-size: 14px"></i>
725
+ </button>
726
+ </div>
727
+ </div>
728
+ </template>
729
+ </div>
730
+ </div>
731
+ </template>
732
+ <template x-if="!orch.managedAgents?.length">
733
+ <p class="text-secondary mb-0" style="font-size: 13px">No managed agents</p>
734
+ </template>
735
+
736
+ <div class="d-flex gap-1 mt-3">
737
+ <button class="btn btn-sm btn-primary" @click="openOrchestratorSpawnFor(orch.id)" :disabled="orch.status !== 'online'">
738
+ <i class="ti ti-plus me-1"></i>Spawn
739
+ </button>
740
+ <button class="btn btn-sm btn-ghost-danger" @click="deleteOrchestrator(orch.id)">
741
+ <i class="ti ti-trash me-1"></i>Remove
742
+ </button>
743
+ </div>
744
+ </div>
745
+ <div class="card-footer text-secondary" style="font-size: 12px">
746
+ Last seen: <span x-text="timeAgo(orch.lastSeen)"></span>
747
+ </div>
748
+ </div>
749
+ </div>
750
+ </template>
751
+ </div>
752
+ </template>
753
+
754
+ <template x-if="orchestrators.length === 0">
755
+ <div class="card">
756
+ <div class="card-body text-center py-5">
757
+ <i class="ti ti-server-2 mb-3" style="font-size: 48px; color: var(--tblr-secondary)"></i>
758
+ <h3>No orchestrators registered</h3>
759
+ <p class="text-secondary">Install and start <code>agent-relay-orchestrator</code> on each host to enable remote agent spawning.</p>
760
+ <pre class="text-start mx-auto" style="max-width: 500px; font-size: 12px">npm install -g agent-relay-orchestrator
761
+ agent-relay-orchestrator init
762
+ agent-relay-orchestrator</pre>
763
+ </div>
764
+ </div>
765
+ </template>
766
+ </div>
767
+
768
+ <!-- Orchestrator Spawn Modal -->
769
+ <div class="modal" :class="{ show: orchestratorSpawnOpen }" tabindex="-1" :style="orchestratorSpawnOpen ? 'display:block' : 'display:none'" @click.self="orchestratorSpawnOpen = false">
770
+ <div class="modal-dialog modal-dialog-centered">
771
+ <div class="modal-content">
772
+ <div class="modal-header">
773
+ <h5 class="modal-title">Spawn Agent</h5>
774
+ <button type="button" class="btn-close" @click="orchestratorSpawnOpen = false"></button>
775
+ </div>
776
+ <div class="modal-body">
777
+ <div class="mb-3">
778
+ <label class="form-label">Orchestrator</label>
779
+ <select class="form-select" x-model="spawnOrchId">
780
+ <template x-for="orch in orchestrators.filter(o => o.status === 'online')" :key="orch.id">
781
+ <option :value="orch.id" x-text="orch.hostname + ' (' + orch.providers.join(', ') + ')'"></option>
782
+ </template>
783
+ </select>
784
+ </div>
785
+ <div class="mb-3">
786
+ <label class="form-label">Provider</label>
787
+ <select class="form-select" x-model="spawnProvider">
788
+ <template x-for="p in spawnAvailableProviders" :key="p">
789
+ <option :value="p" x-text="p"></option>
790
+ </template>
791
+ </select>
792
+ </div>
793
+ <div class="mb-3">
794
+ <label class="form-label">Working Directory</label>
795
+ <div class="input-group">
796
+ <input type="text" class="form-control" x-model="spawnCwd" placeholder="Leave empty for base directory">
797
+ <button class="btn btn-outline-secondary" @click="browseOrchestratorDirs()" type="button">
798
+ <i class="ti ti-folder"></i>
799
+ </button>
800
+ </div>
801
+ <template x-if="spawnDirListing">
802
+ <div class="border rounded mt-2 p-2" style="max-height: 200px; overflow-y: auto; font-size: 13px">
803
+ <template x-if="spawnDirListing.parent">
804
+ <a href="#" class="d-block py-1 text-secondary" @click.prevent="spawnCwd = spawnDirListing.parent; browseOrchestratorDirs()">
805
+ <i class="ti ti-arrow-up me-1"></i>..
806
+ </a>
807
+ </template>
808
+ <template x-for="entry in spawnDirListing.entries" :key="entry.path">
809
+ <a href="#" class="d-block py-1" @click.prevent="spawnCwd = entry.path; browseOrchestratorDirs()">
810
+ <i class="ti ti-folder me-1"></i><span x-text="entry.name"></span>
811
+ </a>
812
+ </template>
813
+ </div>
814
+ </template>
815
+ </div>
816
+ <div class="mb-3">
817
+ <label class="form-label">Label</label>
818
+ <input type="text" class="form-control" x-model="spawnLabel" placeholder="e.g. backend, reviewer">
819
+ </div>
820
+ <div class="mb-3">
821
+ <label class="form-label">Approval Mode</label>
822
+ <select class="form-select" x-model="spawnApproval">
823
+ <option value="guarded">Guarded (block destructive ops)</option>
824
+ <option value="open">Open (no restrictions)</option>
825
+ <option value="read-only">Read-only (observe only)</option>
826
+ </select>
827
+ </div>
828
+ <template x-if="spawnProvider === 'claude'">
829
+ <div class="mb-3">
830
+ <label class="form-label">Initial Prompt</label>
831
+ <textarea class="form-control" x-model="spawnPrompt" rows="3" placeholder="You are a headless relay agent..."></textarea>
832
+ </div>
833
+ </template>
834
+ </div>
835
+ <div class="modal-footer">
836
+ <button class="btn btn-secondary" @click="orchestratorSpawnOpen = false">Cancel</button>
837
+ <button class="btn btn-primary" @click="submitOrchestratorSpawn()">
838
+ <i class="ti ti-rocket me-1"></i>Spawn
839
+ </button>
840
+ </div>
841
+ </div>
842
+ </div>
843
+ </div>
844
+ <div class="modal-backdrop fade show" x-show="orchestratorSpawnOpen" x-cloak></div>
845
+
661
846
  <!-- ==================== CHANNELS ==================== -->
662
847
  <div x-show="view === 'channels'" x-cloak class="fade-in">
663
848
  <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
package/src/db.ts CHANGED
@@ -9,15 +9,21 @@ import type {
9
9
  CreatePairInput,
10
10
  HealthCheck,
11
11
  HealthReport,
12
+ ManagedAgent,
12
13
  Message,
13
14
  MessageType,
15
+ Orchestrator,
16
+ OrchestratorStatus,
14
17
  PairActionInput,
15
18
  PairMessageInput,
16
19
  PairSession,
17
20
  PairStatus,
18
21
  RegisterAgentInput,
22
+ RegisterOrchestratorInput,
19
23
  SendMessageInput,
20
24
  PollQuery,
25
+ SpawnApprovalMode,
26
+ SpawnProvider,
21
27
  Task,
22
28
  TaskEvent,
23
29
  TaskSeverity,
@@ -192,6 +198,20 @@ export function initDb(path: string = "agent-relay.db"): Database {
192
198
  );
193
199
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
194
200
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
201
+
202
+ CREATE TABLE IF NOT EXISTS orchestrators (
203
+ id TEXT PRIMARY KEY,
204
+ hostname TEXT NOT NULL,
205
+ status TEXT NOT NULL DEFAULT 'online',
206
+ agent_id TEXT NOT NULL,
207
+ providers TEXT NOT NULL DEFAULT '[]',
208
+ base_dir TEXT NOT NULL,
209
+ env_keys TEXT NOT NULL DEFAULT '[]',
210
+ meta TEXT NOT NULL DEFAULT '{}',
211
+ managed_agents TEXT NOT NULL DEFAULT '[]',
212
+ last_seen INTEGER NOT NULL,
213
+ created_at INTEGER NOT NULL
214
+ );
195
215
  `);
196
216
 
197
217
  // Migrations
@@ -1866,3 +1886,114 @@ export function getHealth(now: number = Date.now()): HealthReport {
1866
1886
  : "ok";
1867
1887
  return { status, version: VERSION, generatedAt: now, checks };
1868
1888
  }
1889
+
1890
+ // --- Orchestrators ---
1891
+
1892
+ function rowToOrchestrator(row: any): Orchestrator {
1893
+ return {
1894
+ id: row.id,
1895
+ hostname: row.hostname,
1896
+ status: row.status as OrchestratorStatus,
1897
+ agentId: row.agent_id,
1898
+ providers: parseJson<SpawnProvider[]>(row.providers, []),
1899
+ baseDir: row.base_dir,
1900
+ envKeys: parseJson<string[]>(row.env_keys, []),
1901
+ meta: parseJson(row.meta, {}),
1902
+ managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
1903
+ lastSeen: row.last_seen,
1904
+ createdAt: row.created_at,
1905
+ };
1906
+ }
1907
+
1908
+ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
1909
+ const now = Date.now();
1910
+ const agentId = `orchestrator-${input.id}`;
1911
+ const stmt = db.prepare(`
1912
+ INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, env_keys, meta, last_seen, created_at)
1913
+ VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $envKeys, $meta, $now, $now)
1914
+ ON CONFLICT(id) DO UPDATE SET
1915
+ hostname = $hostname,
1916
+ status = 'online',
1917
+ providers = $providers,
1918
+ base_dir = $baseDir,
1919
+ env_keys = $envKeys,
1920
+ meta = $meta,
1921
+ last_seen = $now
1922
+ `);
1923
+ stmt.run({
1924
+ $id: input.id,
1925
+ $hostname: input.hostname,
1926
+ $agentId: agentId,
1927
+ $providers: JSON.stringify(input.providers),
1928
+ $baseDir: input.baseDir,
1929
+ $envKeys: JSON.stringify(input.envKeys ?? []),
1930
+ $meta: JSON.stringify(input.meta ?? {}),
1931
+ $now: now,
1932
+ });
1933
+
1934
+ // Also register as an agent so the orchestrator can receive messages
1935
+ upsertAgent({
1936
+ id: agentId,
1937
+ name: `Orchestrator (${input.hostname})`,
1938
+ tags: ["orchestrator", input.hostname],
1939
+ machine: input.hostname,
1940
+ capabilities: ["orchestrator", "spawn"],
1941
+ status: "online",
1942
+ meta: { orchestratorId: input.id, builtin: true },
1943
+ });
1944
+
1945
+ return getOrchestrator(input.id)!;
1946
+ }
1947
+
1948
+ export function getOrchestrator(id: string): Orchestrator | null {
1949
+ const row = db.prepare("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
1950
+ return row ? rowToOrchestrator(row) : null;
1951
+ }
1952
+
1953
+ export function listOrchestrators(): Orchestrator[] {
1954
+ return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
1955
+ }
1956
+
1957
+ export function orchestratorHeartbeat(id: string): Orchestrator | null {
1958
+ const now = Date.now();
1959
+ db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online' WHERE id = ?").run(now, id);
1960
+ // Also heartbeat the agent
1961
+ const orch = getOrchestrator(id);
1962
+ if (orch) {
1963
+ heartbeat(orch.agentId);
1964
+ }
1965
+ return orch;
1966
+ }
1967
+
1968
+ export function setOrchestratorStatus(id: string, status: OrchestratorStatus): Orchestrator | null {
1969
+ db.prepare("UPDATE orchestrators SET status = ?, last_seen = ? WHERE id = ?").run(status, Date.now(), id);
1970
+ const orch = getOrchestrator(id);
1971
+ if (orch) {
1972
+ setStatus(orch.agentId, status === "online" ? "online" : "offline");
1973
+ }
1974
+ return orch;
1975
+ }
1976
+
1977
+ export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
1978
+ db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
1979
+ .run(JSON.stringify(agents), Date.now(), id);
1980
+ return getOrchestrator(id);
1981
+ }
1982
+
1983
+ export function deleteOrchestrator(id: string): boolean {
1984
+ const orch = getOrchestrator(id);
1985
+ if (!orch) return false;
1986
+ db.prepare("DELETE FROM orchestrators WHERE id = ?").run(id);
1987
+ deleteAgent(orch.agentId);
1988
+ return true;
1989
+ }
1990
+
1991
+ export function reapStaleOrchestrators(): string[] {
1992
+ const cutoff = Date.now() - STALE_TTL_MS;
1993
+ const stale = db.prepare("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
1994
+ for (const row of stale) {
1995
+ db.prepare("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
1996
+ setStatus(row.agent_id, "offline");
1997
+ }
1998
+ return stale.map((row: any) => row.id);
1999
+ }
package/src/routes.ts CHANGED
@@ -46,11 +46,18 @@ import {
46
46
  createCallbackDelivery,
47
47
  finishCallbackDelivery,
48
48
  reapStaleAgents,
49
+ reapStaleOrchestrators,
49
50
  releaseExpiredClaims,
50
51
  validateAgentSession,
52
+ upsertOrchestrator,
53
+ getOrchestrator,
54
+ listOrchestrators,
55
+ orchestratorHeartbeat,
56
+ updateManagedAgents,
57
+ deleteOrchestrator,
51
58
  ValidationError,
52
59
  } from "./db";
53
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
60
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
54
61
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
55
62
  import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
56
63
  import {
@@ -67,6 +74,8 @@ import {
67
74
  emitMessageClaimReleased,
68
75
  emitMessageDeleted,
69
76
  emitTaskChanged,
77
+ emitOrchestratorStatus,
78
+ emitOrchestratorRemoved,
70
79
  } from "./sse";
71
80
 
72
81
  type Handler = (
@@ -804,11 +813,47 @@ const postAgentSpawn: Handler = async (req) => {
804
813
  if (!parsed.ok) return error(parsed.error, parsed.status);
805
814
  try {
806
815
  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");
816
+ const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
817
+ if (!provider) return error("provider required");
809
818
  const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
810
819
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
811
820
  const label = cleanString(parsed.body.label, "label", { max: 120 });
821
+
822
+ // Check for an online orchestrator that supports this provider
823
+ const orchestrators = listOrchestrators().filter(
824
+ (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
825
+ );
826
+ if (orchestrators.length > 0) {
827
+ // Route through the first available orchestrator
828
+ const orch = orchestrators[0]!;
829
+ const msg = sendMessage({
830
+ from: "system",
831
+ to: orch.agentId,
832
+ type: "system",
833
+ subject: "Spawn agent",
834
+ body: JSON.stringify({ action: "spawn", provider, cwd, label, approvalMode }),
835
+ meta: {
836
+ orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() },
837
+ delivery: "interrupt",
838
+ priority: "urgent",
839
+ },
840
+ });
841
+ emitNewMessage(msg);
842
+ auditEvent({
843
+ clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
844
+ kind: "state",
845
+ title: `${provider} agent spawn requested (via ${orch.id})`,
846
+ body: cwd || orch.baseDir,
847
+ meta: orch.id,
848
+ icon: "ti-plus",
849
+ view: "agents",
850
+ metadata: { provider, orchestratorId: orch.id, approvalMode },
851
+ });
852
+ return json({ ok: true, orchestratorId: orch.id, provider, message: msg }, 202);
853
+ }
854
+
855
+ // Fallback: direct spawn for codex only (no orchestrator)
856
+ if (provider !== "codex") return error("no orchestrator available for provider: " + provider);
812
857
  const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
813
858
  const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
814
859
  const result = spawnCodexAgent({
@@ -822,7 +867,7 @@ const postAgentSpawn: Handler = async (req) => {
822
867
  auditEvent({
823
868
  clientId: "server-agent-spawn-codex-" + Date.now(),
824
869
  kind: "state",
825
- title: "Codex agent spawn requested",
870
+ title: "Codex agent spawn requested (direct)",
826
871
  body: result.cwd,
827
872
  meta: result.pid ? `pid ${result.pid}` : "dry run",
828
873
  icon: "ti-plus",
@@ -855,6 +900,207 @@ const getHostDirectories: Handler = (req) => {
855
900
  }
856
901
  };
857
902
 
903
+ // --- Orchestrator routes ---
904
+
905
+ const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
906
+ const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
907
+
908
+ const postOrchestrator: Handler = async (req) => {
909
+ const parsed = await parseBody<unknown>(req);
910
+ if (!parsed.ok) return error(parsed.error, parsed.status);
911
+ try {
912
+ if (!isRecord(parsed.body)) return error("body required");
913
+ const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
914
+ const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
915
+ const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
916
+ const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
917
+ if (providers) {
918
+ for (const p of providers) {
919
+ if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
920
+ return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
921
+ }
922
+ }
923
+ }
924
+ const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
925
+ const meta = cleanMeta(parsed.body.meta);
926
+ const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, meta });
927
+ auditEvent({
928
+ clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
929
+ kind: "state",
930
+ title: "Orchestrator registered",
931
+ body: hostname,
932
+ meta: id,
933
+ icon: "ti-server-2",
934
+ view: "orchestrators",
935
+ metadata: { orchestratorId: id, providers: orch.providers },
936
+ });
937
+ return json(orch, 201);
938
+ } catch (e) {
939
+ if (e instanceof ValidationError) return error(e.message, 400);
940
+ throw e;
941
+ }
942
+ };
943
+
944
+ const getOrchestrators: Handler = () => {
945
+ return json(listOrchestrators());
946
+ };
947
+
948
+ const getOrchestratorById: Handler = (_req, params) => {
949
+ const orch = getOrchestrator(params.id!);
950
+ if (!orch) return error("orchestrator not found", 404);
951
+ return json(orch);
952
+ };
953
+
954
+ const postOrchestratorHeartbeat: Handler = (_req, params) => {
955
+ const orch = orchestratorHeartbeat(params.id!);
956
+ if (!orch) return error("orchestrator not found", 404);
957
+ return json({ ok: true });
958
+ };
959
+
960
+ const patchOrchestratorAgents: Handler = async (req, params) => {
961
+ const parsed = await parseBody<unknown>(req);
962
+ if (!parsed.ok) return error(parsed.error, parsed.status);
963
+ try {
964
+ const orch = getOrchestrator(params.id!);
965
+ if (!orch) return error("orchestrator not found", 404);
966
+ if (!isRecord(parsed.body)) return error("body required");
967
+ const agents = parsed.body.agents;
968
+ if (!Array.isArray(agents)) return error("agents must be an array");
969
+ const cleaned: ManagedAgent[] = agents.map((a: any) => {
970
+ if (!isRecord(a)) throw new ValidationError("each agent must be an object");
971
+ return {
972
+ agentId: cleanString(a.agentId, "agentId", { required: true, max: 240 })!,
973
+ provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
974
+ tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!,
975
+ cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
976
+ label: cleanString(a.label, "label", { max: 120 }),
977
+ approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
978
+ pid: typeof a.pid === "number" && Number.isSafeInteger(a.pid) ? a.pid : undefined,
979
+ startedAt: typeof a.startedAt === "number" ? a.startedAt : Date.now(),
980
+ };
981
+ });
982
+ const updated = updateManagedAgents(params.id!, cleaned);
983
+ return json(updated);
984
+ } catch (e) {
985
+ if (e instanceof ValidationError) return error(e.message, 400);
986
+ throw e;
987
+ }
988
+ };
989
+
990
+ const postOrchestratorSpawn: Handler = async (req, params) => {
991
+ const parsed = await parseBody<unknown>(req);
992
+ if (!parsed.ok) return error(parsed.error, parsed.status);
993
+ try {
994
+ const orch = getOrchestrator(params.id!);
995
+ if (!orch) return error("orchestrator not found", 404);
996
+ if (orch.status !== "online") return error("orchestrator is offline", 409);
997
+
998
+ if (!isRecord(parsed.body)) return error("body required");
999
+ const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
1000
+ if (!orch.providers.includes(provider)) {
1001
+ return error(`orchestrator does not support provider: ${provider}`);
1002
+ }
1003
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
1004
+ if (cwd && !cwd.startsWith(orch.baseDir)) {
1005
+ return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
1006
+ }
1007
+ const label = cleanString(parsed.body.label, "label", { max: 120 });
1008
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
1009
+ const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1010
+
1011
+ // Send control message to orchestrator's agent inbox
1012
+ const msg = sendMessage({
1013
+ from: "system",
1014
+ to: orch.agentId,
1015
+ type: "system",
1016
+ subject: "Spawn agent",
1017
+ body: JSON.stringify({ action: "spawn", provider, cwd: cwd || orch.baseDir, label, approvalMode, prompt }),
1018
+ meta: {
1019
+ orchestratorControl: {
1020
+ action: "spawn",
1021
+ provider,
1022
+ cwd: cwd || orch.baseDir,
1023
+ label,
1024
+ approvalMode,
1025
+ prompt,
1026
+ requestedBy: "dashboard",
1027
+ requestedAt: Date.now(),
1028
+ },
1029
+ delivery: "interrupt",
1030
+ priority: "urgent",
1031
+ },
1032
+ });
1033
+ emitNewMessage(msg);
1034
+ auditEvent({
1035
+ clientId: "server-orchestrator-spawn-" + orch.id + "-" + Date.now(),
1036
+ kind: "state",
1037
+ title: `Spawn ${provider} agent requested`,
1038
+ body: cwd || orch.baseDir,
1039
+ meta: orch.id,
1040
+ icon: "ti-plus",
1041
+ view: "orchestrators",
1042
+ metadata: { orchestratorId: orch.id, provider, approvalMode, label },
1043
+ });
1044
+ return json({ ok: true, orchestratorId: orch.id, message: msg }, 202);
1045
+ } catch (e) {
1046
+ if (e instanceof ValidationError) return error(e.message, 400);
1047
+ throw e;
1048
+ }
1049
+ };
1050
+
1051
+ const postOrchestratorAction: Handler = async (req, params) => {
1052
+ const parsed = await parseBody<unknown>(req);
1053
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1054
+ try {
1055
+ const orch = getOrchestrator(params.id!);
1056
+ if (!orch) return error("orchestrator not found", 404);
1057
+
1058
+ if (!isRecord(parsed.body)) return error("body required");
1059
+ const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
1060
+ if (!action) return error("action required");
1061
+ const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1062
+
1063
+ const msg = sendMessage({
1064
+ from: "system",
1065
+ to: orch.agentId,
1066
+ type: "system",
1067
+ subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1068
+ body: JSON.stringify({ action, agentId }),
1069
+ meta: {
1070
+ orchestratorControl: {
1071
+ action,
1072
+ agentId,
1073
+ requestedBy: "dashboard",
1074
+ requestedAt: Date.now(),
1075
+ },
1076
+ delivery: "interrupt",
1077
+ priority: "urgent",
1078
+ },
1079
+ });
1080
+ emitNewMessage(msg);
1081
+ auditEvent({
1082
+ clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + Date.now(),
1083
+ kind: "state",
1084
+ title: `Agent ${action} requested`,
1085
+ body: agentId || "all",
1086
+ meta: orch.id,
1087
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
1088
+ view: "orchestrators",
1089
+ metadata: { orchestratorId: orch.id, action, agentId },
1090
+ });
1091
+ return json({ ok: true, action, message: msg }, 202);
1092
+ } catch (e) {
1093
+ if (e instanceof ValidationError) return error(e.message, 400);
1094
+ throw e;
1095
+ }
1096
+ };
1097
+
1098
+ const deleteOrchestratorById: Handler = (_req, params) => {
1099
+ const deleted = deleteOrchestrator(params.id!);
1100
+ if (!deleted) return error("orchestrator not found", 404);
1101
+ return json({ ok: true });
1102
+ };
1103
+
858
1104
  // --- Message routes ---
859
1105
 
860
1106
  const VALID_MSG_TYPES = ["message", "system"];
@@ -1486,20 +1732,23 @@ const getHealthRoute: Handler = () => json(getHealth());
1486
1732
  const postSystemReap: Handler = () => {
1487
1733
  const released = releaseExpiredClaims();
1488
1734
  const reapedAgentIds = reapStaleAgents();
1735
+ const reapedOrchestratorIds = reapStaleOrchestrators();
1489
1736
  for (const id of released.messageIds) emitMessageClaimReleased(id);
1490
1737
  for (const task of released.tasks) emitTaskChanged(task, "task.updated");
1491
1738
  for (const id of reapedAgentIds) emitAgentStatus(id);
1739
+ for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
1492
1740
  auditEvent({
1493
1741
  clientId: "server-system-reap-" + Date.now(),
1494
1742
  kind: "state",
1495
1743
  title: "Maintenance reaper run",
1496
- body: `${reapedAgentIds.length} stale agent(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
1744
+ body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
1497
1745
  icon: "ti-broom",
1498
1746
  view: "activity",
1499
1747
  });
1500
1748
  return json({
1501
1749
  ok: true,
1502
1750
  reapedAgentIds,
1751
+ reapedOrchestratorIds,
1503
1752
  releasedMessageIds: released.messageIds,
1504
1753
  releasedTaskIds: released.tasks.map((task) => task.id),
1505
1754
  });
@@ -1542,6 +1791,15 @@ const routes: Route[] = [
1542
1791
  route("POST", "/api/agents/:id/actions", postAgentAction),
1543
1792
  route("DELETE", "/api/agents/:id", deleteAgentById),
1544
1793
 
1794
+ route("POST", "/api/orchestrators", postOrchestrator),
1795
+ route("GET", "/api/orchestrators", getOrchestrators),
1796
+ route("GET", "/api/orchestrators/:id", getOrchestratorById),
1797
+ route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
1798
+ route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
1799
+ route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
1800
+ route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
1801
+ route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
1802
+
1545
1803
  route("POST", "/api/system/broadcast", postSystemBroadcast),
1546
1804
  route("POST", "/api/messages", postMessage),
1547
1805
  route("GET", "/api/messages", getMessages),
package/src/sse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getAgent } from "./db";
1
+ import { getAgent, getOrchestrator } from "./db";
2
2
  import type { Message, Task } from "./types";
3
3
 
4
4
  interface Connection {
@@ -135,3 +135,17 @@ function targetMatchesAgent(target: string, agentId: string): boolean {
135
135
  if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
136
136
  return false;
137
137
  }
138
+
139
+ export function emitOrchestratorStatus(orchestratorId: string) {
140
+ const orch = getOrchestrator(orchestratorId);
141
+ const data = orch ?? { id: orchestratorId, status: "offline" };
142
+ for (const conn of connections.values()) {
143
+ send(conn, "orchestrator.status", data);
144
+ }
145
+ }
146
+
147
+ export function emitOrchestratorRemoved(orchestratorId: string) {
148
+ for (const conn of connections.values()) {
149
+ send(conn, "orchestrator.removed", { id: orchestratorId });
150
+ }
151
+ }
package/src/types.ts CHANGED
@@ -291,6 +291,66 @@ export interface ActivityEventInput {
291
291
  metadata?: Record<string, unknown>;
292
292
  }
293
293
 
294
+ // --- Orchestrators ---
295
+
296
+ export type OrchestratorStatus = "online" | "offline";
297
+ export type SpawnProvider = "claude" | "codex";
298
+ export type SpawnApprovalMode = "open" | "guarded" | "read-only";
299
+
300
+ export interface Orchestrator {
301
+ id: string;
302
+ hostname: string;
303
+ status: OrchestratorStatus;
304
+ agentId: string; // relay agent id for messaging
305
+ providers: SpawnProvider[];
306
+ baseDir: string;
307
+ envKeys: string[]; // names only, never values
308
+ meta: Record<string, unknown>;
309
+ managedAgents: ManagedAgent[];
310
+ lastSeen: number;
311
+ createdAt: number;
312
+ }
313
+
314
+ export interface ManagedAgent {
315
+ agentId: string;
316
+ provider: SpawnProvider;
317
+ tmuxSession: string;
318
+ cwd: string;
319
+ label?: string;
320
+ approvalMode: SpawnApprovalMode;
321
+ pid?: number;
322
+ startedAt: number;
323
+ }
324
+
325
+ export interface RegisterOrchestratorInput {
326
+ id: string;
327
+ hostname: string;
328
+ providers: SpawnProvider[];
329
+ baseDir: string;
330
+ envKeys?: string[];
331
+ meta?: Record<string, unknown>;
332
+ }
333
+
334
+ export interface OrchestratorSpawnInput {
335
+ provider: SpawnProvider;
336
+ cwd?: string;
337
+ label?: string;
338
+ approvalMode?: SpawnApprovalMode;
339
+ prompt?: string;
340
+ env?: Record<string, string>;
341
+ }
342
+
343
+ export interface OrchestratorSpawnResult {
344
+ orchestratorId: string;
345
+ provider: SpawnProvider;
346
+ tmuxSession: string;
347
+ cwd: string;
348
+ label?: string;
349
+ approvalMode: SpawnApprovalMode;
350
+ pid?: number;
351
+ startedAt: number;
352
+ }
353
+
294
354
  export interface HealthCheck {
295
355
  name: string;
296
356
  status: "ok" | "warn" | "error";