agent-relay-server 0.9.0 → 0.10.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.
Files changed (44) hide show
  1. package/README.md +12 -14
  2. package/package.json +18 -1
  3. package/public/index.html +979 -2575
  4. package/public/manifest.webmanifest +6 -6
  5. package/public/sw.js +16 -10
  6. package/recipes/code-review.yaml +26 -0
  7. package/recipes/debug.yaml +20 -0
  8. package/recipes/feature.yaml +26 -0
  9. package/recipes/refactor.yaml +20 -0
  10. package/recipes/test.yaml +20 -0
  11. package/runner/src/adapter.ts +69 -0
  12. package/runner/src/config.ts +144 -0
  13. package/scripts/orchestrator-spawn-smoke.ts +2 -9
  14. package/src/agent-spawn.ts +2 -94
  15. package/src/automations.ts +774 -0
  16. package/src/bus-outbox.ts +75 -0
  17. package/src/bus.ts +439 -0
  18. package/src/cli.ts +251 -5
  19. package/src/commands-db.ts +160 -0
  20. package/src/config.ts +1 -1
  21. package/src/connectors.ts +29 -9
  22. package/src/daemon.ts +1 -0
  23. package/src/db.ts +241 -34
  24. package/src/events.ts +33 -0
  25. package/src/index.ts +94 -5
  26. package/src/recipe-db.ts +163 -0
  27. package/src/recipe-loader.ts +100 -0
  28. package/src/recipe-runner.ts +206 -0
  29. package/src/recipe-validator.ts +85 -0
  30. package/src/routes.ts +649 -155
  31. package/src/security.ts +128 -2
  32. package/src/sse.ts +42 -31
  33. package/src/token-db.ts +96 -0
  34. package/src/types.ts +1 -493
  35. package/src/upgrade.ts +14 -28
  36. package/public/dashboard/actions.js +0 -819
  37. package/public/dashboard/api.js +0 -336
  38. package/public/dashboard/app.js +0 -34
  39. package/public/dashboard/charts.js +0 -128
  40. package/public/dashboard/computed.js +0 -693
  41. package/public/dashboard/constants.js +0 -28
  42. package/public/dashboard/display.js +0 -345
  43. package/public/dashboard/state.js +0 -129
  44. package/public/dashboard/utils.js +0 -207
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { join, resolve } from "node:path";
5
6
  import {
6
7
  createDaemonPlan,
@@ -34,6 +35,9 @@ Usage:
34
35
  agent-relay upgrade [--dry-run] [--version VERSION] [--providers auto|all|codex|claude|orchestrator] [--no-restart] [--yes]
35
36
  agent-relay setup upgrade [same options as upgrade]
36
37
  agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
38
+ agent-relay recipe <list|show|start|stop|status> [options]
39
+ agent-relay provider <wrap|unwrap> <claude|codex>
40
+ agent-relay token <create|list|revoke|verify> [options]
37
41
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
38
42
  agent-relay message <target> <body> [options]
39
43
  agent-relay /pair <target|accept|reject|send|status> [...]
@@ -80,6 +84,17 @@ Upgrade options:
80
84
  --no-restart Do not restart agent-relay.service
81
85
  --dry-run Print detected state and planned commands
82
86
  --yes Skip confirmation prompts
87
+
88
+ Recipe examples:
89
+ agent-relay recipe list
90
+ agent-relay recipe start code-review --cwd /repo --orchestrator local
91
+ agent-relay recipe status
92
+ agent-relay recipe stop INSTANCE_ID
93
+
94
+ Token examples:
95
+ agent-relay token create --role orchestrator --sub macmini --ttl 86400
96
+ agent-relay token list
97
+ agent-relay token revoke JTI
83
98
  `.trim();
84
99
 
85
100
  const DAEMON_ACTIONS = new Set<DaemonAction>([
@@ -117,6 +132,18 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
117
132
  await handleDaemonCommand(args.slice(1));
118
133
  return "handled";
119
134
  }
135
+ if (command === "recipe" || command === "recipes") {
136
+ await handleRecipeCommand(args.slice(1));
137
+ return "handled";
138
+ }
139
+ if (command === "provider" || command === "providers") {
140
+ await handleProviderCommand(args.slice(1));
141
+ return "handled";
142
+ }
143
+ if (command === "token" || command === "tokens") {
144
+ await handleTokenCommand(args.slice(1));
145
+ return "handled";
146
+ }
120
147
  if (command === "pair" || command === "/pair" || command === "/disconnect") {
121
148
  await handleSlashOrPairCommand(command, args.slice(1));
122
149
  return "handled";
@@ -142,7 +169,7 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
142
169
  return "handled";
143
170
  }
144
171
  if (command === "/reconnect") {
145
- console.log("Reconnect is handled automatically by provider sidecars; use `agent-relay pair status` to inspect current pair state.");
172
+ console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
146
173
  return "handled";
147
174
  }
148
175
  throw new Error(`Unknown command "${command}". Run agent-relay --help.`);
@@ -211,6 +238,26 @@ function parseUpgradeProvider(value: string): UpgradeProvider {
211
238
  throw new Error(`Unknown upgrade provider "${value}". Expected auto, all, codex, claude, or orchestrator.`);
212
239
  }
213
240
 
241
+ async function handleProviderCommand(args: string[]): Promise<void> {
242
+ const action = args[0];
243
+ const provider = args[1];
244
+ if ((action !== "wrap" && action !== "unwrap") || (provider !== "claude" && provider !== "codex")) {
245
+ throw new Error("Usage: agent-relay provider <wrap|unwrap> <claude|codex>");
246
+ }
247
+ const dir = join(homedir(), ".agent-relay", "bin");
248
+ const shim = join(dir, provider);
249
+ if (action === "wrap") {
250
+ mkdirSync(dir, { recursive: true });
251
+ writeFileSync(shim, `#!/usr/bin/env bash\nexec ${provider}-relay -- "$@"\n`, "utf8");
252
+ chmodSync(shim, 0o755);
253
+ console.log(`Wrapped ${provider}: ${shim}`);
254
+ console.log(`Ensure ${dir} is before the provider binary on PATH.`);
255
+ return;
256
+ }
257
+ if (existsSync(shim)) unlinkSync(shim);
258
+ console.log(`Unwrapped ${provider}: ${shim}`);
259
+ }
260
+
214
261
  async function handleSlashOrPairCommand(command: string, args: string[]): Promise<void> {
215
262
  if (command === "/disconnect") {
216
263
  await handlePairCommand(["hangup", ...args]);
@@ -614,6 +661,134 @@ async function handleTagsCommand(args: string[]): Promise<void> {
614
661
  else console.log(`Tags: ${next.join(", ") || "(none)"}`);
615
662
  }
616
663
 
664
+ async function handleRecipeCommand(args: string[]): Promise<void> {
665
+ const action = args[0] ?? "list";
666
+ const rest = args.slice(1);
667
+ if (action === "list") {
668
+ const json = rest.includes("--json");
669
+ const recipes = await apiRequest("GET", "/api/recipes");
670
+ if (json) console.log(JSON.stringify(recipes, null, 2));
671
+ else console.log(formatRecipes(recipes as any[]));
672
+ return;
673
+ }
674
+ if (action === "show") {
675
+ const name = rest.find((arg) => !arg.startsWith("--"));
676
+ const json = rest.includes("--json");
677
+ if (!name) throw new Error("Usage: agent-relay recipe show NAME [--json]");
678
+ const recipe = await apiRequest("GET", `/api/recipes/${encodeURIComponent(name)}`);
679
+ if (json) console.log(JSON.stringify(recipe, null, 2));
680
+ else console.log(formatRecipe(recipe as any));
681
+ return;
682
+ }
683
+ if (action === "start") {
684
+ const name = rest[0];
685
+ if (!name || name.startsWith("--")) throw new Error("Usage: agent-relay recipe start NAME [--cwd PATH] [--orchestrator ID] [--by NAME] [--json]");
686
+ let cwd: string | undefined;
687
+ let orchestratorId: string | undefined;
688
+ let startedBy: string | undefined;
689
+ let json = false;
690
+ for (let i = 1; i < rest.length; i++) {
691
+ const arg = rest[i];
692
+ if (arg === "--cwd" && i + 1 < rest.length) cwd = rest[++i];
693
+ else if ((arg === "--orchestrator" || arg === "--orchestrator-id") && i + 1 < rest.length) orchestratorId = rest[++i];
694
+ else if (arg === "--by" && i + 1 < rest.length) startedBy = rest[++i];
695
+ else if (arg === "--json") json = true;
696
+ else throw new Error(`Unknown recipe start option "${arg}"`);
697
+ }
698
+ const result = await apiRequest("POST", "/api/recipes/start", { name, cwd, orchestratorId, startedBy });
699
+ if (json) console.log(JSON.stringify(result, null, 2));
700
+ else {
701
+ const payload = result as any;
702
+ console.log(`Recipe ${payload.instance.recipeName} started: ${payload.instance.id} (${payload.commands.length} command(s))`);
703
+ }
704
+ return;
705
+ }
706
+ if (action === "stop") {
707
+ const id = rest[0];
708
+ if (!id || id.startsWith("--")) throw new Error("Usage: agent-relay recipe stop INSTANCE_ID [--by NAME] [--json]");
709
+ let stoppedBy: string | undefined;
710
+ let json = false;
711
+ for (let i = 1; i < rest.length; i++) {
712
+ const arg = rest[i];
713
+ if (arg === "--by" && i + 1 < rest.length) stoppedBy = rest[++i];
714
+ else if (arg === "--json") json = true;
715
+ else throw new Error(`Unknown recipe stop option "${arg}"`);
716
+ }
717
+ const result = await apiRequest("POST", `/api/recipes/instances/${encodeURIComponent(id)}/stop`, { stoppedBy });
718
+ if (json) console.log(JSON.stringify(result, null, 2));
719
+ else {
720
+ const payload = result as any;
721
+ console.log(`Recipe ${payload.instance.recipeName} stopped: ${payload.instance.id} (${payload.commands.length} command(s))`);
722
+ }
723
+ return;
724
+ }
725
+ if (action === "status" || action === "instances") {
726
+ const json = rest.includes("--json");
727
+ const instances = await apiRequest("GET", "/api/recipes/instances");
728
+ if (json) console.log(JSON.stringify(instances, null, 2));
729
+ else console.log(formatRecipeInstances(instances as any[]));
730
+ return;
731
+ }
732
+ throw new Error("Usage: agent-relay recipe <list|show|start|stop|status> [options]");
733
+ }
734
+
735
+ async function handleTokenCommand(args: string[]): Promise<void> {
736
+ const action = args[0] ?? "list";
737
+ const rest = args.slice(1);
738
+ if (action === "list") {
739
+ const json = rest.includes("--json");
740
+ const tokens = await apiRequest("GET", "/api/tokens");
741
+ if (json) console.log(JSON.stringify(tokens, null, 2));
742
+ else console.log(formatTokens(tokens as any[]));
743
+ return;
744
+ }
745
+ if (action === "create") {
746
+ let role: string | undefined;
747
+ let sub: string | undefined;
748
+ let ttlSeconds: number | undefined;
749
+ let json = false;
750
+ let scope: string[] | undefined;
751
+ for (let i = 0; i < rest.length; i++) {
752
+ const arg = rest[i];
753
+ if (arg === "--role" && i + 1 < rest.length) role = rest[++i];
754
+ else if (arg === "--sub" && i + 1 < rest.length) sub = rest[++i];
755
+ else if ((arg === "--scope" || arg === "--scopes") && i + 1 < rest.length) scope = splitTagArgs(rest[++i]!);
756
+ else if ((arg === "--ttl" || arg === "--ttl-seconds") && i + 1 < rest.length) ttlSeconds = parseInt(rest[++i]!, 10);
757
+ else if (arg === "--json") json = true;
758
+ else throw new Error(`Unknown token create option "${arg}"`);
759
+ }
760
+ if (!role) throw new Error("Usage: agent-relay token create --role ROLE [--sub SUBJECT] [--scope a,b] [--ttl SECONDS]");
761
+ const result = await apiRequest("POST", "/api/tokens", { role, sub, scope, ttlSeconds, createdBy: "cli" });
762
+ if (json) console.log(JSON.stringify(result, null, 2));
763
+ else {
764
+ const payload = result as any;
765
+ console.log(payload.token);
766
+ console.error(`Issued ${payload.record.role} token ${payload.record.jti} for ${payload.record.sub}`);
767
+ }
768
+ return;
769
+ }
770
+ if (action === "revoke") {
771
+ const jti = rest.find((arg) => !arg.startsWith("--"));
772
+ if (!jti) throw new Error("Usage: agent-relay token revoke JTI");
773
+ await apiRequest("POST", `/api/tokens/${encodeURIComponent(jti)}/revoke`, {});
774
+ console.log(`Token revoked: ${jti}`);
775
+ return;
776
+ }
777
+ if (action === "verify") {
778
+ const token = rest.find((arg) => !arg.startsWith("--")) ?? process.env.AGENT_RELAY_TOKEN;
779
+ if (!token) throw new Error("Usage: agent-relay token verify TOKEN");
780
+ const payload = decodeJwtPayload(token);
781
+ if (!payload) throw new Error("not a component JWT");
782
+ let record: unknown = null;
783
+ if (typeof payload.jti === "string") {
784
+ record = await apiRequest("GET", `/api/tokens/${encodeURIComponent(payload.jti)}`).catch(() => null);
785
+ }
786
+ console.log(JSON.stringify({ payload, record }, null, 2));
787
+ return;
788
+ }
789
+ throw new Error("Usage: agent-relay token <create|list|revoke|verify> [options]");
790
+ }
791
+
617
792
  async function apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
618
793
  const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
619
794
  const headers: Record<string, string> = {};
@@ -687,13 +862,14 @@ function currentAgentContextId(): string | undefined {
687
862
  const explicitPath = process.env.AGENT_RELAY_CONTEXT_PATH;
688
863
  if (explicitPath) {
689
864
  const explicit = readAgentContext(explicitPath);
690
- if (explicit?.agentId) return explicit.agentId;
865
+ if (explicit?.agentId && contextMatchesCurrentProcess(explicit)) return explicit.agentId;
691
866
  }
692
867
 
693
868
  const candidates = collectAgentContextFiles();
694
869
  const matches = candidates
695
870
  .map((path) => readAgentContext(path))
696
- .filter((context): context is { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } => Boolean(context))
871
+ .filter((context): context is AgentContextState => Boolean(context))
872
+ .filter((context) => contextMatchesCurrentProcess(context))
697
873
  .filter((context) => context.matchEnv.some((match) => process.env[match.name] === match.value))
698
874
  .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
699
875
 
@@ -701,11 +877,23 @@ function currentAgentContextId(): string | undefined {
701
877
  return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
702
878
  }
703
879
 
704
- function readAgentContext(path: string): { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } | null {
880
+ interface AgentContextState {
881
+ agentId: string;
882
+ cwd?: string;
883
+ updatedAtMs: number;
884
+ matchEnv: Array<{ name: string; value: string }>;
885
+ }
886
+
887
+ function contextMatchesCurrentProcess(context: AgentContextState): boolean {
888
+ return !context.cwd || context.cwd === process.cwd();
889
+ }
890
+
891
+ function readAgentContext(path: string): AgentContextState | null {
705
892
  if (!existsSync(path)) return null;
706
893
  try {
707
894
  const parsed = JSON.parse(readFileSync(path, "utf8")) as {
708
895
  agentId?: unknown;
896
+ cwd?: unknown;
709
897
  updatedAt?: unknown;
710
898
  matchEnv?: unknown;
711
899
  };
@@ -723,6 +911,7 @@ function readAgentContext(path: string): { agentId: string; updatedAtMs: number;
723
911
  const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
724
912
  return {
725
913
  agentId: parsed.agentId,
914
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
726
915
  matchEnv,
727
916
  updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
728
917
  };
@@ -834,6 +1023,63 @@ function formatStatus(payload: any): string {
834
1023
  ].join("\n");
835
1024
  }
836
1025
 
1026
+ function formatRecipes(recipes: any[]): string {
1027
+ if (!recipes.length) return "No recipes.";
1028
+ return recipes
1029
+ .map((entry) => {
1030
+ const recipe = entry.recipe ?? {};
1031
+ const agents = Array.isArray(recipe.agents)
1032
+ ? recipe.agents.map((agent: any) => `${agent.count ?? 1}x ${agent.provider}:${agent.role}`).join(", ")
1033
+ : "no agents";
1034
+ return `${entry.name} ${entry.source} ${agents} ${recipe.description ?? ""}`.trim();
1035
+ })
1036
+ .join("\n");
1037
+ }
1038
+
1039
+ function formatRecipe(entry: any): string {
1040
+ const recipe = entry.recipe ?? {};
1041
+ const agents = Array.isArray(recipe.agents)
1042
+ ? recipe.agents.map((agent: any) => ` - ${agent.count ?? 1}x ${agent.provider}:${agent.role}`).join("\n")
1043
+ : " (none)";
1044
+ return [
1045
+ `${entry.name} (${entry.source})`,
1046
+ recipe.description,
1047
+ "Agents:",
1048
+ agents,
1049
+ ].filter(Boolean).join("\n");
1050
+ }
1051
+
1052
+ function formatRecipeInstances(instances: any[]): string {
1053
+ if (!instances.length) return "No recipe instances.";
1054
+ return instances
1055
+ .map((instance) => {
1056
+ const agents = Array.isArray(instance.agents) ? instance.agents.length : 0;
1057
+ return `${instance.id} ${instance.status} ${instance.recipeName} agents=${agents} cwd=${instance.cwd}`;
1058
+ })
1059
+ .join("\n");
1060
+ }
1061
+
1062
+ function formatTokens(tokens: any[]): string {
1063
+ if (!tokens.length) return "No component tokens.";
1064
+ return tokens
1065
+ .map((token) => {
1066
+ const state = token.revokedAt ? "revoked" : token.expiresAt && token.expiresAt <= Math.floor(Date.now() / 1000) ? "expired" : "active";
1067
+ return `${token.jti} ${state} ${token.role} ${token.sub} ${(token.scope ?? []).join(",")}`;
1068
+ })
1069
+ .join("\n");
1070
+ }
1071
+
1072
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
1073
+ const payload = token.split(".")[1];
1074
+ if (!payload) return null;
1075
+ try {
1076
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1077
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1078
+ } catch {
1079
+ return null;
1080
+ }
1081
+ }
1082
+
837
1083
  async function confirm(message: string): Promise<boolean> {
838
1084
  if (!input.isTTY) return false;
839
1085
  const rl = createInterface({ input, output });
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb } from "./db";
3
+ import type { Command, CommandStatus, CreateCommandInput, UpdateCommandInput } from "./types";
4
+
5
+ interface CommandRow {
6
+ id: string;
7
+ type: string;
8
+ source: string;
9
+ target: string;
10
+ params: string;
11
+ status: CommandStatus;
12
+ result: string | null;
13
+ error: string | null;
14
+ correlation_id: string | null;
15
+ created_at: number;
16
+ updated_at: number;
17
+ expires_at: number | null;
18
+ }
19
+
20
+ interface CommandFilters {
21
+ target?: string;
22
+ status?: CommandStatus;
23
+ type?: string;
24
+ since?: number;
25
+ limit?: number;
26
+ }
27
+
28
+ const ACTIVE_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
29
+
30
+ export function createCommand(input: CreateCommandInput): Command {
31
+ const now = Date.now();
32
+ const command: Command = {
33
+ id: randomUUID(),
34
+ type: input.type,
35
+ source: input.source,
36
+ target: input.target,
37
+ params: input.params ?? {},
38
+ status: "pending",
39
+ correlationId: input.correlationId,
40
+ createdAt: now,
41
+ updatedAt: now,
42
+ expiresAt: input.ttlMs ? now + input.ttlMs : defaultExpiresAt(input.type, now),
43
+ };
44
+ getDb().prepare(`
45
+ INSERT INTO commands (id, type, source, target, params, status, correlation_id, created_at, updated_at, expires_at)
46
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
47
+ `).run(
48
+ command.id,
49
+ command.type,
50
+ command.source,
51
+ command.target,
52
+ JSON.stringify(command.params),
53
+ command.status,
54
+ command.correlationId ?? null,
55
+ command.createdAt,
56
+ command.updatedAt,
57
+ command.expiresAt ?? null,
58
+ );
59
+ return command;
60
+ }
61
+
62
+ export function getCommand(id: string): Command | null {
63
+ const row = getDb().prepare("SELECT * FROM commands WHERE id = ?").get(id) as CommandRow | undefined;
64
+ return row ? rowToCommand(row) : null;
65
+ }
66
+
67
+ export function listCommands(filters: CommandFilters = {}): Command[] {
68
+ const clauses: string[] = [];
69
+ const params: unknown[] = [];
70
+ if (filters.target) {
71
+ clauses.push("target = ?");
72
+ params.push(filters.target);
73
+ }
74
+ if (filters.status) {
75
+ clauses.push("status = ?");
76
+ params.push(filters.status);
77
+ }
78
+ if (filters.type) {
79
+ clauses.push("type = ?");
80
+ params.push(filters.type);
81
+ }
82
+ if (filters.since !== undefined) {
83
+ clauses.push("created_at >= ?");
84
+ params.push(filters.since);
85
+ }
86
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
87
+ const limit = Math.min(Math.max(filters.limit ?? 100, 1), 500);
88
+ const bindings = [...params, limit] as any[];
89
+ const rows = getDb()
90
+ .prepare(`SELECT * FROM commands ${where} ORDER BY created_at DESC LIMIT ?`)
91
+ .all(...bindings) as CommandRow[];
92
+ return rows.map(rowToCommand);
93
+ }
94
+
95
+ export function updateCommand(id: string, input: UpdateCommandInput): Command | null {
96
+ const existing = getCommand(id);
97
+ if (!existing) return null;
98
+ const nextStatus = input.status ?? existing.status;
99
+ const now = Date.now();
100
+ getDb().prepare(`
101
+ UPDATE commands
102
+ SET status = ?, result = ?, error = ?, updated_at = ?
103
+ WHERE id = ?
104
+ `).run(
105
+ nextStatus,
106
+ input.result !== undefined ? JSON.stringify(input.result) : existing.result ? JSON.stringify(existing.result) : null,
107
+ input.error ?? existing.error ?? null,
108
+ now,
109
+ id,
110
+ );
111
+ return getCommand(id);
112
+ }
113
+
114
+ export function deleteCommand(id: string): boolean {
115
+ const existing = getCommand(id);
116
+ if (!existing) return false;
117
+ if (!["pending", "accepted"].includes(existing.status)) return false;
118
+ updateCommand(id, { status: "canceled", error: "canceled" });
119
+ return true;
120
+ }
121
+
122
+ export function expireCommands(now: number = Date.now()): Command[] {
123
+ const rows = getDb()
124
+ .prepare(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${ACTIVE_STATUSES.map(() => "?").join(",")})`)
125
+ .all(now, ...ACTIVE_STATUSES) as CommandRow[];
126
+ for (const row of rows) updateCommand(row.id, { status: "timed_out", error: "command timed out" });
127
+ return rows.map((row) => getCommand(row.id)).filter((command): command is Command => Boolean(command));
128
+ }
129
+
130
+ function defaultExpiresAt(type: string, now: number): number | undefined {
131
+ if (type === "agent.spawn") return now + 60_000;
132
+ if (type === "agent.shutdown" || type === "agent.restart" || type === "agent.kill") return now + 30_000;
133
+ return undefined;
134
+ }
135
+
136
+ function rowToCommand(row: CommandRow): Command {
137
+ return {
138
+ id: row.id,
139
+ type: row.type,
140
+ source: row.source,
141
+ target: row.target,
142
+ params: parseObject(row.params),
143
+ status: row.status,
144
+ result: row.result ? parseObject(row.result) : undefined,
145
+ error: row.error ?? undefined,
146
+ correlationId: row.correlation_id ?? undefined,
147
+ createdAt: row.created_at,
148
+ updatedAt: row.updated_at,
149
+ expiresAt: row.expires_at ?? undefined,
150
+ };
151
+ }
152
+
153
+ function parseObject(raw: string): Record<string, unknown> {
154
+ try {
155
+ const parsed = JSON.parse(raw);
156
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
157
+ } catch {
158
+ return {};
159
+ }
160
+ }
package/src/config.ts CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
9
9
  export const VERSION: string = pkg.version;
10
- export const ORCHESTRATOR_PROTOCOL_VERSION = 2;
10
+ export const ORCHESTRATOR_PROTOCOL_VERSION = 3;
11
11
 
12
12
  export const DAY_MS = 86_400_000;
13
13
  function envPositiveInt(name: string, fallback: number): number {
package/src/connectors.ts CHANGED
@@ -7,6 +7,7 @@ import { ValidationError } from "./db";
7
7
  const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
8
8
  const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
9
9
  const VALID_ACTIONS = new Set(["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"]);
10
+ const DEFAULT_ACTION_TIMEOUT_MS = 30_000;
10
11
 
11
12
  function connectorRegistryDir(): string {
12
13
  const configured = process.env.AGENT_RELAY_CONNECTORS_DIR;
@@ -173,31 +174,50 @@ export function writeConnectorConfig(id: string, config: Record<string, unknown>
173
174
  return config;
174
175
  }
175
176
 
176
- export function runConnectorAction(id: string, action: ConnectorAction): ConnectorActionResult {
177
+ function connectorActionTimeoutMs(): number {
178
+ const raw = Number(process.env.AGENT_RELAY_CONNECTOR_ACTION_TIMEOUT_MS);
179
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_ACTION_TIMEOUT_MS;
180
+ }
181
+
182
+ export async function runConnectorAction(id: string, action: ConnectorAction): Promise<ConnectorActionResult> {
177
183
  if (!VALID_ACTIONS.has(action)) throw new ValidationError("unsupported connector action");
178
184
  const connector = getConnector(id);
179
185
  if (!connector) throw new ValidationError(`connector ${id} not found`);
180
186
  const command = connector.manifest.commands[action];
181
187
  if (!command?.length) throw new ValidationError(`connector ${id} does not support ${action}`);
182
188
 
183
- const proc = Bun.spawnSync({
189
+ const proc = Bun.spawn({
184
190
  cmd: command,
185
191
  stdout: "pipe",
186
192
  stderr: "pipe",
187
193
  env: { ...process.env, AGENT_RELAY_CONNECTOR_ID: id, AGENT_RELAY_CONNECTORS_DIR: connectorRegistryDir() },
188
194
  });
189
- const stdout = new TextDecoder().decode(proc.stdout).trim();
190
- const stderr = new TextDecoder().decode(proc.stderr).trim();
191
- const parsed = parseJsonMaybe(stdout);
192
- const ok = proc.success;
195
+
196
+ let timedOut = false;
197
+ const timeout = setTimeout(() => {
198
+ timedOut = true;
199
+ proc.kill();
200
+ }, connectorActionTimeoutMs());
201
+
202
+ const [exitCode, stdout, stderr] = await Promise.all([
203
+ proc.exited,
204
+ new Response(proc.stdout).text(),
205
+ new Response(proc.stderr).text(),
206
+ ]);
207
+ clearTimeout(timeout);
208
+
209
+ const trimmedStdout = stdout.trim();
210
+ const trimmedStderr = stderr.trim();
211
+ const parsed = parseJsonMaybe(trimmedStdout);
212
+ const ok = exitCode === 0 && !timedOut;
193
213
  const result: ConnectorActionResult = {
194
214
  connectorId: id,
195
215
  action,
196
216
  command,
197
217
  ok,
198
- exitCode: proc.exitCode,
199
- stdout,
200
- stderr,
218
+ exitCode,
219
+ stdout: trimmedStdout,
220
+ stderr: timedOut ? [trimmedStderr, `connector action timed out after ${connectorActionTimeoutMs()}ms`].filter(Boolean).join("\n") : trimmedStderr,
201
221
  parsed,
202
222
  };
203
223
  if ((action === "status" || action === "doctor") && ok) {
package/src/daemon.ts CHANGED
@@ -372,6 +372,7 @@ function systemdServicePath(): string {
372
372
  dirname(process.execPath),
373
373
  join(homedir(), ".bun", "bin"),
374
374
  join(homedir(), ".npm-global", "bin"),
375
+ join(homedir(), ".local", "bin"),
375
376
  "/usr/local/sbin",
376
377
  "/usr/local/bin",
377
378
  "/usr/sbin",