agent-relay-runner 0.10.19 → 0.10.21

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 (36) hide show
  1. package/package.json +2 -2
  2. package/plugins/claude/.claude-plugin/plugin.json +4 -1
  3. package/plugins/claude/hooks/hooks.json +114 -0
  4. package/plugins/claude/hooks/permission-request.sh +20 -0
  5. package/plugins/claude/hooks/post-compact.sh +5 -0
  6. package/plugins/claude/hooks/pre-compact.sh +5 -0
  7. package/plugins/claude/hooks/relay-status.sh +66 -0
  8. package/plugins/claude/hooks/session-end.sh +16 -3
  9. package/plugins/claude/hooks/session-start.sh +14 -0
  10. package/plugins/claude/hooks/stop-failure.sh +15 -0
  11. package/plugins/claude/hooks/stop.sh +13 -3
  12. package/plugins/claude/hooks/subagent-start.sh +12 -0
  13. package/plugins/claude/hooks/subagent-stop.sh +12 -0
  14. package/plugins/claude/hooks/user-prompt-submit.sh +2 -3
  15. package/plugins/claude/monitors/relay-monitor.ts +16 -4
  16. package/plugins/claude/skills/react/SKILL.md +18 -0
  17. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  18. package/plugins/claude/skills/reply/SKILL.md +7 -3
  19. package/plugins/codex/skills/guide/SKILL.md +15 -0
  20. package/plugins/codex/skills/react/SKILL.md +17 -0
  21. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  22. package/plugins/codex/skills/reply/SKILL.md +6 -2
  23. package/src/adapter.ts +207 -6
  24. package/src/adapters/claude-delivery.ts +108 -0
  25. package/src/adapters/claude.ts +232 -31
  26. package/src/adapters/codex-client.ts +27 -1
  27. package/src/adapters/codex.ts +635 -26
  28. package/src/attachment-cache.ts +190 -0
  29. package/src/claim-tracker.ts +48 -5
  30. package/src/control-server.ts +193 -6
  31. package/src/index.ts +203 -6
  32. package/src/profile-home.ts +85 -0
  33. package/src/profile-projection.ts +146 -0
  34. package/src/relay-instructions.ts +25 -0
  35. package/src/runner.ts +811 -40
  36. package/src/version.ts +39 -0
package/src/index.ts CHANGED
@@ -1,58 +1,99 @@
1
1
  #!/usr/bin/env bun
2
+ import { hostname } from "node:os";
2
3
  import { basename } from "node:path";
3
4
  import { isatty } from "node:tty";
4
5
  import { ClaudeAdapter } from "./adapters/claude";
5
6
  import { CodexAdapter } from "./adapters/codex";
6
7
  import { AgentRunner } from "./runner";
7
8
  import { loadGlobalConfig, loadProviderConfig, resolveCwd, runnerId } from "./config";
9
+ import { VERSION } from "./version";
10
+ import type { AgentProfile, WorkspaceMetadata } from "agent-relay-sdk";
8
11
 
9
12
  interface CliOptions {
10
13
  provider: "claude" | "codex";
14
+ model?: string;
15
+ effort?: string;
11
16
  headless: boolean;
12
17
  interactive: boolean;
13
18
  cwd?: string;
14
19
  rig?: string;
20
+ profile?: string;
15
21
  relayUrl?: string;
16
22
  token?: string;
17
23
  approvalMode?: string;
18
24
  label?: string;
19
25
  agentId?: string;
20
26
  prompt?: string;
27
+ systemPromptAppend?: string;
21
28
  tags: string[];
22
29
  caps: string[];
23
30
  providerArgs: string[];
24
31
  }
25
32
 
26
33
  export async function main(argv = process.argv): Promise<void> {
34
+ if (isVersionRequest(argv)) {
35
+ console.log(VERSION);
36
+ return;
37
+ }
27
38
  const opts = parseArgs(argv);
28
39
  const globalConfig = loadGlobalConfig();
29
40
  const providerConfig = loadProviderConfig(opts.provider);
30
41
  const cwd = resolveCwd(opts.cwd, globalConfig.defaultCwd);
31
42
  const id = runnerId(opts.provider, cwd, opts.label);
32
43
  const adapter = opts.provider === "claude" ? new ClaudeAdapter() : new CodexAdapter();
44
+ const relayUrl = opts.relayUrl ?? globalConfig.relayUrl;
45
+ const runtimeAuth = await resolveRunnerToken({
46
+ interactive: opts.interactive,
47
+ relayUrl,
48
+ token: opts.token ?? globalConfig.token,
49
+ runtimeTokenProfile: process.env.AGENT_RELAY_TOKEN_PROFILE,
50
+ runtimeTokenJti: process.env.AGENT_RELAY_TOKEN_JTI,
51
+ runtimeTokenExpiresAt: parseTokenExpiresAt(process.env.AGENT_RELAY_TOKEN_EXPIRES_AT),
52
+ provider: opts.provider,
53
+ cwd,
54
+ runnerId: id,
55
+ agentId: opts.agentId ?? id,
56
+ label: opts.label,
57
+ });
33
58
 
34
59
  let exitResolve: (code: number) => void;
35
60
  const exitPromise = new Promise<number>((resolve) => { exitResolve = resolve; });
36
61
 
62
+ const approvalMode = opts.approvalMode
63
+ ?? detectApprovalModeFromArgs(opts.provider, providerConfig.defaultArgs, opts.providerArgs)
64
+ ?? providerConfig.defaultApprovalMode;
65
+
37
66
  const runner = new AgentRunner({
38
67
  provider: opts.provider,
68
+ model: opts.model,
69
+ effort: opts.effort,
39
70
  runnerId: id,
40
71
  instanceId: crypto.randomUUID(),
41
72
  agentId: opts.agentId,
42
- relayUrl: opts.relayUrl ?? globalConfig.relayUrl,
43
- token: opts.token ?? globalConfig.token,
73
+ relayUrl,
74
+ token: runtimeAuth.token,
75
+ tokenJti: runtimeAuth.jti,
76
+ tokenProfileId: runtimeAuth.profileId,
77
+ tokenExpiresAt: runtimeAuth.expiresAt,
78
+ rootTokenFallback: runtimeAuth.rootFallback,
44
79
  cwd,
45
80
  headless: opts.headless,
46
- approvalMode: opts.approvalMode ?? providerConfig.defaultApprovalMode,
81
+ approvalMode,
47
82
  label: opts.label,
48
83
  rig: opts.rig,
84
+ profile: opts.profile ?? process.env.AGENT_RELAY_AGENT_PROFILE,
85
+ agentProfile: parseAgentProfileEnv(process.env.AGENT_RELAY_AGENT_PROFILE_JSON),
86
+ workspace: parseWorkspaceEnv(process.env.AGENT_RELAY_WORKSPACE_JSON),
49
87
  prompt: opts.prompt,
88
+ systemPromptAppend: opts.systemPromptAppend,
89
+ tmuxSession: process.env.AGENT_RELAY_TMUX_SESSION,
50
90
  tags: opts.tags,
51
91
  capabilities: opts.caps,
52
92
  providerArgs: opts.providerArgs,
53
93
  policyName: process.env.AGENT_RELAY_POLICY,
54
94
  spawnRequestId: process.env.AGENT_RELAY_SPAWN_REQUEST_ID,
55
- tmuxSession: process.env.AGENT_RELAY_TMUX_SESSION,
95
+ automationId: process.env.AGENT_RELAY_AUTOMATION_ID,
96
+ automationRunId: process.env.AGENT_RELAY_AUTOMATION_RUN_ID,
56
97
  startedAt: Date.now(),
57
98
  providerConfig,
58
99
  adapter,
@@ -65,6 +106,108 @@ export async function main(argv = process.argv): Promise<void> {
65
106
  process.exit(code);
66
107
  }
67
108
 
109
+ export async function resolveRunnerToken(input: {
110
+ interactive: boolean;
111
+ relayUrl: string;
112
+ token?: string;
113
+ runtimeTokenProfile?: string;
114
+ runtimeTokenJti?: string;
115
+ runtimeTokenExpiresAt?: number;
116
+ provider: string;
117
+ cwd: string;
118
+ runnerId: string;
119
+ agentId: string;
120
+ label?: string;
121
+ }): Promise<{ token?: string; jti?: string; profileId?: string; expiresAt?: number; rootFallback?: boolean }> {
122
+ if (!input.token) return {};
123
+ const claims = decodeComponentTokenClaims(input.token);
124
+ if (input.runtimeTokenProfile) {
125
+ return {
126
+ token: input.token,
127
+ jti: input.runtimeTokenJti ?? claims?.jti,
128
+ profileId: input.runtimeTokenProfile,
129
+ expiresAt: input.runtimeTokenExpiresAt ?? claims?.exp,
130
+ };
131
+ }
132
+ const inferredRuntime = inferRuntimeTokenMetadata(claims);
133
+ if (!input.interactive && inferredRuntime) return { token: input.token, ...inferredRuntime };
134
+ if (!input.interactive) return { token: input.token };
135
+
136
+ try {
137
+ const url = new URL("/api/runtime-tokens/interactive-runner", input.relayUrl);
138
+ const res = await fetch(url, {
139
+ method: "POST",
140
+ headers: {
141
+ "Content-Type": "application/json",
142
+ "X-Agent-Relay-Token": input.token,
143
+ },
144
+ body: JSON.stringify({
145
+ provider: input.provider,
146
+ cwd: input.cwd,
147
+ runnerId: input.runnerId,
148
+ agentId: input.agentId,
149
+ label: input.label,
150
+ host: hostname(),
151
+ }),
152
+ signal: AbortSignal.timeout(5_000),
153
+ });
154
+ const body = await res.json().catch(() => null) as { token?: string; record?: { jti?: string; profileId?: string; expiresAt?: number }; error?: string } | null;
155
+ if (res.ok && body?.token) return { token: body.token, jti: body.record?.jti, profileId: body.record?.profileId ?? "provider-interactive", expiresAt: body.record?.expiresAt };
156
+ const detail = body?.error ? `: ${body.error}` : "";
157
+ console.warn(`[agent-relay] interactive scoped-token exchange failed (${res.status}${detail}); continuing with existing token. Root-token runtime fallback is deprecated.`);
158
+ } catch (error) {
159
+ console.warn(`[agent-relay] interactive scoped-token exchange failed (${error instanceof Error ? error.message : String(error)}); continuing with existing token. Root-token runtime fallback is deprecated.`);
160
+ }
161
+ return { token: input.token, rootFallback: true };
162
+ }
163
+
164
+ function decodeComponentTokenClaims(token: string | undefined): { sub?: string; role?: string; exp?: number; jti?: string } | undefined {
165
+ const payload = token?.split(".")[1];
166
+ if (!payload) return undefined;
167
+ try {
168
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
169
+ const decoded = JSON.parse(Buffer.from(normalized, "base64").toString("utf8")) as Record<string, unknown>;
170
+ const exp = typeof decoded.exp === "number" && Number.isSafeInteger(decoded.exp) && decoded.exp > 0 ? decoded.exp : undefined;
171
+ return {
172
+ ...(typeof decoded.sub === "string" ? { sub: decoded.sub } : {}),
173
+ ...(typeof decoded.role === "string" ? { role: decoded.role } : {}),
174
+ ...(typeof decoded.jti === "string" ? { jti: decoded.jti } : {}),
175
+ ...(exp ? { exp } : {}),
176
+ };
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ function inferRuntimeTokenMetadata(claims: { sub?: string; role?: string; exp?: number; jti?: string } | undefined): { jti?: string; profileId?: string; expiresAt?: number } | undefined {
183
+ if (claims?.role !== "provider" || !claims.exp) return undefined;
184
+ const sub = claims.sub ?? "";
185
+ const profileId = sub.startsWith("runner:interactive:")
186
+ ? "provider-interactive"
187
+ : sub.startsWith("runner:spawn:") || sub.startsWith("runner:policy:") || /^runner:(codex|claude|provider):/.test(sub)
188
+ ? "provider-agent"
189
+ : undefined;
190
+ if (!profileId) return undefined;
191
+ return {
192
+ ...(claims.jti ? { jti: claims.jti } : {}),
193
+ profileId,
194
+ expiresAt: claims.exp,
195
+ };
196
+ }
197
+
198
+ function parseTokenExpiresAt(value: string | undefined): number | undefined {
199
+ if (!value) return undefined;
200
+ const parsed = Number(value);
201
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
202
+ }
203
+
204
+ export function isVersionRequest(argv: string[]): boolean {
205
+ const relayArgs = argv.slice(2);
206
+ const providerSep = relayArgs.indexOf("--");
207
+ const ownArgs = providerSep >= 0 ? relayArgs.slice(0, providerSep) : relayArgs;
208
+ return ownArgs.some((arg) => arg === "--version" || arg === "-v");
209
+ }
210
+
68
211
  function parseArgs(argv: string[]): CliOptions {
69
212
  const bin = [argv[1], process.env._].map((s) => basename(s || "")).join(" ");
70
213
  let provider: "claude" | "codex" = bin.includes("claude") ? "claude" : "codex";
@@ -73,14 +216,18 @@ function parseArgs(argv: string[]): CliOptions {
73
216
  const ownArgs = providerSep >= 0 ? relayArgs.slice(0, providerSep) : relayArgs;
74
217
  const providerArgs = providerSep >= 0 ? relayArgs.slice(providerSep + 1) : [];
75
218
  let headless = false;
219
+ let model: string | undefined;
220
+ let effort: string | undefined;
76
221
  let cwd: string | undefined;
77
222
  let rig: string | undefined;
223
+ let profile: string | undefined;
78
224
  let relayUrl: string | undefined;
79
225
  let token: string | undefined;
80
226
  let approvalMode: string | undefined;
81
227
  let label: string | undefined;
82
228
  let agentId: string | undefined;
83
229
  let prompt: string | undefined;
230
+ let systemPromptAppend: string | undefined;
84
231
  let tags: string[] = [];
85
232
  let caps: string[] = [];
86
233
 
@@ -88,14 +235,18 @@ function parseArgs(argv: string[]): CliOptions {
88
235
  const arg = ownArgs[i];
89
236
  if (arg === "claude" || arg === "codex") provider = arg;
90
237
  else if (arg === "--headless") headless = true;
238
+ else if (arg === "--model" && ownArgs[i + 1]) model = ownArgs[++i];
239
+ else if (arg === "--effort" && ownArgs[i + 1]) effort = ownArgs[++i];
91
240
  else if (arg === "--cwd" && ownArgs[i + 1]) cwd = ownArgs[++i];
92
241
  else if (arg === "--rig" && ownArgs[i + 1]) rig = ownArgs[++i];
242
+ else if (arg === "--profile" && ownArgs[i + 1]) profile = ownArgs[++i];
93
243
  else if (arg === "--relay-url" && ownArgs[i + 1]) relayUrl = ownArgs[++i];
94
244
  else if (arg === "--token" && ownArgs[i + 1]) token = ownArgs[++i];
95
245
  else if ((arg === "--approval" || arg === "--approval-mode") && ownArgs[i + 1]) approvalMode = ownArgs[++i];
96
246
  else if (arg === "--label" && ownArgs[i + 1]) label = ownArgs[++i];
97
247
  else if (arg === "--agent-id" && ownArgs[i + 1]) agentId = ownArgs[++i];
98
248
  else if (arg === "--prompt" && ownArgs[i + 1]) prompt = ownArgs[++i];
249
+ else if ((arg === "--system-prompt-append" || arg === "--append-system-prompt") && ownArgs[i + 1]) systemPromptAppend = ownArgs[++i];
99
250
  else if (arg === "--tags" && ownArgs[i + 1]) tags = csvSplit(ownArgs[++i]!);
100
251
  else if (arg === "--caps" && ownArgs[i + 1]) caps = csvSplit(ownArgs[++i]!);
101
252
  else if (arg === "--help" || arg === "-h") {
@@ -107,15 +258,35 @@ function parseArgs(argv: string[]): CliOptions {
107
258
  }
108
259
 
109
260
  const interactive = !headless && isatty(0);
110
- return { provider, headless, interactive, cwd, rig, relayUrl, token, approvalMode, label, agentId, prompt, tags, caps, providerArgs };
261
+ return { provider, model, effort, headless, interactive, cwd, rig, profile, relayUrl, token, approvalMode, label, agentId, prompt, systemPromptAppend, tags, caps, providerArgs };
111
262
  }
112
263
 
113
264
  function csvSplit(value: string): string[] {
114
265
  return value.split(",").map((s) => s.trim()).filter(Boolean);
115
266
  }
116
267
 
268
+ function parseAgentProfileEnv(raw: string | undefined): AgentProfile | undefined {
269
+ if (!raw) return undefined;
270
+ try {
271
+ const parsed = JSON.parse(raw) as Partial<AgentProfile>;
272
+ return parsed && typeof parsed === "object" && typeof parsed.name === "string" ? parsed as AgentProfile : undefined;
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ function parseWorkspaceEnv(raw: string | undefined): WorkspaceMetadata | undefined {
279
+ if (!raw) return undefined;
280
+ try {
281
+ const parsed = JSON.parse(raw) as Partial<WorkspaceMetadata>;
282
+ return parsed && typeof parsed === "object" && typeof parsed.mode === "string" ? parsed as WorkspaceMetadata : undefined;
283
+ } catch {
284
+ return undefined;
285
+ }
286
+ }
287
+
117
288
  function printHelp(provider: string): void {
118
- console.log(`${provider}-relay [--headless] [--rig NAME] [--cwd PATH] [--relay-url URL] [--approval MODE] [--label NAME] [--agent-id ID] [--tags a,b] [--caps x,y] [-- provider-args...]`);
289
+ console.log(`${provider}-relay [--headless] [--model ALIAS] [--effort LEVEL] [--rig NAME] [--profile NAME] [--cwd PATH] [--relay-url URL] [--approval MODE] [--label NAME] [--agent-id ID] [--prompt TEXT] [--system-prompt-append TEXT] [--tags a,b] [--caps x,y] [-- provider-args...]`);
119
290
  }
120
291
 
121
292
  function signalPromise(): Promise<number> {
@@ -125,6 +296,32 @@ function signalPromise(): Promise<number> {
125
296
  });
126
297
  }
127
298
 
299
+ export function detectApprovalModeFromArgs(provider: string, defaultArgs: string[], providerArgs: string[]): string | undefined {
300
+ const allArgs = [...defaultArgs, ...providerArgs];
301
+ if (provider === "claude") {
302
+ if (allArgs.includes("--dangerously-skip-permissions")) return "open";
303
+ const modeIdx = allArgs.lastIndexOf("--permission-mode");
304
+ if (modeIdx >= 0) {
305
+ const mode = allArgs[modeIdx + 1];
306
+ if (mode === "dontAsk") return "read-only";
307
+ if (mode === "default") return "guarded";
308
+ }
309
+ return undefined;
310
+ }
311
+ if (provider === "codex") {
312
+ if (allArgs.includes("--yolo")) return "open";
313
+ for (let i = 0; i < allArgs.length; i++) {
314
+ if (allArgs[i] === "-c" || allArgs[i] === "--config") {
315
+ const value = allArgs[i + 1] ?? "";
316
+ if (/sandbox_mode\s*=\s*"?danger-full-access"?/.test(value)) return "open";
317
+ if (/sandbox_mode\s*=\s*"?read-only"?/.test(value)) return "read-only";
318
+ }
319
+ }
320
+ return undefined;
321
+ }
322
+ return undefined;
323
+ }
324
+
128
325
  if (import.meta.main) {
129
326
  await main();
130
327
  }
@@ -0,0 +1,85 @@
1
+ import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
5
+ import { CLAUDE_RELAY_MANUAL } from "./relay-instructions";
6
+
7
+ type ProviderHome = {
8
+ path: string;
9
+ authLinked: string[];
10
+ };
11
+
12
+ export function profileUsesHostProviderGlobals(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
13
+ return !config.agentProfile || config.agentProfile.base === "host";
14
+ }
15
+
16
+ function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
17
+ return Boolean(config.agentProfile && config.agentProfile.base !== "host");
18
+ }
19
+
20
+ export function prepareCodexProfileHome(config: RunnerSpawnConfig): ProviderHome | undefined {
21
+ if (!profileRequiresIsolatedHome(config)) return undefined;
22
+ const target = providerHomePath("codex", config);
23
+ mkdirSync(target, { recursive: true });
24
+ trustCodexProjectIfAllowed(target, config);
25
+ const sourceHome = process.env.CODEX_HOME || join(homedir(), ".codex");
26
+ const authLinked = linkExistingAuthItems(sourceHome, target, ["auth.json", "installation_id"]);
27
+ return { path: target, authLinked };
28
+ }
29
+
30
+ export function prepareClaudeProfileHome(config: RunnerSpawnConfig): ProviderHome | undefined {
31
+ if (!profileRequiresIsolatedHome(config)) return undefined;
32
+ const target = providerHomePath("claude", config);
33
+ mkdirSync(target, { recursive: true });
34
+ // Only inject the Relay usage manual when the profile actually wants a Relay
35
+ // surface. An isolated-research profile (relay.context disabled) must not get
36
+ // agent-relay communication instructions written into its config home.
37
+ if (profileAllowsRelayFeature(config, "context")) writeClaudeRelayManual(target);
38
+ const sourceHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
39
+ const authLinked = linkExistingAuthItems(sourceHome, target, [".credentials.json", "statsig"]);
40
+ return { path: target, authLinked };
41
+ }
42
+
43
+ function providerHomePath(provider: "claude" | "codex", config: RunnerSpawnConfig): string {
44
+ const root = process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
45
+ const profileName = sanitizePathPart(config.agentProfile?.name || config.profile || "profile");
46
+ const instance = sanitizePathPart(config.instanceId || config.runnerId);
47
+ return join(root, provider, profileName, instance);
48
+ }
49
+
50
+ function sanitizePathPart(value: string): string {
51
+ return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120) || "profile";
52
+ }
53
+
54
+ function linkExistingAuthItems(sourceHome: string, targetHome: string, items: string[]): string[] {
55
+ const linked: string[] = [];
56
+ for (const item of items) {
57
+ const source = join(sourceHome, item);
58
+ const target = join(targetHome, item);
59
+ if (!existsSync(source) || existsSync(target)) continue;
60
+ symlinkSync(source, target);
61
+ linked.push(item);
62
+ }
63
+ return linked;
64
+ }
65
+
66
+ function trustCodexProjectIfAllowed(codexHome: string, config: RunnerSpawnConfig): void {
67
+ if (config.agentProfile?.instructions.repoInstructions === "ignore") return;
68
+ const path = join(codexHome, "config.toml");
69
+ const project = tomlBasicString(resolve(config.cwd));
70
+ const section = `[projects.${project}]`;
71
+ const entry = `${section}\ntrust_level = "trusted"\n`;
72
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
73
+ if (current.includes(section)) return;
74
+ const prefix = current.trimEnd();
75
+ writeFileSync(path, `${prefix ? `${prefix}\n\n` : ""}${entry}`, { mode: 0o600 });
76
+ }
77
+
78
+ function writeClaudeRelayManual(claudeHome: string): void {
79
+ const path = join(claudeHome, "CLAUDE.md");
80
+ writeFileSync(path, CLAUDE_RELAY_MANUAL, { mode: 0o600 });
81
+ }
82
+
83
+ function tomlBasicString(value: string): string {
84
+ return JSON.stringify(value);
85
+ }
@@ -0,0 +1,146 @@
1
+ import type { AgentProfile, AgentProfileProjectionEntry, AgentProfileProjectionReport, SpawnProvider } from "agent-relay-sdk";
2
+
3
+ interface AgentProfileProjectionInput {
4
+ provider: SpawnProvider;
5
+ profile: AgentProfile;
6
+ generatedAt?: number;
7
+ }
8
+
9
+ export function agentProfileProjectionReport(input: AgentProfileProjectionInput): AgentProfileProjectionReport {
10
+ const entries: AgentProfileProjectionEntry[] = [];
11
+ const add = (entry: AgentProfileProjectionEntry) => entries.push(entry);
12
+ const { provider, profile } = input;
13
+ const generatedAt = input.generatedAt ?? Date.now();
14
+
15
+ add(relayContextEntry(profile));
16
+ add(relaySkillsEntry(provider, profile));
17
+ add(relayPluginsEntry(provider, profile));
18
+ add(relayStatusLineEntry(provider, profile));
19
+ add(repoInstructionsEntry(profile));
20
+ add(globalInstructionsEntry(provider, profile));
21
+ add(mcpEntry(provider, profile));
22
+ add(hooksEntry(provider, profile));
23
+ add(filesystemEntry(profile));
24
+ add(configHomeEntry(provider, profile));
25
+ add(envEntry(profile));
26
+
27
+ const warnings = entries
28
+ .filter((entry) => entry.result === "partial" || entry.result === "unsupported")
29
+ .map((entry) => `${entry.capability}: ${entry.detail}`);
30
+ const unsupported = entries
31
+ .filter((entry) => entry.result === "unsupported")
32
+ .map((entry) => entry.capability);
33
+
34
+ return {
35
+ profileName: profile.name,
36
+ provider,
37
+ base: profile.base,
38
+ generatedAt,
39
+ entries,
40
+ warnings,
41
+ unsupported,
42
+ };
43
+ }
44
+
45
+ function relayContextEntry(profile: AgentProfile): AgentProfileProjectionEntry {
46
+ return profile.relay.context
47
+ ? applied("relay.context", "enabled", "Relay context is injected before Relay-delivered provider turns.")
48
+ : applied("relay.context", "disabled", "Relay context injection is disabled by the profile.");
49
+ }
50
+
51
+ function relaySkillsEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
52
+ if (provider === "codex") {
53
+ return profile.relay.skills
54
+ ? applied("relay.skills", "enabled", "Bundled Agent Relay Codex skills are passed through Codex skills.config.")
55
+ : applied("relay.skills", "disabled", "Bundled Agent Relay Codex skills are omitted.");
56
+ }
57
+ return profile.relay.skills
58
+ ? applied("relay.skills", "enabled", "Agent Relay Claude skills are available through the bundled Relay plugin.")
59
+ : applied("relay.skills", "disabled", profile.relay.plugins
60
+ ? "Claude does not expose an independent Relay skill switch; disabling skills requires disabling the Relay plugin."
61
+ : "Bundled Agent Relay Claude plugin dirs are omitted, so Relay plugin-provided skills are unavailable.");
62
+ }
63
+
64
+ function relayPluginsEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
65
+ if (provider === "claude") {
66
+ return profile.relay.plugins
67
+ ? applied("relay.plugins", "enabled", "Bundled Agent Relay Claude plugin dir is passed to Claude.")
68
+ : applied("relay.plugins", "disabled", "Bundled Agent Relay Claude plugin dirs are omitted.");
69
+ }
70
+ return notApplicable("relay.plugins", profile.relay.plugins ? "enabled" : "disabled", "Codex Relay integration uses skills/app-server delivery, not a Relay plugin dir.");
71
+ }
72
+
73
+ function relayStatusLineEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
74
+ if (provider === "claude") {
75
+ return profile.relay.statusLine
76
+ ? applied("relay.statusLine", "enabled", "Agent Relay status-line context probe settings are injected when caller settings allow it.")
77
+ : applied("relay.statusLine", "disabled", "Agent Relay status-line settings are omitted.");
78
+ }
79
+ return notApplicable("relay.statusLine", profile.relay.statusLine ? "enabled" : "disabled", "Codex reports context through App Server events instead of a Relay status-line setting.");
80
+ }
81
+
82
+ function repoInstructionsEntry(profile: AgentProfile): AgentProfileProjectionEntry {
83
+ if (profile.instructions.repoInstructions === "allow") {
84
+ return applied("instructions.repo", "allow", "Repo-local provider instructions are left available.");
85
+ }
86
+ return unsupported("instructions.repo", "ignore", "Provider-specific repo instruction suppression is not projected yet.");
87
+ }
88
+
89
+ function globalInstructionsEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
90
+ if (profile.instructions.globalInstructions === "allow") {
91
+ return applied("instructions.global", "allow", "Host/global provider instructions are left available.");
92
+ }
93
+ if (profile.base !== "host") {
94
+ return applied("instructions.global", "ignore", `${provider} uses an isolated provider config home, so host/global provider instructions are not loaded.`);
95
+ }
96
+ return unsupported("instructions.global", "ignore", `${provider} global instruction isolation requires an isolated provider config home.`);
97
+ }
98
+
99
+ function mcpEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
100
+ if (profile.mcp.mode === "host") return applied("mcp", "host", "Host provider MCP configuration is left available.");
101
+ if (profile.mcp.mode === "none" && profile.base !== "host") {
102
+ return applied("mcp", "none", `${provider} uses an isolated provider config home without host MCP configuration.`);
103
+ }
104
+ return unsupported("mcp", profile.mcp.mode, `${provider} MCP category projection is not enforced yet.`);
105
+ }
106
+
107
+ function hooksEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
108
+ if (profile.hooks.mode === "host") return applied("hooks", "host", "Host provider hooks are left available.");
109
+ if (profile.hooks.mode === "none" && profile.base !== "host") {
110
+ return applied("hooks", "none", `${provider} uses an isolated provider config home without host hook configuration.`);
111
+ }
112
+ return unsupported("hooks", profile.hooks.mode, `${provider} hook category projection is not enforced yet.`);
113
+ }
114
+
115
+ function filesystemEntry(profile: AgentProfile): AgentProfileProjectionEntry {
116
+ if (profile.permissions.filesystem === "host") return applied("permissions.filesystem", "host", "No profile-level filesystem boundary requested.");
117
+ return partial("permissions.filesystem", profile.permissions.filesystem, "Relay constrains spawn cwd through the orchestrator; provider-level filesystem sandbox projection is not complete yet.");
118
+ }
119
+
120
+ function configHomeEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
121
+ if (profile.base === "host") return applied("provider.configHome", "host", "Provider uses the host's normal configuration home.");
122
+ return applied("provider.configHome", profile.base, `${provider} is launched with an Agent Relay managed provider config home and linked host auth where available.`);
123
+ }
124
+
125
+ function envEntry(profile: AgentProfile): AgentProfileProjectionEntry {
126
+ const count = Object.keys(profile.env).length;
127
+ return count
128
+ ? applied("env", `${count} vars`, "Profile environment variables are passed through the orchestrator runner environment.")
129
+ : applied("env", "none", "No profile environment variables requested.");
130
+ }
131
+
132
+ function applied(capability: string, requested: string, detail: string): AgentProfileProjectionEntry {
133
+ return { capability, requested, result: "applied", detail };
134
+ }
135
+
136
+ function partial(capability: string, requested: string, detail: string): AgentProfileProjectionEntry {
137
+ return { capability, requested, result: "partial", detail };
138
+ }
139
+
140
+ function unsupported(capability: string, requested: string, detail: string): AgentProfileProjectionEntry {
141
+ return { capability, requested, result: "unsupported", detail };
142
+ }
143
+
144
+ function notApplicable(capability: string, requested: string, detail: string): AgentProfileProjectionEntry {
145
+ return { capability, requested, result: "not-applicable", detail };
146
+ }
@@ -0,0 +1,25 @@
1
+ export const CLAUDE_RELAY_MANUAL = `# Agent Relay
2
+
3
+ - Agent Relay messages may come from humans, channels, or other agents.
4
+ - Read the delivered message body and do the requested work.
5
+ - Reply through Relay only when the sender still needs an answer.
6
+ - Prefer \`agent-relay /reply <messageId> --stdin < response.md\` for the actual answer to an incoming Relay message.
7
+ - Do not send \`/message\` with the useful answer and then a separate \`/reply\` that only says it was sent.
8
+ - If multiple Relay messages arrive together, answer once to the latest relevant message and cover the current request. Do not separately acknowledge stale greetings or context.
9
+ - If the useful response was already delivered through Relay, do not send an extra "sent", "done", or "drafts sent" confirmation unless the user explicitly asked for one.
10
+ - No reply is needed for pure info messages, passive acknowledgements, or reactions that do not ask for action.
11
+ - Use \`agent-relay /react <messageId> <emoji>\` instead of a text reply for lightweight acknowledgement, approval, thanks, or "good job" after a completed work update.
12
+ - Good reaction uses: acknowledge praise with 👍 or ❤️, mark a completed handoff as seen, approve a proposed next step, or acknowledge a passive FYI.
13
+ - Do not use reactions when the user asked a question, gave a new task, reported a bug, or needs a textual result.
14
+ - A thumbs-up reaction to your question means approval to proceed. A thumbs-up reaction to your completed status means no further action.
15
+ - Use \`agent-relay get-message <id>\` when a delivered preview is truncated.
16
+ - Use \`agent-relay /status --json\` to inspect your current Relay identity and \`agent-relay /guide\` for command details.
17
+ `;
18
+
19
+ export const CLAUDE_RELAY_CONTEXT = `[agent-relay] Relay is available. Follow the Agent Relay rules in CLAUDE.md when present, or run agent-relay /guide. Reply through Relay only when a response is needed; do not send status-only follow-ups after the useful Relay response was already delivered.`;
20
+
21
+ export const CLAUDE_READ_ONLY_RELAY_CONTEXT = `${CLAUDE_RELAY_CONTEXT}
22
+
23
+ This Claude session is running with restricted read-only Relay permissions. Do not invoke Agent Relay skills. If you need to reply, use this Bash command shape:
24
+
25
+ agent-relay /reply <messageId> "<your reply>"`;