agent-relay-server 0.6.0 → 0.7.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/src/db.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
- import { VERSION } from "./config.ts";
3
+ import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./config.ts";
4
4
  import type {
5
5
  AgentCard,
6
6
  ActivityEvent,
@@ -11,12 +11,14 @@ import type {
11
11
  ChannelBindingMode,
12
12
  ChannelRouteTarget,
13
13
  ChannelSummary,
14
+ ChannelTargetHealth,
14
15
  CreatePairInput,
15
16
  HealthCheck,
16
17
  HealthReport,
17
18
  ManagedAgent,
18
19
  Message,
19
20
  Orchestrator,
21
+ OrchestratorHealth,
20
22
  OrchestratorStatus,
21
23
  PairActionInput,
22
24
  PairMessageInput,
@@ -628,6 +630,94 @@ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
628
630
  return "";
629
631
  }
630
632
 
633
+ function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
634
+ const candidates = listAgents().filter((agent) => (
635
+ agent.id !== "user" &&
636
+ agent.id !== "system" &&
637
+ agent.kind !== "channel" &&
638
+ agent.meta?.kind !== "channel"
639
+ ));
640
+ if (target.type === "agent" || target.type === "orchestrator") {
641
+ const agent = getAgent(target.id);
642
+ return agent ? [agent] : [];
643
+ }
644
+ if (target.type === "label") return candidates.filter((agent) => agent.label === target.id);
645
+ if (target.type === "tag") return candidates.filter((agent) => agent.tags.includes(target.id));
646
+ if (target.type === "capability") return candidates.filter((agent) => agent.capabilities.includes(target.id));
647
+ if (target.type === "broadcast") return candidates;
648
+ return [];
649
+ }
650
+
651
+ function channelTargetMatchSnapshot(agent: AgentCard): ChannelTargetHealth["matches"][number] {
652
+ return {
653
+ id: agent.id,
654
+ name: agent.name,
655
+ status: agent.status,
656
+ ready: agent.ready,
657
+ lastSeen: agent.lastSeen,
658
+ label: agent.label,
659
+ tags: agent.tags,
660
+ capabilities: agent.capabilities,
661
+ };
662
+ }
663
+
664
+ function isHealthyChannelTarget(agent: AgentCard, now: number): boolean {
665
+ return isDeliveryAgent(agent) && agent.ready && agent.lastSeen > now - STALE_TTL_MS;
666
+ }
667
+
668
+ function describeTarget(target: ChannelRouteTarget): string {
669
+ return bindingTargetToLegacyTarget(target);
670
+ }
671
+
672
+ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()): ChannelTargetHealth {
673
+ const target = binding.target;
674
+ const targetLabel = describeTarget(target);
675
+ const matches = channelTargetMatches(target);
676
+ const snapshots = matches.map(channelTargetMatchSnapshot);
677
+
678
+ if (target.type === "agent" || target.type === "orchestrator") {
679
+ const agent = matches[0];
680
+ if (!agent) {
681
+ return { status: "error", detail: `Target ${targetLabel} is not registered`, target, matches: [] };
682
+ }
683
+ if (agent.id !== "user" && agent.id !== "system" && (agent.kind === "channel" || agent.meta?.kind === "channel")) {
684
+ return { status: "error", detail: `Target ${targetLabel} is a channel, not a delivery agent`, target, matches: snapshots };
685
+ }
686
+ if (agent.status === "offline") {
687
+ return { status: "error", detail: `Target ${targetLabel} is offline`, target, matches: snapshots };
688
+ }
689
+ if (!agent.ready) {
690
+ return { status: "warning", detail: `Target ${targetLabel} is online but not ready`, target, matches: snapshots };
691
+ }
692
+ if (agent.id !== "user" && agent.id !== "system" && agent.lastSeen <= now - STALE_TTL_MS) {
693
+ return { status: "warning", detail: `Target ${targetLabel} heartbeat is stale`, target, matches: snapshots };
694
+ }
695
+ return { status: "ok", detail: `Target ${targetLabel} is online and ready`, target, matches: snapshots };
696
+ }
697
+
698
+ const healthyMatches = matches.filter((agent) => isHealthyChannelTarget(agent, now));
699
+ if (matches.length === 0) {
700
+ return { status: "error", detail: `Target ${targetLabel} has no matching agents`, target, matches: [] };
701
+ }
702
+ if (healthyMatches.length === 0) {
703
+ return { status: "error", detail: `Target ${targetLabel} has no online ready agents`, target, matches: snapshots };
704
+ }
705
+ if (binding.mode === "exclusive" && healthyMatches.length > 1) {
706
+ return {
707
+ status: "warning",
708
+ detail: `Target ${targetLabel} matches ${healthyMatches.length} online ready agents for an exclusive binding`,
709
+ target,
710
+ matches: snapshots,
711
+ };
712
+ }
713
+ return {
714
+ status: "ok",
715
+ detail: `Target ${targetLabel} has ${healthyMatches.length} online ready agent${healthyMatches.length === 1 ? "" : "s"}`,
716
+ target,
717
+ matches: snapshots,
718
+ };
719
+ }
720
+
631
721
  function rowToChannelSummary(row: any): ChannelSummary {
632
722
  const binding = row.binding_id ? rowToChannelBinding({
633
723
  id: row.binding_id,
@@ -653,6 +743,7 @@ function rowToChannelSummary(row: any): ChannelSummary {
653
743
  direction: row.direction,
654
744
  target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
655
745
  binding,
746
+ targetHealth: binding ? channelTargetHealth(binding) : undefined,
656
747
  topicChannels: parseStringArray(row.topic_channels),
657
748
  capabilities: parseStringArray(row.channel_capabilities),
658
749
  tags: parseStringArray(row.agent_tags),
@@ -965,25 +1056,31 @@ export function upsertChannelBinding(input: {
965
1056
  const targetId = input.target.type === "broadcast" ? "" : input.target.id;
966
1057
  const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
967
1058
  const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
1059
+ const mode = input.mode ?? "exclusive";
968
1060
  const now = Date.now();
969
- db.prepare(`
970
- INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
971
- VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
972
- ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
973
- mode = $mode,
974
- priority = $priority,
975
- updated_at = $now
976
- `).run({
977
- $id: id,
978
- $channelId: input.channelId,
979
- $conversationKey: conversationKey,
980
- $conversationId: input.conversationId ?? null,
981
- $targetType: input.target.type,
982
- $targetId: targetId,
983
- $mode: input.mode ?? "exclusive",
984
- $priority: input.priority ?? 0,
985
- $now: now,
986
- });
1061
+ db.transaction(() => {
1062
+ if (mode === "exclusive") {
1063
+ db.prepare("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
1064
+ }
1065
+ db.prepare(`
1066
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
1067
+ VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
1068
+ ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
1069
+ mode = $mode,
1070
+ priority = $priority,
1071
+ updated_at = $now
1072
+ `).run({
1073
+ $id: id,
1074
+ $channelId: input.channelId,
1075
+ $conversationKey: conversationKey,
1076
+ $conversationId: input.conversationId ?? null,
1077
+ $targetType: input.target.type,
1078
+ $targetId: targetId,
1079
+ $mode: mode,
1080
+ $priority: input.priority ?? 0,
1081
+ $now: now,
1082
+ });
1083
+ })();
987
1084
 
988
1085
  const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
989
1086
  return rowToChannelBinding(row);
@@ -1733,6 +1830,21 @@ function isChannelAgentId(agentId: string): boolean {
1733
1830
  ));
1734
1831
  }
1735
1832
 
1833
+ function legacyChannelTargets(agent: AgentCard | null | undefined): string[] {
1834
+ if (!agent || !isChannelAgentId(agent.id)) return [];
1835
+ const aliases = new Set<string>();
1836
+ const provider = channelProviderForAgent(agent);
1837
+ if (provider && provider !== "custom") aliases.add(provider);
1838
+ const channelType = stringValue(agent.meta?.channelType);
1839
+ if (channelType) aliases.add(channelType);
1840
+ const transport = stringValue(agent.meta?.transport);
1841
+ if (transport) aliases.add(transport);
1842
+ const providerTag = agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length);
1843
+ if (providerTag) aliases.add(providerTag);
1844
+ aliases.delete(agent.id);
1845
+ return [...aliases];
1846
+ }
1847
+
1736
1848
  function matchingDeliveryAgents(target: string): AgentCard[] {
1737
1849
  if (!target) return [];
1738
1850
  const candidates = listAgents().filter(isDeliveryAgent);
@@ -1963,11 +2075,15 @@ export function pollMessages(query: PollQuery): Message[] {
1963
2075
  const agentTags = agent?.tags ?? [];
1964
2076
  const agentCaps = agent?.capabilities ?? [];
1965
2077
  const agentLabel = agent?.label;
2078
+ const agentLegacyChannelTargets = legacyChannelTargets(agent);
1966
2079
 
1967
2080
  const conditions: string[] = [];
1968
2081
  const params: any[] = [];
1969
2082
 
1970
- // Build target matching: direct + broadcast + tag + capability + label
2083
+ // Build target matching: direct + broadcast + tag + capability + label.
2084
+ // Channel agents also accept legacy bare provider targets (for example
2085
+ // "telegram") so older clients keep working after canonical IDs became
2086
+ // provider:account ("telegram:default").
1971
2087
  const targetClauses = ["to_target = ?", "to_target = 'broadcast'"];
1972
2088
  params.push(query.for);
1973
2089
 
@@ -1983,6 +2099,10 @@ export function pollMessages(query: PollQuery): Message[] {
1983
2099
  targetClauses.push("to_target = ?");
1984
2100
  params.push(`label:${agentLabel}`);
1985
2101
  }
2102
+ for (const target of agentLegacyChannelTargets) {
2103
+ targetClauses.push("to_target = ?");
2104
+ params.push(target);
2105
+ }
1986
2106
  conditions.push(`(${targetClauses.join(" OR ")})`);
1987
2107
 
1988
2108
  // Hide active claims held by someone else, but let expired claims surface so
@@ -2267,6 +2387,21 @@ export function getHealth(now: number = Date.now()): HealthReport {
2267
2387
  detail: offlineClaimedTasks > 0 ? `${offlineClaimedTasks} active task(s) are claimed by offline agents` : undefined,
2268
2388
  });
2269
2389
 
2390
+ const unhealthyChannelTargets = listChannels().filter((channel) => channel.targetHealth && channel.targetHealth.status !== "ok");
2391
+ const channelTargetErrors = unhealthyChannelTargets.filter((channel) => channel.targetHealth?.status === "error");
2392
+ const channelTargetDetail = unhealthyChannelTargets
2393
+ .slice(0, 3)
2394
+ .map((channel) => `${channel.name}: ${channel.targetHealth?.detail}`)
2395
+ .join("; ");
2396
+ checks.push({
2397
+ name: "channel-delivery-targets",
2398
+ status: channelTargetErrors.length > 0 ? "error" : unhealthyChannelTargets.length > 0 ? "warn" : "ok",
2399
+ count: unhealthyChannelTargets.length,
2400
+ detail: unhealthyChannelTargets.length > 0
2401
+ ? `${unhealthyChannelTargets.length} channel delivery target issue(s): ${channelTargetDetail}${unhealthyChannelTargets.length > 3 ? "; ..." : ""}`
2402
+ : undefined,
2403
+ });
2404
+
2270
2405
  const status = checks.some((check) => check.status === "error")
2271
2406
  ? "error"
2272
2407
  : checks.some((check) => check.status === "warn")
@@ -2278,6 +2413,15 @@ export function getHealth(now: number = Date.now()): HealthReport {
2278
2413
  // --- Orchestrators ---
2279
2414
 
2280
2415
  function rowToOrchestrator(row: any): Orchestrator {
2416
+ const meta = parseJson<Record<string, unknown>>(row.meta, {});
2417
+ const version = stringValue(meta.version);
2418
+ const gitSha = stringValue(meta.gitSha);
2419
+ const protocolRaw = meta.protocolVersion;
2420
+ const protocolVersion = typeof protocolRaw === "number"
2421
+ ? protocolRaw
2422
+ : typeof protocolRaw === "string" && protocolRaw.trim() !== ""
2423
+ ? Number(protocolRaw)
2424
+ : undefined;
2281
2425
  return {
2282
2426
  id: row.id,
2283
2427
  hostname: row.hostname,
@@ -2286,13 +2430,40 @@ function rowToOrchestrator(row: any): Orchestrator {
2286
2430
  providers: parseJson<SpawnProvider[]>(row.providers, []),
2287
2431
  baseDir: row.base_dir,
2288
2432
  envKeys: parseJson<string[]>(row.env_keys, []),
2289
- meta: parseJson(row.meta, {}),
2433
+ ...(version ? { version } : {}),
2434
+ ...(Number.isFinite(protocolVersion) ? { protocolVersion } : {}),
2435
+ ...(gitSha ? { gitSha } : {}),
2436
+ health: orchestratorHealth(version, Number.isFinite(protocolVersion) ? protocolVersion : undefined),
2437
+ meta,
2290
2438
  managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
2291
2439
  lastSeen: row.last_seen,
2292
2440
  createdAt: row.created_at,
2293
2441
  };
2294
2442
  }
2295
2443
 
2444
+ function orchestratorHealth(version: string | undefined, protocolVersion: number | undefined): OrchestratorHealth {
2445
+ const issues: OrchestratorHealth["issues"] = [];
2446
+ if (!version) {
2447
+ issues.push({ code: "missing-version", detail: "Orchestrator did not report a version; restart or upgrade it." });
2448
+ } else if (version !== VERSION) {
2449
+ issues.push({ code: "outdated", detail: `Orchestrator ${version} does not match server ${VERSION}.` });
2450
+ }
2451
+ if (protocolVersion !== ORCHESTRATOR_PROTOCOL_VERSION) {
2452
+ issues.push({
2453
+ code: "protocol-mismatch",
2454
+ detail: `Orchestrator protocol ${protocolVersion ?? "unknown"} does not match server protocol ${ORCHESTRATOR_PROTOCOL_VERSION}.`,
2455
+ });
2456
+ }
2457
+ if (issues.length > 0) {
2458
+ issues.push({ code: "restart-required", detail: "Restart the orchestrator after upgrading Agent Relay." });
2459
+ }
2460
+ return {
2461
+ status: issues.some((issue) => issue.code === "protocol-mismatch") ? "error" : issues.length > 0 ? "warn" : "ok",
2462
+ restartRequired: issues.length > 0,
2463
+ issues,
2464
+ };
2465
+ }
2466
+
2296
2467
  export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
2297
2468
  const now = Date.now();
2298
2469
  const agentId = `orchestrator-${input.id}`;
@@ -2315,7 +2486,12 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
2315
2486
  $providers: JSON.stringify(input.providers),
2316
2487
  $baseDir: input.baseDir,
2317
2488
  $envKeys: JSON.stringify(input.envKeys ?? []),
2318
- $meta: JSON.stringify(input.meta ?? {}),
2489
+ $meta: JSON.stringify({
2490
+ ...(input.meta ?? {}),
2491
+ ...(input.version ? { version: input.version } : {}),
2492
+ ...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
2493
+ ...(input.gitSha ? { gitSha: input.gitSha } : {}),
2494
+ }),
2319
2495
  $now: now,
2320
2496
  });
2321
2497
 
@@ -2327,7 +2503,13 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
2327
2503
  machine: input.hostname,
2328
2504
  capabilities: ["orchestrator", "spawn"],
2329
2505
  status: "online",
2330
- meta: { orchestratorId: input.id, builtin: true },
2506
+ meta: {
2507
+ orchestratorId: input.id,
2508
+ builtin: true,
2509
+ ...(input.version ? { version: input.version } : {}),
2510
+ ...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
2511
+ ...(input.gitSha ? { gitSha: input.gitSha } : {}),
2512
+ },
2331
2513
  });
2332
2514
 
2333
2515
  return getOrchestrator(input.id)!;
package/src/routes.ts CHANGED
@@ -775,6 +775,44 @@ function auditEvent(input: ActivityEventInput): void {
775
775
  }
776
776
  }
777
777
 
778
+ type AgentActivityState = {
779
+ key: "available" | "busy" | "not-ready" | "offline";
780
+ title: string;
781
+ icon: string;
782
+ };
783
+
784
+ function agentActivityState(agent?: Pick<AgentCard, "ready" | "status"> | null): AgentActivityState | null {
785
+ if (!agent) return null;
786
+ if (agent.status === "offline") return { key: "offline", title: "Agent offline", icon: "ti-plug-off" };
787
+ if (!agent.ready) return { key: "not-ready", title: "Agent not ready", icon: "ti-loader" };
788
+ if (agent.status === "busy") return { key: "busy", title: "Agent busy", icon: "ti-activity" };
789
+ return { key: "available", title: "Agent available", icon: "ti-circle-check" };
790
+ }
791
+
792
+ function auditAgentStateTransition(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
793
+ const previous = agentActivityState(before);
794
+ const next = agentActivityState(after);
795
+ if (!next || previous?.key === next.key) return;
796
+ auditEvent({
797
+ clientId: "server-agent-" + agentId + "-state-" + next.key + "-" + Date.now(),
798
+ kind: "state",
799
+ title: next.title,
800
+ meta: agentId,
801
+ icon: next.icon,
802
+ view: "agents",
803
+ agentId,
804
+ });
805
+ }
806
+
807
+ function orchestratorControlMeta(control: Record<string, unknown>): Record<string, unknown> {
808
+ return {
809
+ delivery: "interrupt",
810
+ priority: "urgent",
811
+ // Legacy orchestrators read control details from message meta.
812
+ orchestratorControl: control,
813
+ };
814
+ }
815
+
778
816
  // --- Agent routes ---
779
817
 
780
818
  const postAgent: Handler = async (req) => {
@@ -836,21 +874,14 @@ const patchAgentStatus: Handler = async (req, params) => {
836
874
  const guard = normalizeAgentSessionGuard(req, body);
837
875
  const session = validateAgentSession(params.id!, guard);
838
876
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
877
+ const before = getAgent(params.id!);
839
878
  if (!setStatus(params.id!, body.status as any, guard)) return error("agent not found", 404);
879
+ auditAgentStateTransition(params.id!, before, getAgent(params.id!));
840
880
  } catch (e) {
841
881
  if (e instanceof ValidationError) return error(e.message, 400);
842
882
  throw e;
843
883
  }
844
884
  emitAgentStatus(params.id!);
845
- auditEvent({
846
- clientId: "server-agent-" + params.id! + "-status-" + body.status + "-" + Date.now(),
847
- kind: "state",
848
- title: "Agent " + body.status,
849
- meta: params.id!,
850
- icon: body.status === "offline" ? "ti-plug-off" : "ti-activity",
851
- view: "agents",
852
- agentId: params.id!,
853
- });
854
885
  return json({ ok: true });
855
886
  };
856
887
 
@@ -903,21 +934,14 @@ const patchAgentReady: Handler = async (req, params) => {
903
934
  const guard = normalizeAgentSessionGuard(req, body);
904
935
  const session = validateAgentSession(params.id!, guard);
905
936
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
937
+ const before = getAgent(params.id!);
906
938
  if (!markReady(params.id!, body.ready, guard)) return error("agent not found", 404);
939
+ auditAgentStateTransition(params.id!, before, getAgent(params.id!));
907
940
  } catch (e) {
908
941
  if (e instanceof ValidationError) return error(e.message, 400);
909
942
  throw e;
910
943
  }
911
944
  emitAgentStatus(params.id!);
912
- auditEvent({
913
- clientId: "server-agent-" + params.id! + "-ready-" + body.ready + "-" + Date.now(),
914
- kind: "state",
915
- title: body.ready ? "Agent ready" : "Agent not ready",
916
- meta: params.id!,
917
- icon: body.ready ? "ti-circle-check" : "ti-loader",
918
- view: "agents",
919
- agentId: params.id!,
920
- });
921
945
  return json({ ok: true });
922
946
  };
923
947
 
@@ -1008,17 +1032,15 @@ const postAgentSpawn: Handler = async (req) => {
1008
1032
  if (orchestrators.length > 0) {
1009
1033
  // Route through the first available orchestrator
1010
1034
  const orch = orchestrators[0]!;
1035
+ const control = { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() };
1011
1036
  const msg = sendMessage({
1012
1037
  from: "system",
1013
1038
  to: orch.agentId,
1014
1039
  kind: "control",
1015
1040
  subject: "Spawn agent",
1016
1041
  body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1017
- payload: { orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() } },
1018
- meta: {
1019
- delivery: "interrupt",
1020
- priority: "urgent",
1021
- },
1042
+ payload: { orchestratorControl: control },
1043
+ meta: orchestratorControlMeta(control),
1022
1044
  });
1023
1045
  emitNewMessage(msg);
1024
1046
  auditEvent({
@@ -1104,8 +1126,16 @@ const postOrchestrator: Handler = async (req) => {
1104
1126
  }
1105
1127
  }
1106
1128
  const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
1129
+ const version = cleanString(parsed.body.version, "version", { max: 80 });
1130
+ const gitSha = cleanString(parsed.body.gitSha, "gitSha", { max: 80 });
1131
+ const protocolVersionRaw = parsed.body.protocolVersion;
1132
+ const protocolVersion = protocolVersionRaw === undefined
1133
+ ? undefined
1134
+ : typeof protocolVersionRaw === "number" && Number.isInteger(protocolVersionRaw) && protocolVersionRaw > 0
1135
+ ? protocolVersionRaw
1136
+ : (() => { throw new ValidationError("protocolVersion must be a positive integer"); })();
1107
1137
  const meta = cleanMeta(parsed.body.meta);
1108
- const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, meta });
1138
+ const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, version, protocolVersion, gitSha, meta });
1109
1139
  auditEvent({
1110
1140
  clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
1111
1141
  kind: "state",
@@ -1192,6 +1222,16 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1192
1222
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1193
1223
 
1194
1224
  // Send control message to orchestrator's agent inbox
1225
+ const control = {
1226
+ action: "spawn",
1227
+ provider,
1228
+ cwd: cwd || orch.baseDir,
1229
+ label,
1230
+ approvalMode,
1231
+ prompt,
1232
+ requestedBy: "dashboard",
1233
+ requestedAt: Date.now(),
1234
+ };
1195
1235
  const msg = sendMessage({
1196
1236
  from: "system",
1197
1237
  to: orch.agentId,
@@ -1199,21 +1239,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1199
1239
  subject: "Spawn agent",
1200
1240
  body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1201
1241
  payload: {
1202
- orchestratorControl: {
1203
- action: "spawn",
1204
- provider,
1205
- cwd: cwd || orch.baseDir,
1206
- label,
1207
- approvalMode,
1208
- prompt,
1209
- requestedBy: "dashboard",
1210
- requestedAt: Date.now(),
1211
- },
1212
- },
1213
- meta: {
1214
- delivery: "interrupt",
1215
- priority: "urgent",
1242
+ orchestratorControl: control,
1216
1243
  },
1244
+ meta: orchestratorControlMeta(control),
1217
1245
  });
1218
1246
  emitNewMessage(msg);
1219
1247
  auditEvent({
@@ -1245,6 +1273,12 @@ const postOrchestratorAction: Handler = async (req, params) => {
1245
1273
  if (!action) return error("action required");
1246
1274
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1247
1275
 
1276
+ const control = {
1277
+ action,
1278
+ agentId,
1279
+ requestedBy: "dashboard",
1280
+ requestedAt: Date.now(),
1281
+ };
1248
1282
  const msg = sendMessage({
1249
1283
  from: "system",
1250
1284
  to: orch.agentId,
@@ -1252,17 +1286,9 @@ const postOrchestratorAction: Handler = async (req, params) => {
1252
1286
  subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1253
1287
  body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
1254
1288
  payload: {
1255
- orchestratorControl: {
1256
- action,
1257
- agentId,
1258
- requestedBy: "dashboard",
1259
- requestedAt: Date.now(),
1260
- },
1261
- },
1262
- meta: {
1263
- delivery: "interrupt",
1264
- priority: "urgent",
1289
+ orchestratorControl: control,
1265
1290
  },
1291
+ meta: orchestratorControlMeta(control),
1266
1292
  });
1267
1293
  emitNewMessage(msg);
1268
1294
  auditEvent({
package/src/types.ts CHANGED
@@ -290,6 +290,22 @@ export interface ChannelBinding {
290
290
  updatedAt: number;
291
291
  }
292
292
 
293
+ export interface ChannelTargetHealth {
294
+ status: "ok" | "warning" | "error";
295
+ detail: string;
296
+ target: ChannelRouteTarget;
297
+ matches: Array<{
298
+ id: string;
299
+ name: string;
300
+ status: AgentCard["status"];
301
+ ready: boolean;
302
+ lastSeen: number;
303
+ label?: string;
304
+ tags: string[];
305
+ capabilities: string[];
306
+ }>;
307
+ }
308
+
293
309
  export interface ChannelSummary {
294
310
  id: string;
295
311
  name: string;
@@ -302,6 +318,7 @@ export interface ChannelSummary {
302
318
  direction: ChannelDirection;
303
319
  target?: string;
304
320
  binding?: ChannelBinding;
321
+ targetHealth?: ChannelTargetHealth;
305
322
  topicChannels: string[];
306
323
  capabilities: string[];
307
324
  tags: string[];
@@ -394,12 +411,25 @@ export interface Orchestrator {
394
411
  providers: SpawnProvider[];
395
412
  baseDir: string;
396
413
  envKeys: string[]; // names only, never values
414
+ version?: string;
415
+ protocolVersion?: number;
416
+ gitSha?: string;
417
+ health?: OrchestratorHealth;
397
418
  meta: Record<string, unknown>;
398
419
  managedAgents: ManagedAgent[];
399
420
  lastSeen: number;
400
421
  createdAt: number;
401
422
  }
402
423
 
424
+ export interface OrchestratorHealth {
425
+ status: "ok" | "warn" | "error";
426
+ restartRequired: boolean;
427
+ issues: Array<{
428
+ code: "missing-version" | "outdated" | "protocol-mismatch" | "restart-required";
429
+ detail: string;
430
+ }>;
431
+ }
432
+
403
433
  export interface ManagedAgent {
404
434
  agentId: string;
405
435
  provider: SpawnProvider;
@@ -417,6 +447,9 @@ export interface RegisterOrchestratorInput {
417
447
  providers: SpawnProvider[];
418
448
  baseDir: string;
419
449
  envKeys?: string[];
450
+ version?: string;
451
+ protocolVersion?: number;
452
+ gitSha?: string;
420
453
  meta?: Record<string, unknown>;
421
454
  }
422
455