agent-relay-server 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -2,9 +2,9 @@
2
2
  "name": "Agent Relay",
3
3
  "short_name": "Relay",
4
4
  "description": "Local control panel for Agent Relay agents, channels, tasks, and messages.",
5
- "id": "/",
6
- "start_url": "/",
7
- "scope": "/",
5
+ "id": "./",
6
+ "start_url": "./",
7
+ "scope": "./",
8
8
  "display": "standalone",
9
9
  "background_color": "#0d1117",
10
10
  "theme_color": "#0d1117",
@@ -12,19 +12,19 @@
12
12
  "categories": ["developer", "productivity", "utilities"],
13
13
  "icons": [
14
14
  {
15
- "src": "/icons/agent-relay.svg",
15
+ "src": "icons/agent-relay.svg",
16
16
  "sizes": "any",
17
17
  "type": "image/svg+xml",
18
18
  "purpose": "any maskable"
19
19
  },
20
20
  {
21
- "src": "/icons/agent-relay-192.png",
21
+ "src": "icons/agent-relay-192.png",
22
22
  "sizes": "192x192",
23
23
  "type": "image/png",
24
24
  "purpose": "any maskable"
25
25
  },
26
26
  {
27
- "src": "/icons/agent-relay-512.png",
27
+ "src": "icons/agent-relay-512.png",
28
28
  "sizes": "512x512",
29
29
  "type": "image/png",
30
30
  "purpose": "any maskable"
package/public/sw.js CHANGED
@@ -1,12 +1,14 @@
1
1
  const CACHE_NAME = "agent-relay-dashboard-v1";
2
+ const scopeUrl = new URL(self.registration.scope);
3
+ const scopePath = scopeUrl.pathname.endsWith("/") ? scopeUrl.pathname : `${scopeUrl.pathname}/`;
4
+ const appUrl = (path) => new URL(path, self.registration.scope).toString();
2
5
  const APP_SHELL = [
3
- "/",
4
- "/index.html",
5
- "/dashboard.js",
6
- "/manifest.webmanifest",
7
- "/icons/agent-relay.svg",
8
- "/icons/agent-relay-192.png",
9
- "/icons/agent-relay-512.png",
6
+ appUrl("./"),
7
+ appUrl("index.html"),
8
+ appUrl("manifest.webmanifest"),
9
+ appUrl("icons/agent-relay.svg"),
10
+ appUrl("icons/agent-relay-192.png"),
11
+ appUrl("icons/agent-relay-512.png"),
10
12
  ];
11
13
 
12
14
  self.addEventListener("install", (event) => {
@@ -31,7 +33,11 @@ self.addEventListener("fetch", (event) => {
31
33
  const request = event.request;
32
34
  const url = new URL(request.url);
33
35
 
34
- if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) {
36
+ if (request.method !== "GET" || url.origin !== self.location.origin || !url.pathname.startsWith(scopePath)) {
37
+ return;
38
+ }
39
+
40
+ if (url.pathname.startsWith(`${scopePath}api/`)) {
35
41
  return;
36
42
  }
37
43
 
@@ -42,7 +48,7 @@ self.addEventListener("fetch", (event) => {
42
48
  event.respondWith(
43
49
  fetch(request)
44
50
  .then((response) => {
45
- if (response.ok && APP_SHELL.includes(url.pathname === "/" ? "/" : url.pathname)) {
51
+ if (response.ok && APP_SHELL.includes(url.href)) {
46
52
  const copy = response.clone();
47
53
  caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
48
54
  }
@@ -51,7 +57,7 @@ self.addEventListener("fetch", (event) => {
51
57
  .catch(async () => {
52
58
  const cached = await caches.match(request);
53
59
  if (cached) return cached;
54
- if (request.mode === "navigate") return caches.match("/index.html");
60
+ if (request.mode === "navigate") return caches.match(appUrl("index.html"));
55
61
  throw new Error("offline");
56
62
  }),
57
63
  );
@@ -0,0 +1,26 @@
1
+ name: Code Review
2
+ description: Three agents review code from architecture, security, and test angles.
3
+ version: "1.0"
4
+ agents:
5
+ - role: lead-reviewer
6
+ provider: claude
7
+ capabilities: [review, architecture]
8
+ label: lead reviewer
9
+ approvalMode: guarded
10
+ prompt: Review architecture, maintainability, and release risk.
11
+ - role: security-reviewer
12
+ provider: claude
13
+ capabilities: [review, security]
14
+ label: security reviewer
15
+ approvalMode: guarded
16
+ prompt: Review authentication, authorization, secrets, and data handling.
17
+ - role: test-reviewer
18
+ provider: codex
19
+ capabilities: [review, testing]
20
+ label: test reviewer
21
+ approvalMode: guarded
22
+ prompt: Review test coverage and failure modes.
23
+ workflow:
24
+ trigger: cap:review
25
+ fanOut: all
26
+ collect: lead-reviewer
@@ -0,0 +1,20 @@
1
+ name: Debug
2
+ description: Investigator and fixer agents isolate and patch a bug.
3
+ version: "1.0"
4
+ agents:
5
+ - role: investigator
6
+ provider: claude
7
+ capabilities: [debug, analysis]
8
+ label: bug investigator
9
+ approvalMode: guarded
10
+ prompt: Reproduce the bug and identify the failing path.
11
+ - role: fixer
12
+ provider: codex
13
+ capabilities: [debug, code]
14
+ label: bug fixer
15
+ approvalMode: guarded
16
+ prompt: Implement the smallest safe fix and verify it.
17
+ workflow:
18
+ trigger: cap:debug
19
+ fanOut: first
20
+ collect: investigator
@@ -0,0 +1,26 @@
1
+ name: Feature
2
+ description: Planner plus two implementers for feature work.
3
+ version: "1.0"
4
+ agents:
5
+ - role: planner
6
+ provider: claude
7
+ capabilities: [planning, architecture]
8
+ label: feature planner
9
+ approvalMode: read-only
10
+ prompt: Map the change and split implementation safely.
11
+ - role: implementer-a
12
+ provider: codex
13
+ capabilities: [implementation]
14
+ label: implementer a
15
+ approvalMode: guarded
16
+ prompt: Implement the assigned feature slice.
17
+ - role: implementer-b
18
+ provider: codex
19
+ capabilities: [implementation]
20
+ label: implementer b
21
+ approvalMode: guarded
22
+ prompt: Implement an independent feature slice.
23
+ workflow:
24
+ trigger: cap:feature
25
+ fanOut: all
26
+ collect: planner
@@ -0,0 +1,20 @@
1
+ name: Refactor
2
+ description: Analyzer and refactorer agents improve structure safely.
3
+ version: "1.0"
4
+ agents:
5
+ - role: analyzer
6
+ provider: claude
7
+ capabilities: [refactor, analysis]
8
+ label: refactor analyzer
9
+ approvalMode: read-only
10
+ prompt: Map dependents and identify safe refactor boundaries.
11
+ - role: refactorer
12
+ provider: codex
13
+ capabilities: [refactor, implementation]
14
+ label: refactorer
15
+ approvalMode: guarded
16
+ prompt: Apply the refactor and keep behavior unchanged.
17
+ workflow:
18
+ trigger: cap:refactor
19
+ fanOut: first
20
+ collect: analyzer
@@ -0,0 +1,20 @@
1
+ name: Test
2
+ description: Test writer and verifier agents improve coverage and run checks.
3
+ version: "1.0"
4
+ agents:
5
+ - role: test-writer
6
+ provider: codex
7
+ capabilities: [testing, implementation]
8
+ label: test writer
9
+ approvalMode: guarded
10
+ prompt: Add focused tests for the target behavior.
11
+ - role: verifier
12
+ provider: claude
13
+ capabilities: [testing, review]
14
+ label: test verifier
15
+ approvalMode: read-only
16
+ prompt: Verify tests are meaningful and not overfit.
17
+ workflow:
18
+ trigger: cap:test
19
+ fanOut: all
20
+ collect: verifier
@@ -0,0 +1,69 @@
1
+ import type { Message } from "agent-relay-sdk";
2
+
3
+ export type SemanticStatus = "idle" | "busy" | "offline" | "error";
4
+
5
+ export interface ProviderConfig {
6
+ command: string;
7
+ defaultArgs: string[];
8
+ env: Record<string, string>;
9
+ pluginDirs: string[];
10
+ defaultCapabilities: string[];
11
+ defaultApprovalMode: string;
12
+ defaultTags: string[];
13
+ headless: {
14
+ tmuxPrefix: string;
15
+ shutdownTimeoutMs: number;
16
+ };
17
+ }
18
+
19
+ export interface RunnerSpawnConfig {
20
+ provider: string;
21
+ runnerId: string;
22
+ instanceId: string;
23
+ agentId: string;
24
+ relayUrl: string;
25
+ cwd: string;
26
+ headless: boolean;
27
+ approvalMode: string;
28
+ label?: string;
29
+ prompt?: string;
30
+ providerArgs: string[];
31
+ providerConfig: ProviderConfig;
32
+ env: Record<string, string>;
33
+ controlPort: number;
34
+ monitor?: {
35
+ deliver(messages: Message[]): Promise<number[]>;
36
+ };
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface SpawnArgs {
41
+ command: string;
42
+ args: string[];
43
+ cwd: string;
44
+ env: Record<string, string>;
45
+ }
46
+
47
+ export interface ManagedProcess {
48
+ pid?: number;
49
+ process?: Bun.Subprocess;
50
+ meta?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface ProviderAdapter {
54
+ provider: string;
55
+ spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
56
+ shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
57
+ deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
58
+ onStatusChange(cb: (status: SemanticStatus) => void): void;
59
+ buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs;
60
+ }
61
+
62
+ export function providerMessageText(messages: Message[]): string {
63
+ return messages
64
+ .map((message) => {
65
+ const subject = message.subject ? `Subject: ${message.subject}\n` : "";
66
+ return `[relay message #${message.id} from ${message.from}]\n${subject}${message.body}`;
67
+ })
68
+ .join("\n\n");
69
+ }
@@ -0,0 +1,144 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir, hostname } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import type { ProviderConfig } from "./adapter";
5
+
6
+ interface GlobalRunnerConfig {
7
+ relayUrl: string;
8
+ token?: string;
9
+ defaultCwd: string;
10
+ }
11
+
12
+ interface LoadedProviderConfig extends ProviderConfig {
13
+ path: string;
14
+ }
15
+
16
+ const DEFAULT_RELAY_URL = "http://127.0.0.1:4850";
17
+
18
+ function agentRelayHome(): string {
19
+ return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
20
+ }
21
+
22
+ function providersDir(home = agentRelayHome()): string {
23
+ return join(home, "providers");
24
+ }
25
+
26
+ export function defaultProviderConfig(provider: string): ProviderConfig {
27
+ const command = provider === "claude" ? "claude-rig" : provider;
28
+ return {
29
+ command,
30
+ defaultArgs: provider === "claude" ? ["--dangerously-skip-permissions"] : [],
31
+ env: {},
32
+ pluginDirs: [],
33
+ defaultCapabilities: ["chat", "code", "review"],
34
+ defaultApprovalMode: "guarded",
35
+ defaultTags: [],
36
+ headless: {
37
+ tmuxPrefix: `${provider}-relay`,
38
+ shutdownTimeoutMs: 10_000,
39
+ },
40
+ };
41
+ }
42
+
43
+ export function loadGlobalConfig(home = agentRelayHome()): GlobalRunnerConfig {
44
+ const path = join(home, "config.json");
45
+ const parsed = readJson(path);
46
+ return {
47
+ relayUrl: stringValue(parsed.relayUrl) ?? process.env.AGENT_RELAY_URL ?? DEFAULT_RELAY_URL,
48
+ token: stringValue(parsed.token) ?? process.env.AGENT_RELAY_TOKEN,
49
+ defaultCwd: stringValue(parsed.defaultCwd) ?? process.cwd(),
50
+ };
51
+ }
52
+
53
+ export function loadProviderConfig(provider: string, home = agentRelayHome()): LoadedProviderConfig {
54
+ const path = join(providersDir(home), `${provider}.json`);
55
+ const raw = readJson(path);
56
+ const defaults = defaultProviderConfig(provider);
57
+ return {
58
+ path,
59
+ command: stringValue(raw.command) ?? defaults.command,
60
+ defaultArgs: stringArray(raw.defaultArgs) ?? defaults.defaultArgs,
61
+ env: stringRecord(raw.env) ?? defaults.env,
62
+ pluginDirs: stringArray(raw.pluginDirs) ?? defaults.pluginDirs,
63
+ defaultCapabilities: stringArray(raw.defaultCapabilities) ?? defaults.defaultCapabilities,
64
+ defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
65
+ defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
66
+ headless: {
67
+ tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
68
+ shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
69
+ },
70
+ };
71
+ }
72
+
73
+ export function writeProviderConfig(provider: string, config: ProviderConfig, home = agentRelayHome()): LoadedProviderConfig {
74
+ const dir = providersDir(home);
75
+ mkdirSync(dir, { recursive: true });
76
+ const path = join(dir, `${provider}.json`);
77
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
78
+ return { ...config, path };
79
+ }
80
+
81
+ export function providerConfigPublic(config: LoadedProviderConfig): Record<string, unknown> {
82
+ return {
83
+ path: config.path,
84
+ command: config.command,
85
+ defaultArgs: config.defaultArgs,
86
+ env: maskEnv(config.env),
87
+ pluginDirs: config.pluginDirs,
88
+ defaultCapabilities: config.defaultCapabilities,
89
+ defaultApprovalMode: config.defaultApprovalMode,
90
+ defaultTags: config.defaultTags,
91
+ headless: config.headless,
92
+ };
93
+ }
94
+
95
+ export function runnerId(provider: string, cwd: string, label?: string): string {
96
+ const project = cwd.split("/").filter(Boolean).at(-1) || "workspace";
97
+ const cleanLabel = (label || project).replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
98
+ return `${hostname()}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
99
+ }
100
+
101
+ export function resolveCwd(value: string | undefined, fallback: string): string {
102
+ return resolve(value || fallback);
103
+ }
104
+
105
+ function readJson(path: string): Record<string, unknown> {
106
+ if (!existsSync(path)) return {};
107
+ try {
108
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
109
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ function stringValue(value: unknown): string | undefined {
116
+ return typeof value === "string" && value.length > 0 ? value : undefined;
117
+ }
118
+
119
+ function positiveInteger(value: unknown): number | undefined {
120
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
121
+ }
122
+
123
+ function stringArray(value: unknown): string[] | undefined {
124
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
125
+ }
126
+
127
+ function stringRecord(value: unknown): Record<string, string> | undefined {
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
129
+ const entries = Object.entries(value);
130
+ if (entries.some(([, item]) => typeof item !== "string")) return undefined;
131
+ return Object.fromEntries(entries) as Record<string, string>;
132
+ }
133
+
134
+ function recordValue(value: unknown): Record<string, unknown> {
135
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
136
+ }
137
+
138
+ function maskEnv(env: Record<string, string>): Record<string, string> {
139
+ const result: Record<string, string> = {};
140
+ for (const [key, value] of Object.entries(env)) {
141
+ result[key] = /token|secret|key|password/i.test(key) && !value.startsWith("$env:") ? "********" : value;
142
+ }
143
+ return result;
144
+ }
@@ -115,20 +115,13 @@ for (const provider of providers) {
115
115
  });
116
116
  console.log(`registered ${provider}: ${agent.id}`);
117
117
 
118
- const managed = await waitFor(`waiting for ${provider} managed session`, async () => {
118
+ await waitFor(`waiting for ${provider} managed session`, async () => {
119
119
  const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
120
120
  return latest?.managedAgents?.find((entry) => entry.label === label || entry.tmuxSession.includes(label)) || null;
121
121
  });
122
122
 
123
- await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/actions`, {
123
+ await api("POST", `/agents/${encodeURIComponent(agent.id)}/actions`, {
124
124
  action: "shutdown",
125
- agentId: managed.tmuxSession,
126
- });
127
-
128
- await waitFor(`waiting for ${provider} managed session shutdown`, async () => {
129
- const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
130
- const stillManaged = latest?.managedAgents?.some((managed) => managed.label === label || managed.tmuxSession.includes(label));
131
- return stillManaged ? null : true;
132
125
  });
133
126
  await waitFor(`waiting for ${provider} agent cleanup`, async () => {
134
127
  const current = await apiOptional<Agent>("GET", `/agents/${encodeURIComponent(agent.id)}`);
@@ -1,27 +1,6 @@
1
- import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, delimiter, dirname, join, resolve } from "node:path";
4
-
5
- export type CodexSpawnApprovalMode = "open" | "guarded" | "read-only";
6
-
7
- interface CodexSpawnInput {
8
- cwd?: string;
9
- approvalMode: CodexSpawnApprovalMode;
10
- label?: string;
11
- relayUrl: string;
12
- token?: string;
13
- dryRun?: boolean;
14
- }
15
-
16
- interface CodexSpawnResult {
17
- provider: "codex";
18
- pid?: number;
19
- cwd: string;
20
- approvalMode: CodexSpawnApprovalMode;
21
- logPath: string;
22
- command: string[];
23
- dryRun?: boolean;
24
- }
3
+ import { dirname, join, resolve } from "node:path";
25
4
 
26
5
  interface HostDirectoryEntry {
27
6
  name: string;
@@ -64,74 +43,3 @@ export function listHostDirectories(raw: string | undefined): HostDirectoryListi
64
43
  entries,
65
44
  };
66
45
  }
67
-
68
- export function codexSpawnCommand(): string[] {
69
- const override = process.env.AGENT_RELAY_CODEX_RELAY_BIN;
70
- if (override) return [override];
71
-
72
- const repoLauncher = resolve(import.meta.dir, "../codex/bin/agent-relay-codex.ts");
73
- if (existsSync(repoLauncher)) return ["bun", "run", repoLauncher, "start"];
74
-
75
- const fromPath = findOnPath("codex-relay");
76
- if (fromPath) return [fromPath];
77
-
78
- return ["codex-relay"];
79
- }
80
-
81
- export function codexSpawnLogPath(cwd: string, now = Date.now()): string {
82
- const project = basename(cwd).replace(/[^a-zA-Z0-9._-]+/g, "-") || "project";
83
- return join(homedir(), ".agent-relay", "spawns", `codex-${project}-${now}.log`);
84
- }
85
-
86
- export function spawnCodexAgent(input: CodexSpawnInput): CodexSpawnResult {
87
- const cwd = normalizeCodexSpawnCwd(input.cwd);
88
- const command = [
89
- ...codexSpawnCommand(),
90
- "--headless",
91
- "--relay-url",
92
- input.relayUrl,
93
- ];
94
- const logPath = codexSpawnLogPath(cwd);
95
- const env: Record<string, string | undefined> = {
96
- ...process.env,
97
- AGENT_RELAY_CODEX_HEADLESS: "1",
98
- AGENT_RELAY_APPROVAL: input.approvalMode,
99
- AGENT_RELAY_TAGS: mergeCsv(process.env.AGENT_RELAY_TAGS, ["headless", "dashboard-spawned"]),
100
- AGENT_RELAY_LABEL: input.label || process.env.AGENT_RELAY_LABEL,
101
- AGENT_RELAY_URL: input.relayUrl,
102
- AGENT_RELAY_TOKEN: input.token || process.env.AGENT_RELAY_TOKEN,
103
- };
104
-
105
- if (input.dryRun) {
106
- return { provider: "codex", cwd, approvalMode: input.approvalMode, logPath, command, dryRun: true };
107
- }
108
-
109
- mkdirSync(dirname(logPath), { recursive: true });
110
- const log = Bun.file(logPath);
111
- const child = Bun.spawn(command, {
112
- cwd,
113
- env,
114
- stdin: "ignore",
115
- stdout: log,
116
- stderr: log,
117
- });
118
- child.unref();
119
-
120
- return { provider: "codex", pid: child.pid, cwd, approvalMode: input.approvalMode, logPath, command };
121
- }
122
-
123
- function mergeCsv(raw: string | undefined, additions: string[]): string {
124
- return [...new Set([
125
- ...(raw || "").split(",").map((item) => item.trim()).filter(Boolean),
126
- ...additions,
127
- ])].join(",");
128
- }
129
-
130
- function findOnPath(command: string): string | null {
131
- for (const dir of (process.env.PATH || "").split(delimiter)) {
132
- if (!dir) continue;
133
- const candidate = join(dir, command);
134
- if (existsSync(candidate)) return candidate;
135
- }
136
- return null;
137
- }