agent-relay-runner 0.102.1 → 0.103.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.102.1",
3
+ "version": "0.103.4",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,8 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.83"
23
+ "agent-relay-providers": "0.102.2",
24
+ "agent-relay-sdk": "0.2.87"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.102.1",
4
+ "version": "0.103.4",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -6,10 +6,32 @@ function isRecord(value) {
6
6
  // sdk/src/types/constants.ts
7
7
  var DEFAULT_RELAY_PORT = 4850;
8
8
  var DEFAULT_RELAY_URL = `http://127.0.0.1:${DEFAULT_RELAY_PORT}`;
9
- // sdk/src/provider-catalog.ts
9
+ // providers/registry.ts
10
+ var PROVIDER_ID_RE = /^[a-z][a-z0-9-]*$/;
11
+ var manifests = new Map;
12
+ function registerProvider(manifest) {
13
+ if (!PROVIDER_ID_RE.test(manifest.id)) {
14
+ throw new Error(`invalid provider id: ${manifest.id}`);
15
+ }
16
+ if (!manifest.label.trim()) {
17
+ throw new Error(`provider ${manifest.id} is missing a label`);
18
+ }
19
+ if (!manifest.icon.trim()) {
20
+ throw new Error(`provider ${manifest.id} is missing an icon`);
21
+ }
22
+ if (manifests.has(manifest.id)) {
23
+ throw new Error(`provider already registered: ${manifest.id}`);
24
+ }
25
+ manifests.set(manifest.id, manifest);
26
+ }
27
+ function getAllManifests() {
28
+ return [...manifests.values()];
29
+ }
30
+ var accountKeyAliasResolvers = new Map;
31
+
32
+ // providers/claude.ts
10
33
  var CLAUDE_LOW_TO_MAX = ["low", "medium", "high", "max"];
11
34
  var CLAUDE_LOW_TO_XHIGH_MAX = ["low", "medium", "high", "xhigh", "max"];
12
- var CODEX_REASONING = ["low", "medium", "high", "xhigh"];
13
35
  var CONTEXT_200K = { value: 200000, source: "catalog", confidence: "declared" };
14
36
  var CONTEXT_1M = { value: 1e6, source: "catalog", confidence: "declared" };
15
37
  var CODE_MODEL_CAPABILITIES = {
@@ -26,16 +48,26 @@ var CODE_MODEL_CAPABILITIES = {
26
48
  source: "catalog",
27
49
  confidence: "declared"
28
50
  };
51
+ var quotaWindows = [
52
+ { name: "five_hour", unit: "%", reset: true },
53
+ { name: "seven_day", unit: "%", reset: true }
54
+ ];
29
55
  function codeModel(limits) {
30
56
  return {
31
57
  ...limits ? { limits } : {},
32
58
  capabilities: CODE_MODEL_CAPABILITIES
33
59
  };
34
60
  }
35
- var PROVIDER_CATALOG = {
36
- claude: {
37
- provider: "claude",
38
- label: "Claude Code",
61
+ var claudeProviderManifest = {
62
+ id: "claude",
63
+ label: "Claude",
64
+ icon: "\uD83D\uDFE0",
65
+ launch: {
66
+ managedPolicyPrompt: {
67
+ alwaysOn: "append-system-prompt"
68
+ }
69
+ },
70
+ catalog: {
39
71
  defaultModel: "sonnet-4.6",
40
72
  models: [
41
73
  { alias: "fable-5", label: "Fable 5", providerModel: "claude-fable-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", unavailable: "suspended by export-control directive, 2026-06-12", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
@@ -50,20 +82,210 @@ var PROVIDER_CATALOG = {
50
82
  { alias: "haiku", label: "Haiku", providerModel: "haiku", efforts: [], ...codeModel() }
51
83
  ]
52
84
  },
53
- codex: {
54
- provider: "codex",
55
- label: "Codex",
85
+ quotaWindows: [...quotaWindows],
86
+ capabilityDefaults: {
87
+ lifecycle: {
88
+ managed: true,
89
+ shutdownHard: { when: "headless", value: true, otherwise: false },
90
+ restartHard: { when: "headless", value: true, otherwise: false },
91
+ semanticStatus: true,
92
+ reconnect: true
93
+ },
94
+ session: {
95
+ fileRead: true,
96
+ fileWrite: { when: "approval:not-read-only", value: true, otherwise: false },
97
+ shell: true,
98
+ shellMode: { open: "unrestricted", guarded: "guarded", readOnly: "read-only-guarded" }
99
+ },
100
+ context: {
101
+ managed: "headless",
102
+ compact: { when: "headless", value: true, otherwise: false },
103
+ clear: { when: "headless", value: true, otherwise: false },
104
+ inject: true,
105
+ resumePreference: ["native", "clear-inject", "none"]
106
+ },
107
+ quota: {
108
+ supported: true,
109
+ source: "probe",
110
+ unit: "%",
111
+ windows: [...quotaWindows]
112
+ },
113
+ terminal: {
114
+ mode: "live",
115
+ condition: "headless",
116
+ live: { read: true, write: true }
117
+ },
118
+ liveSession: {
119
+ capture: true,
120
+ inject: true,
121
+ interrupt: true,
122
+ promptEcho: true,
123
+ reasoning: true,
124
+ slashCommands: true
125
+ }
126
+ },
127
+ slashCommands: ["/clear", "/compact", "/model", "/help"],
128
+ resume: { supported: true, idFormat: "uuid-v4", idPattern: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" },
129
+ home: { configDir: ".claude", envPrefix: "CLAUDE", configDirEnvVar: "CLAUDE_CONFIG_DIR", quotaCredentialFile: ".credentials.json", authItems: [".credentials.json", "statsig"] },
130
+ provisioning: {
131
+ pluginManifestPath: ".claude-plugin/plugin.json",
132
+ bundledPlugin: {
133
+ ref: "agent-relay-claude-plugin",
134
+ dir: "runner/plugins/claude",
135
+ title: "Agent Relay Claude plugin",
136
+ description: "Bundled Agent Relay Claude plugin, materialized whole through the provisioning registry.",
137
+ omitFiles: ["monitors/relay-monitor.provisioned.mjs"],
138
+ fileOverrides: [{ path: "monitors/relay-monitor.ts", sourcePath: "monitors/relay-monitor.provisioned.mjs" }]
139
+ }
140
+ },
141
+ upgrade: {
142
+ packages: ["agent-relay-runner", "agent-relay-plugin"],
143
+ detectPackages: ["agent-relay-runner"],
144
+ detectCommands: ["claude"]
145
+ },
146
+ probe: {
147
+ command: "claude",
148
+ args: ["--version"],
149
+ healthCheck: "claude --version",
150
+ featureChecks: [{ name: "rig", command: "claude-rig", homeDir: ".claude-rig" }]
151
+ },
152
+ quotaPoll: { strategy: "none" },
153
+ shim: { kind: "claude-relay" }
154
+ };
155
+ registerProvider(claudeProviderManifest);
156
+
157
+ // providers/codex.ts
158
+ var CODEX_REASONING = ["low", "medium", "high", "xhigh"];
159
+ var CONTEXT_200K2 = { value: 200000, source: "catalog", confidence: "declared" };
160
+ var CODE_MODEL_CAPABILITIES2 = {
161
+ modalities: {
162
+ input: { text: true, image: true },
163
+ output: { text: true }
164
+ },
165
+ tools: {
166
+ code: true,
167
+ review: true,
168
+ debug: true,
169
+ refactor: true
170
+ },
171
+ source: "catalog",
172
+ confidence: "declared"
173
+ };
174
+ var quotaWindows2 = [
175
+ { name: "primary", unit: "%", reset: true },
176
+ { name: "secondary", unit: "%", reset: true }
177
+ ];
178
+ function codeModel2(limits) {
179
+ return {
180
+ ...limits ? { limits } : {},
181
+ capabilities: CODE_MODEL_CAPABILITIES2
182
+ };
183
+ }
184
+ var codexProviderManifest = {
185
+ id: "codex",
186
+ label: "Codex",
187
+ icon: "\u2328\uFE0F",
188
+ launch: {
189
+ managedPolicyPrompt: {
190
+ alwaysOn: "prompt"
191
+ }
192
+ },
193
+ catalog: {
56
194
  defaultModel: "gpt-5.5",
57
195
  models: [
58
- { alias: "gpt-5.5", label: "GPT-5.5", providerModel: "gpt-5.5", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
59
- { alias: "gpt-5.4", label: "GPT-5.4", providerModel: "gpt-5.4", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
60
- { alias: "gpt-5.4-mini", label: "GPT-5.4 Mini", providerModel: "gpt-5.4-mini", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
61
- { alias: "gpt-5.3-codex", label: "GPT-5.3 Codex", providerModel: "gpt-5.3-codex", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
62
- { alias: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", providerModel: "gpt-5.3-codex-spark", efforts: CODEX_REASONING, defaultEffort: "high", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
63
- { alias: "gpt-5.2", label: "GPT-5.2", providerModel: "gpt-5.2", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) }
196
+ { alias: "gpt-5.5", label: "GPT-5.5", providerModel: "gpt-5.5", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
197
+ { alias: "gpt-5.4", label: "GPT-5.4", providerModel: "gpt-5.4", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
198
+ { alias: "gpt-5.4-mini", label: "GPT-5.4 Mini", providerModel: "gpt-5.4-mini", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
199
+ { alias: "gpt-5.3-codex", label: "GPT-5.3 Codex", providerModel: "gpt-5.3-codex", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
200
+ { alias: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", providerModel: "gpt-5.3-codex-spark", efforts: CODEX_REASONING, defaultEffort: "high", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
201
+ { alias: "gpt-5.2", label: "GPT-5.2", providerModel: "gpt-5.2", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) }
64
202
  ]
65
- }
203
+ },
204
+ quotaWindows: [...quotaWindows2],
205
+ capabilityDefaults: {
206
+ lifecycle: {
207
+ managed: true,
208
+ shutdownHard: true,
209
+ restartHard: true,
210
+ semanticStatus: true,
211
+ reconnect: true
212
+ },
213
+ session: {
214
+ fileRead: true,
215
+ fileWrite: { when: "approval:not-read-only", value: true, otherwise: false },
216
+ shell: { when: "approval:not-read-only", value: true, otherwise: false },
217
+ shellMode: { open: "unrestricted", guarded: "guarded", readOnly: "none" }
218
+ },
219
+ context: {
220
+ managed: "always",
221
+ compact: true,
222
+ clear: true,
223
+ inject: true,
224
+ resumePreference: ["native", "clear-inject", "none"]
225
+ },
226
+ quota: {
227
+ supported: true,
228
+ source: "probe",
229
+ unit: "%",
230
+ windows: [...quotaWindows2]
231
+ },
232
+ terminal: {
233
+ mode: "attach",
234
+ attach: {
235
+ create: true,
236
+ read: true,
237
+ write: true,
238
+ detach: true
239
+ }
240
+ },
241
+ liveSession: {
242
+ capture: true,
243
+ inject: true,
244
+ interrupt: true,
245
+ promptEcho: true,
246
+ reasoning: true,
247
+ slashCommands: true
248
+ }
249
+ },
250
+ slashCommands: ["/compact", "/new"],
251
+ resume: { supported: true, idFormat: "codex-thread-id", idPattern: "[\\w-]+" },
252
+ home: { configDir: ".codex", envPrefix: "CODEX", configDirEnvVar: "CODEX_HOME", quotaCredentialFile: "auth.json", authItems: ["auth.json", "installation_id"] },
253
+ provisioning: {
254
+ pluginManifestPath: ".codex-plugin/plugin.json",
255
+ bundledSkills: {
256
+ refPrefix: "agent-relay-codex-",
257
+ dir: "runner/plugins/codex/skills",
258
+ titlePrefix: "Agent Relay Codex skill: ",
259
+ description: "Bundled Agent Relay Codex skill, materialized through the provisioning registry.",
260
+ entryFile: "SKILL.md"
261
+ }
262
+ },
263
+ upgrade: {
264
+ packages: ["agent-relay-runner", "agent-relay-codex"],
265
+ detectPackages: ["agent-relay-runner", "agent-relay-codex"],
266
+ detectCommands: ["codex-relay", "agent-relay-runner"]
267
+ },
268
+ probe: {
269
+ command: "codex",
270
+ args: ["--version"],
271
+ healthCheck: "codex --version"
272
+ },
273
+ quotaPoll: { strategy: "codex-app-server" },
274
+ shim: { kind: "codex-relay", extraBinPaths: ["codex/bin/codex"], cleanLegacyHooks: true }
66
275
  };
276
+ registerProvider(codexProviderManifest);
277
+ // sdk/src/types/agents.ts
278
+ var SPAWN_PROVIDERS = getAllManifests().map((manifest) => manifest.id);
279
+ // sdk/src/provider-catalog.ts
280
+ function manifestToCatalogEntry(manifest) {
281
+ return {
282
+ provider: manifest.id,
283
+ label: manifest.label,
284
+ defaultModel: manifest.catalog.defaultModel,
285
+ models: manifest.catalog.models
286
+ };
287
+ }
288
+ var PROVIDER_CATALOG = Object.fromEntries(getAllManifests().map((manifest) => [manifest.id, manifestToCatalogEntry(manifest)]));
67
289
  // sdk/src/reconnect.ts
68
290
  import { setTimeout as delay } from "timers/promises";
69
291
  var DEFAULTS = {
@@ -1,7 +1,7 @@
1
1
  import type { QuotaState, RelayHttpClient } from "agent-relay-sdk";
2
2
  import { quotaStateFromProbeMetrics } from "agent-relay-sdk/context-probe";
3
- import { readRunnerContextProbeState } from "./context-probe-state";
4
- import { DEFAULT_PROVIDER_QUOTA_CONFIG, normalizeProviderQuotaConfig, resolveClaudeStatuslineQuotaIdentity } from "./quota";
3
+ import { readRunnerContextProbeState } from "../context-probe-state";
4
+ import { DEFAULT_PROVIDER_QUOTA_CONFIG, normalizeProviderQuotaConfig, resolveClaudeStatuslineQuotaIdentity } from "../quota";
5
5
 
6
6
  const PASSIVE_QUOTA_CONFIG_REFRESH_MS = 60_000;
7
7
  const PASSIVE_QUOTA_SAMPLE_STALE_MS = 10 * 60_000;
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from "node:path";
5
5
  import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "agent-relay-sdk";
6
6
  import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
+ import { getManifest } from "agent-relay-providers";
8
9
 
9
10
  interface ProviderConfig {
10
11
  command: string;
@@ -40,11 +41,15 @@ function providersDir(home = agentRelayHome()): string {
40
41
  return join(home, "providers");
41
42
  }
42
43
 
44
+ const DEFAULT_PROVIDER_ARGS: Record<string, string[]> = {
45
+ claude: ["--dangerously-skip-permissions"],
46
+ };
47
+
43
48
  export function defaultProviderConfig(provider: string): ProviderConfig {
44
- const command = provider === "claude" ? "claude" : provider;
49
+ const manifest = getManifest(provider);
45
50
  return {
46
- command,
47
- defaultArgs: provider === "claude" ? ["--dangerously-skip-permissions"] : [],
51
+ command: manifest?.probe?.command ?? provider,
52
+ defaultArgs: DEFAULT_PROVIDER_ARGS[provider] ?? [],
48
53
  env: {},
49
54
  pluginDirs: [],
50
55
  defaultCapabilities: ["chat", "code", "review"],
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { createProviderAdapter } from "./providers";
26
26
  import { VERSION } from "./version";
27
27
  import type { AgentProfile, RelayInjectionCategory, SpawnProvider, WorkspaceMetadata } from "agent-relay-sdk";
28
28
  import { errMessage, isRecord, normalizeAgentLifecycle, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
29
+ import { getAllManifests } from "agent-relay-providers";
29
30
  import type { RunnerRelayInjectionEvent } from "./relay-injection-events";
30
31
 
31
32
  interface CliOptions {
@@ -233,9 +234,20 @@ export function isVersionRequest(argv: string[]): boolean {
233
234
  return ownArgs.some((arg) => arg === "--version" || arg === "-v");
234
235
  }
235
236
 
236
- function parseArgs(argv: string[]): CliOptions {
237
+ function detectProviderFromBin(argv: string[]): SpawnProvider {
237
238
  const bin = [argv[1], process.env._].map((s) => basename(s || "")).join(" ");
238
- let provider: SpawnProvider = bin.includes("claude") ? "claude" : "codex";
239
+ for (const manifest of getAllManifests()) {
240
+ if (bin.includes(manifest.id)) return manifest.id as SpawnProvider;
241
+ }
242
+ return getAllManifests()[0]?.id as SpawnProvider ?? "claude";
243
+ }
244
+
245
+ function isRegisteredProvider(value: string): value is SpawnProvider {
246
+ return getAllManifests().some((m) => m.id === value);
247
+ }
248
+
249
+ function parseArgs(argv: string[]): CliOptions {
250
+ let provider = detectProviderFromBin(argv);
239
251
  const relayArgs = argv.slice(2);
240
252
  const providerSep = relayArgs.indexOf("--");
241
253
  const ownArgs = providerSep >= 0 ? relayArgs.slice(0, providerSep) : relayArgs;
@@ -258,7 +270,7 @@ function parseArgs(argv: string[]): CliOptions {
258
270
 
259
271
  for (let i = 0; i < ownArgs.length; i++) {
260
272
  const arg = ownArgs[i];
261
- if (arg === "claude" || arg === "codex") provider = arg;
273
+ if (arg && isRegisteredProvider(arg)) provider = arg;
262
274
  else if (arg === "--headless") headless = true;
263
275
  else if (arg === "--model" && ownArgs[i + 1]) model = ownArgs[++i];
264
276
  else if (arg === "--effort" && ownArgs[i + 1]) effort = ownArgs[++i];
@@ -338,9 +350,10 @@ function signalPromise(): Promise<number> {
338
350
  });
339
351
  }
340
352
 
341
- export function detectApprovalModeFromArgs(provider: string, defaultArgs: string[], providerArgs: string[]): string | undefined {
342
- const allArgs = [...defaultArgs, ...providerArgs];
343
- if (provider === "claude") {
353
+ type ApprovalModeDetector = (allArgs: string[]) => string | undefined;
354
+
355
+ const APPROVAL_MODE_DETECTORS: Record<string, ApprovalModeDetector> = {
356
+ claude(allArgs) {
344
357
  if (allArgs.includes("--dangerously-skip-permissions")) return "open";
345
358
  const modeIdx = allArgs.lastIndexOf("--permission-mode");
346
359
  if (modeIdx >= 0) {
@@ -349,8 +362,8 @@ export function detectApprovalModeFromArgs(provider: string, defaultArgs: string
349
362
  if (mode === "default") return "guarded";
350
363
  }
351
364
  return undefined;
352
- }
353
- if (provider === "codex") {
365
+ },
366
+ codex(allArgs) {
354
367
  if (allArgs.includes("--yolo")) return "open";
355
368
  for (let i = 0; i < allArgs.length; i++) {
356
369
  if (allArgs[i] === "-c" || allArgs[i] === "--config") {
@@ -360,8 +373,12 @@ export function detectApprovalModeFromArgs(provider: string, defaultArgs: string
360
373
  }
361
374
  }
362
375
  return undefined;
363
- }
364
- return undefined;
376
+ },
377
+ };
378
+
379
+ export function detectApprovalModeFromArgs(provider: string, defaultArgs: string[], providerArgs: string[]): string | undefined {
380
+ const detector = APPROVAL_MODE_DETECTORS[provider];
381
+ return detector ? detector([...defaultArgs, ...providerArgs]) : undefined;
365
382
  }
366
383
 
367
384
  if (import.meta.main) {
@@ -8,12 +8,13 @@ import {
8
8
  type RunnerSpawnConfig,
9
9
  } from "./adapter";
10
10
  import {
11
- claudeProjectConfigProjectionOn,
11
+ projectConfigProjectionOn,
12
12
  hostUserScopeMcpServers,
13
- profileUsesClaudeHostProviderGlobals,
13
+ profileUsesProviderHostGlobals,
14
14
  providerHomePathFor,
15
15
  providerProvisioningHomePathFor,
16
16
  } from "./profile-home";
17
+ import { getManifest } from "agent-relay-providers";
17
18
  import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
18
19
  import {
19
20
  materializeResolvedAssets,
@@ -229,7 +230,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
229
230
  const configHome = providerHomePathFor("claude", config);
230
231
  const assetHome = providerProvisioningHomePathFor("claude", config);
231
232
  const vanilla = profileIsVanillaBase(config);
232
- const projectConfigProjection = claudeProjectConfigProjectionOn(config);
233
+ const projectConfigProjection = projectConfigProjectionOn(config);
233
234
  const relayPlugin = profileAllowsRelayFeature(config, "plugins");
234
235
  const relaySkills = profileAllowsRelayFeature(config, "skills");
235
236
  const statusLine = profileAllowsRelayFeature(config, "statusLine");
@@ -248,7 +249,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
248
249
  // host plugin dirs → ONLY host-base profiles; isolated/vanilla must not pull them back in.
249
250
  const relayPluginDirs = !config.agentProfile && relayPlugin ? [CLAUDE_RELAY_PLUGIN_DIR] : [];
250
251
  const provisionedPluginDirs = provisionedPlugins.map((p) => p.dir);
251
- const hostPluginDirs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.pluginDirs : [];
252
+ const hostPluginDirs = profileUsesProviderHostGlobals(config) ? providerConfig.pluginDirs : [];
252
253
  const pluginDirs = [...new Set([...relayPluginDirs, ...provisionedPluginDirs, ...hostPluginDirs])];
253
254
 
254
255
  const provisionedMcp = profileProvisioning(config).mcpServers;
@@ -295,7 +296,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
295
296
  // still excluded) when the launch both uses the host config home AND its mcp mode is host.
296
297
  // An isolated/minimal base has its own config home and never had the real host's user MCP,
297
298
  // so it stays relay+provisioned only even if a profile sets mode:"host".
298
- const usesHostMcp = profileUsesClaudeHostProviderGlobals(config)
299
+ const usesHostMcp = profileUsesProviderHostGlobals(config)
299
300
  && (!config.agentProfile || config.agentProfile.mcp.mode === "host");
300
301
  const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
301
302
  const hostMcpServers = Object.keys(hostMcp).filter(
@@ -346,7 +347,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
346
347
  },
347
348
  instructions,
348
349
  args,
349
- env: configHome ? { CLAUDE_CONFIG_DIR: configHome } : {},
350
+ env: configHomeEnv("claude", configHome),
350
351
  };
351
352
  }
352
353
 
@@ -418,19 +419,58 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
418
419
  },
419
420
  instructions,
420
421
  args,
421
- env: configHome ? { CODEX_HOME: configHome } : {},
422
+ env: configHomeEnv("codex", configHome),
422
423
  };
423
424
  }
424
425
 
426
+ // ── Manifest-driven env + dispatch ────────────────────────────────────────────
427
+
428
+ function configHomeEnv(provider: string, configHome: string | undefined): Record<string, string> {
429
+ if (!configHome) return {};
430
+ const manifest = getManifest(provider);
431
+ const envVar = manifest?.home?.configDirEnvVar;
432
+ return envVar ? { [envVar]: configHome } : {};
433
+ }
434
+
435
+ type Assembler = (config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]) => AssembledLaunch;
436
+ type Materializer = (config: RunnerSpawnConfig) => void;
437
+
438
+ const ASSEMBLERS: Record<string, Assembler> = {
439
+ claude: assembleClaude,
440
+ codex: (config, providerConfig) => assembleCodex(config, providerConfig),
441
+ };
442
+
443
+ const MATERIALIZERS: Record<string, Materializer> = {
444
+ claude: materializeClaudeAssets,
445
+ codex: materializeCodexAssets,
446
+ };
447
+
448
+ function materializeClaudeAssets(config: RunnerSpawnConfig): void {
449
+ const configHome = providerHomePathFor("claude", config);
450
+ const assetHome = providerProvisioningHomePathFor("claude", config);
451
+ if (configHome) {
452
+ const skills = resolveClaudeProvisionedSkills(configHome, config);
453
+ materializeResolvedAssets(skills);
454
+ materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set(skills.map((s) => s.name))));
455
+ }
456
+ if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
457
+ }
458
+
459
+ function materializeCodexAssets(config: RunnerSpawnConfig): void {
460
+ const assetHome = providerProvisioningHomePathFor("codex", config);
461
+ if (assetHome) materializeResolvedAssets(resolveCodexProvisionedSkills(assetHome, config));
462
+ }
463
+
425
464
  /**
426
465
  * Assemble the launch for `provider` from the resolved profile + provisioning. PURE:
427
466
  * resolves which declared assets exist (filesystem reads) but writes nothing. A missing
428
467
  * `agentProfile` assembles the legacy default (host base, relay features on, no provisioning).
429
468
  */
430
469
  export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
431
- if (provider === "codex") return assembleCodex(config, providerConfig);
432
- const hostDefaultArgs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
433
- return assembleClaude(config, providerConfig, hostDefaultArgs);
470
+ const assembler = ASSEMBLERS[provider];
471
+ if (!assembler) throw new Error(`no launch assembler registered for provider: ${provider}`);
472
+ const hostDefaultArgs = profileUsesProviderHostGlobals(config) ? providerConfig.defaultArgs : [];
473
+ return assembler(config, providerConfig, hostDefaultArgs);
434
474
  }
435
475
 
436
476
  /**
@@ -443,14 +483,6 @@ export function materializeLaunchAssembly(provider: SpawnProvider, config: Runne
443
483
  const configHome = providerHomePathFor(provider, config);
444
484
  const assetHome = providerProvisioningHomePathFor(provider, config);
445
485
  if (!configHome && !assetHome) return;
446
- if (provider === "claude") {
447
- if (configHome) {
448
- const skills = resolveClaudeProvisionedSkills(configHome, config);
449
- materializeResolvedAssets(skills);
450
- materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set(skills.map((s) => s.name))));
451
- }
452
- if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
453
- return;
454
- }
455
- if (assetHome) materializeResolvedAssets(resolveCodexProvisionedSkills(assetHome, config));
486
+ const materializer = MATERIALIZERS[provider];
487
+ if (materializer) materializer(config);
456
488
  }
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
5
- import type { ProvisioningMcpServer } from "agent-relay-sdk";
5
+ import type { ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
6
+ import { getManifest } from "agent-relay-providers";
6
7
  import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
7
8
  import { CLAUDE_RELAY_MANUAL } from "./relay-instructions";
8
9
  import { providerHomeRootFromEnv } from "./config";
@@ -17,25 +18,31 @@ export function profileUsesHostProviderGlobals(config: { agentProfile?: RunnerSp
17
18
  return !config.agentProfile || config.agentProfile.base === "host";
18
19
  }
19
20
 
20
- export function claudeProjectConfigProjectionOn(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
21
+ export function projectConfigProjectionOn(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
21
22
  return Boolean(config.agentProfile?.projectSkills || config.agentProfile?.projectSettings);
22
23
  }
23
24
 
24
- export function profileUsesClaudeHostProviderGlobals(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
25
- return profileUsesHostProviderGlobals(config) && !claudeProjectConfigProjectionOn(config);
25
+ /** @deprecated Use {@link projectConfigProjectionOn}. */
26
+ export { projectConfigProjectionOn as claudeProjectConfigProjectionOn };
27
+
28
+ export function profileUsesProviderHostGlobals(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
29
+ return profileUsesHostProviderGlobals(config) && !projectConfigProjectionOn(config);
26
30
  }
27
31
 
28
- function profileRequiresIsolatedHome(provider: "claude" | "codex", config: RunnerSpawnConfig): boolean {
32
+ /** @deprecated Use {@link profileUsesProviderHostGlobals}. */
33
+ export { profileUsesProviderHostGlobals as profileUsesClaudeHostProviderGlobals };
34
+
35
+ function profileRequiresIsolatedHome(_provider: SpawnProvider, config: RunnerSpawnConfig): boolean {
29
36
  if (!config.agentProfile) return false;
30
37
  if (config.agentProfile.base !== "host") return true;
31
- return provider === "claude" && claudeProjectConfigProjectionOn(config);
38
+ return projectConfigProjectionOn(config);
32
39
  }
33
40
 
34
41
  // #557 — PURE: the isolated provider config home path a launch WILL use, or undefined
35
42
  // for a host-base profile (which uses the provider's normal home). The launch assembler
36
43
  // computes the home this way to report it + build CLAUDE_CONFIG_DIR/skill/plugin paths
37
44
  // without side effects; prepare*ProfileHome creates it at the same path at spawn time.
38
- export function providerHomePathFor(provider: "claude" | "codex", config: RunnerSpawnConfig): string | undefined {
45
+ export function providerHomePathFor(provider: SpawnProvider, config: RunnerSpawnConfig): string | undefined {
39
46
  if (!profileRequiresIsolatedHome(provider, config)) return undefined;
40
47
  return providerHomePath(provider, config);
41
48
  }
@@ -44,7 +51,7 @@ export function providerHomePathFor(provider: "claude" | "codex", config: Runner
44
51
  // host-base profiles, where the provider's real config home stays in use. This path is
45
52
  // only passed explicitly through launch args such as Codex skills.config or Claude
46
53
  // --plugin-dir; it does not imply CODEX_HOME/CLAUDE_CONFIG_DIR isolation.
47
- export function providerProvisioningHomePathFor(provider: "claude" | "codex", config: RunnerSpawnConfig): string | undefined {
54
+ export function providerProvisioningHomePathFor(provider: SpawnProvider, config: RunnerSpawnConfig): string | undefined {
48
55
  if (!config.agentProfile) return undefined;
49
56
  return providerHomePathFor(provider, config) ?? join(providerHomePath(provider, config), "provisioning");
50
57
  }
@@ -67,44 +74,45 @@ export function providerProvisioningHomePathFor(provider: "claude" | "codex", co
67
74
  // (claude-rig / host-base launches dodge all of this via onboarded host config +
68
75
  // --dangerously-skip-permissions; only isolated homes need the bootstrap.)
69
76
 
70
- // Host auth items symlinked into a fresh isolated home so the provider is
71
- // launch-ready without a re-login. One list per provider — single source.
72
- const CODEX_AUTH_ITEMS = ["auth.json", "installation_id"];
73
- const CLAUDE_AUTH_ITEMS = [".credentials.json", "statsig"];
77
+ // Provider-specific first-run bootstrap hooks, registered at module load.
78
+ type BootstrapHook = (home: string, config: RunnerSpawnConfig) => void;
79
+ const BOOTSTRAP_HOOKS = new Map<string, BootstrapHook>();
74
80
 
75
- // Shared skeleton for both providers: gate on isolated-profile, make the
76
- // instance-keyed home, run the provider-specific first-run bootstrap. The
77
- // bootstrap step is the only genuinely provider-specific part.
78
- function prepareProviderHome(provider: "claude" | "codex", config: RunnerSpawnConfig): ProviderHome | undefined {
81
+ // Shared skeleton: gate on isolated-profile, make the instance-keyed home,
82
+ // link auth items from the manifest, and run any registered first-run hook.
83
+ function prepareProviderHome(provider: SpawnProvider, config: RunnerSpawnConfig): ProviderHome | undefined {
79
84
  if (!profileRequiresIsolatedHome(provider, config)) return undefined;
80
85
  const target = providerHomePath(provider, config);
81
86
  mkdirSync(target, { recursive: true });
82
- const authLinked = provider === "codex"
83
- ? bootstrapCodexFirstRun(target, config)
84
- : bootstrapClaudeFirstRun(target, config);
87
+ const hook = BOOTSTRAP_HOOKS.get(provider);
88
+ if (hook) hook(target, config);
89
+ const manifest = getManifest(provider);
90
+ const authItems = manifest?.home?.authItems ?? [];
91
+ const sourceHome = providerSourceHome(provider);
92
+ const authLinked = linkExistingAuthItems(sourceHome, target, authItems);
85
93
  return { path: target, authLinked };
86
94
  }
87
95
 
88
96
  export const prepareCodexProfileHome = (config: RunnerSpawnConfig) => prepareProviderHome("codex", config);
89
97
  export const prepareClaudeProfileHome = (config: RunnerSpawnConfig) => prepareProviderHome("claude", config);
90
98
 
91
- function bootstrapCodexFirstRun(codexHome: string, config: RunnerSpawnConfig): string[] {
99
+ function bootstrapCodexFirstRun(codexHome: string, config: RunnerSpawnConfig): void {
92
100
  trustWorkspaceForCodex(codexHome, config);
93
- const sourceHome = process.env.CODEX_HOME || join(homedir(), ".codex");
94
- return linkExistingAuthItems(sourceHome, codexHome, CODEX_AUTH_ITEMS);
95
101
  }
96
102
 
97
- function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig): string[] {
98
- // Only inject the Relay usage manual when the profile actually wants a Relay
99
- // surface. An isolated-research profile (relay.context disabled) must not get
100
- // agent-relay communication instructions written into its config home.
103
+ function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig): void {
101
104
  if (profileAllowsRelayFeature(config, "context")) writeClaudeRelayManual(claudeHome);
102
105
  seedClaudeConfigIfMissing(claudeHome, config);
103
- // #554/#557 — relay-provisioned skills are materialized into <home>/skills by the launch
104
- // assembler (materializeLaunchAssembly), the single owner of all provisioned-asset writes;
105
- // the bootstrap only creates/auths the home so the assembler has somewhere to write.
106
- const sourceHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
107
- return linkExistingAuthItems(sourceHome, claudeHome, CLAUDE_AUTH_ITEMS);
106
+ }
107
+
108
+ BOOTSTRAP_HOOKS.set("codex", bootstrapCodexFirstRun);
109
+ BOOTSTRAP_HOOKS.set("claude", bootstrapClaudeFirstRun);
110
+
111
+ function providerSourceHome(provider: string): string {
112
+ const manifest = getManifest(provider);
113
+ const envVar = manifest?.home?.configDirEnvVar;
114
+ const configDir = manifest?.home?.configDir ?? `.${provider}`;
115
+ return (envVar && process.env[envVar]) || join(homedir(), configDir);
108
116
  }
109
117
 
110
118
  function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig): void {
@@ -139,7 +147,7 @@ export function hostUserScopeMcpServers(): Record<string, ProvisioningMcpServer>
139
147
  return servers as Record<string, ProvisioningMcpServer>;
140
148
  }
141
149
 
142
- function providerHomePath(provider: "claude" | "codex", config: RunnerSpawnConfig): string {
150
+ function providerHomePath(provider: SpawnProvider, config: RunnerSpawnConfig): string {
143
151
  const root = providerHomeRootFromEnv();
144
152
  const profileName = sanitizePathPart(config.agentProfile?.name || config.profile || "profile");
145
153
  const instance = sanitizePathPart(config.instanceId || config.runnerId);
@@ -1,4 +1,5 @@
1
1
  import type { AgentProfile, AgentProfileProjectionEntry, AgentProfileProjectionReport, SpawnProvider } from "agent-relay-sdk";
2
+ import { getManifest } from "agent-relay-providers";
2
3
  import type { AssembledLaunch } from "./launch-assembly";
3
4
 
4
5
  // #557 — the projection report DESCRIBES an AssembledLaunch, the same object the adapters
@@ -68,43 +69,52 @@ function relayContextEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
68
69
  }
69
70
 
70
71
  function relaySkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
71
- if (a.provider === "codex") {
72
+ const manifest = getManifest(a.provider);
73
+ const label = manifest?.label ?? a.provider;
74
+ const hasPluginSurface = Boolean(manifest?.provisioning?.bundledPlugin);
75
+ if (!hasPluginSurface) {
72
76
  return a.relaySkills
73
- ? applied("relay.skills", "enabled", "Bundled Agent Relay Codex skills are passed through Codex skills.config.")
74
- : applied("relay.skills", "disabled", "Bundled Agent Relay Codex skills are omitted.");
77
+ ? applied("relay.skills", "enabled", `Bundled Agent Relay ${label} skills are passed through the provider's skill delivery mechanism.`)
78
+ : applied("relay.skills", "disabled", `Bundled Agent Relay ${label} skills are omitted.`);
75
79
  }
76
80
  return a.relaySkills
77
- ? applied("relay.skills", "enabled", "Agent Relay Claude skills are available through the bundled Relay plugin.")
81
+ ? applied("relay.skills", "enabled", `Agent Relay ${label} skills are available through the bundled Relay plugin.`)
78
82
  : applied("relay.skills", "disabled", a.relayPlugin
79
- ? "Claude does not expose an independent Relay skill switch; disabling skills requires disabling the Relay plugin."
80
- : "Bundled Agent Relay Claude plugin dirs are omitted, so Relay plugin-provided skills are unavailable.");
83
+ ? `${label} does not expose an independent Relay skill switch; disabling skills requires disabling the Relay plugin.`
84
+ : `Bundled Agent Relay ${label} plugin dirs are omitted, so Relay plugin-provided skills are unavailable.`);
81
85
  }
82
86
 
83
87
  function relayPluginsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
84
- if (a.provider === "claude") {
88
+ const manifest = getManifest(a.provider);
89
+ const label = manifest?.label ?? a.provider;
90
+ const hasPluginSurface = Boolean(manifest?.provisioning?.bundledPlugin);
91
+ if (hasPluginSurface) {
85
92
  return a.relayPlugin
86
- ? applied("relay.plugins", "enabled", "Bundled Agent Relay Claude plugin dir is passed to Claude.")
87
- : applied("relay.plugins", "disabled", "Bundled Agent Relay Claude plugin dirs are omitted.");
93
+ ? applied("relay.plugins", "enabled", `Bundled Agent Relay ${label} plugin dir is passed to ${label}.`)
94
+ : applied("relay.plugins", "disabled", `Bundled Agent Relay ${label} plugin dirs are omitted.`);
88
95
  }
89
- return notApplicable("relay.plugins", a.relaySkills ? "enabled" : "disabled", "Codex Relay integration uses skills/app-server delivery, not a Relay plugin dir.");
96
+ return notApplicable("relay.plugins", a.relaySkills ? "enabled" : "disabled", `${label} Relay integration uses skills/app-server delivery, not a Relay plugin dir.`);
90
97
  }
91
98
 
92
99
  function relayStatusLineEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
93
- if (a.provider === "claude") {
94
- return a.statusLine
95
- ? applied("relay.statusLine", "enabled", "Agent Relay status-line context probe settings are injected when caller settings allow it.")
96
- : applied("relay.statusLine", "disabled", "Agent Relay status-line settings are omitted.");
100
+ const manifest = getManifest(a.provider);
101
+ const label = manifest?.label ?? a.provider;
102
+ if (a.statusLine) {
103
+ return applied("relay.statusLine", "enabled", "Agent Relay status-line context probe settings are injected when caller settings allow it.");
97
104
  }
98
- return notApplicable("relay.statusLine", "disabled", "Codex reports context through App Server events instead of a Relay status-line setting.");
105
+ const hasPluginSurface = Boolean(manifest?.provisioning?.bundledPlugin);
106
+ if (hasPluginSurface) {
107
+ return applied("relay.statusLine", "disabled", "Agent Relay status-line settings are omitted.");
108
+ }
109
+ return notApplicable("relay.statusLine", "disabled", `${label} reports context through provider-native events instead of a Relay status-line setting.`);
99
110
  }
100
111
 
101
112
  function repoInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
102
113
  const disposition = a.instructions.repo;
103
114
  if (disposition === "allow") return applied("instructions.repo", "allow", "Repo-local provider instructions are left available.");
104
115
  if (disposition === "suppressed") {
105
- return applied("instructions.repo", "ignore", a.provider === "claude"
106
- ? "Repo CLAUDE.md is suppressed: Claude launches with --setting-sources \"user\" (the project scope carrying repo CLAUDE.md is excluded; only the relay-built config home loads)."
107
- : "Repo AGENTS.md is suppressed: Codex launches with project_doc_max_bytes=0 (no project doc loaded).");
116
+ const label = getManifest(a.provider)?.label ?? a.provider;
117
+ return applied("instructions.repo", "ignore", `Repo project instructions are suppressed: ${label} launches with the vanilla base mechanism (provider-specific suppression of repo instructions).`);
108
118
  }
109
119
  return unsupported("instructions.repo", "ignore", "Repo instruction suppression requires the vanilla base; isolated/minimal do not enforce it at launch yet.");
110
120
  }
@@ -113,39 +123,55 @@ function repoInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry
113
123
  // provider mechanism the assembler emitted (Claude --setting-sources "user"; Codex
114
124
  // project_doc_max_bytes=0). OS-level admin/managed policy is beyond any client flag and is
115
125
  // called out so it isn't mistaken for a leak.
126
+ const VANILLA_MECHANISM: Record<string, { requested: string; detail: string }> = {
127
+ claude: {
128
+ requested: "--setting-sources user",
129
+ detail: "Claude launches the isolated config home under --setting-sources \"user\": host CLAUDE.md (repo + global), host skills/plugins/hooks, and host MCP (.mcp.json / ~/.claude.json) are all excluded; relay-provisioned assets in the home load. Auth, model, and built-in tools work normally. OS-level admin-managed policy still applies.",
130
+ },
131
+ codex: {
132
+ requested: "project_doc_max_bytes=0",
133
+ detail: "Codex launches with an isolated CODEX_HOME (no host config.toml MCP/hooks) and project_doc_max_bytes=0 (no AGENTS.md project doc, repo or global).",
134
+ },
135
+ };
136
+
116
137
  function vanillaLaunchEntry(provider: SpawnProvider): AgentProfileProjectionEntry {
117
- return provider === "claude"
118
- ? applied("provider.vanilla", "--setting-sources user", "Claude launches the isolated config home under --setting-sources \"user\": host CLAUDE.md (repo + global), host skills/plugins/hooks, and host MCP (.mcp.json / ~/.claude.json) are all excluded; relay-provisioned assets in the home load. Auth, model, and built-in tools work normally. OS-level admin-managed policy still applies.")
119
- : applied("provider.vanilla", "project_doc_max_bytes=0", "Codex launches with an isolated CODEX_HOME (no host config.toml MCP/hooks) and project_doc_max_bytes=0 (no AGENTS.md project doc, repo or global).");
138
+ const label = getManifest(provider)?.label ?? provider;
139
+ const mechanism = VANILLA_MECHANISM[provider];
140
+ return applied(
141
+ "provider.vanilla",
142
+ mechanism?.requested ?? "active",
143
+ mechanism?.detail ?? `${label} launches with the vanilla base: an isolated provider config home and provider-specific repo instruction suppression. Auth, model, and built-in tools work normally.`,
144
+ );
120
145
  }
121
146
 
122
147
  function provisioningSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
123
148
  const count = a.skills.length;
124
149
  if (!count) return notApplicable("provisioning.skills", "none", "No relay-provisioned skills materialized for this launch.");
125
150
  const names = ` (${a.skills.map((s) => s.name).join(", ")})`;
126
- return applied("provisioning.skills", `${count}`, a.provider === "claude"
127
- ? `Relay-provisioned skills materialized into the isolated config home skills/ dir, auto-discovered under --setting-sources "user"${names}.`
128
- : `Relay-provisioned skills added to Codex skills.config from the isolated CODEX_HOME${names}.`);
151
+ const label = getManifest(a.provider)?.label ?? a.provider;
152
+ return applied("provisioning.skills", `${count}`, `Relay-provisioned skills materialized for ${label} from the isolated provider config home${names}.`);
129
153
  }
130
154
 
131
155
  function provisioningPluginsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
132
- if (a.provider === "codex") {
156
+ const manifest = getManifest(a.provider);
157
+ const label = manifest?.label ?? a.provider;
158
+ const hasPluginSurface = Boolean(manifest?.provisioning?.bundledPlugin);
159
+ if (!hasPluginSurface) {
133
160
  const declared = (a.profile?.plugins ?? []).filter((p) => p.enabled).length;
134
- return notApplicable("provisioning.plugins", `${declared}`, "Codex has no --plugin-dir surface; provisioned plugins apply to Claude only.");
161
+ return notApplicable("provisioning.plugins", `${declared}`, `${label} has no plugin surface; provisioned plugins apply to plugin-capable providers only.`);
135
162
  }
136
163
  const count = a.plugins.length;
137
164
  if (!count) return notApplicable("provisioning.plugins", "none", "No relay-provisioned plugins materialized for this launch.");
138
165
  const names = ` (${a.plugins.map((p) => p.name).join(", ")})`;
139
- return applied("provisioning.plugins", `${count}`, `Relay-provisioned plugins passed to Claude via --plugin-dir${names}.`);
166
+ return applied("provisioning.plugins", `${count}`, `Relay-provisioned plugins passed to ${label} via the plugin dir mechanism${names}.`);
140
167
  }
141
168
 
142
169
  function provisioningMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
143
170
  const names = a.mcp.servers;
144
171
  if (!names.length) return notApplicable("provisioning.mcp", "none", "No relay-provisioned MCP servers materialized for this launch.");
145
- const label = `${names.length} server${names.length === 1 ? "" : "s"}`;
146
- return applied("provisioning.mcp", label, a.provider === "claude"
147
- ? `Relay-provisioned MCP servers composed into --mcp-config (${names.join(", ")})${a.mcp.strict ? "; --strict-mcp-config guarantees only these load" : ""}.`
148
- : `Relay-provisioned MCP servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
172
+ const count = `${names.length} server${names.length === 1 ? "" : "s"}`;
173
+ const providerLabel = getManifest(a.provider)?.label ?? a.provider;
174
+ return applied("provisioning.mcp", count, `Relay-provisioned MCP servers injected for ${providerLabel} (${names.join(", ")})${a.mcp.strict ? "; strict mode guarantees only declared servers load" : ""}.`);
149
175
  }
150
176
 
151
177
  function provisioningSkippedEntries(a: AssembledLaunch): AgentProfileProjectionEntry[] {
@@ -164,16 +190,14 @@ function projectMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
164
190
  const names = a.mcp.projectServers;
165
191
  if (!optedIn) return notApplicable("projectMcp", "off", "Project .mcp.json projection is off (opt in via the projectMcp profile/spawn option).");
166
192
  if (!names.length) return applied("projectMcp", "none", "Project .mcp.json projection is on, but the spawn cwd declared no injectable MCP servers.");
167
- const label = `${names.length} server${names.length === 1 ? "" : "s"}`;
168
- return applied("projectMcp", label, a.provider === "claude"
169
- ? `Project .mcp.json servers composed into --mcp-config (${names.join(", ")})${a.mcp.strict ? "; survives --strict-mcp-config" : ""}.`
170
- : `Project .mcp.json servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
193
+ const count = `${names.length} server${names.length === 1 ? "" : "s"}`;
194
+ return applied("projectMcp", count, `Project .mcp.json servers injected for this launch (${names.join(", ")})${a.mcp.strict ? "; survives strict MCP mode" : ""}.`);
171
195
  }
172
196
 
173
197
  function projectSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
174
198
  const optedIn = Boolean(a.profile?.projectSkills);
175
199
  if (!optedIn) return notApplicable("projectSkills", "off", "Project .claude/skills projection is off (opt in via the projectSkills profile/spawn option).");
176
- if (a.provider !== "claude") return notApplicable("projectSkills", "unsupported", "Project .claude/skills projection applies to Claude launches only.");
200
+ if (!getManifest(a.provider)?.provisioning?.bundledPlugin) return notApplicable("projectSkills", "unsupported", "Project skills projection applies to plugin-capable providers only.");
177
201
  if (!a.projectSkills.length) return applied("projectSkills", "none", "Project .claude/skills projection is on, but the spawn cwd declared no valid skill dirs.");
178
202
  const names = a.projectSkills.map((s) => s.name).join(", ");
179
203
  return applied("projectSkills", `${a.projectSkills.length}`, `Project .claude skills materialized into Relay's managed Claude config home (${names}); host/global skills are excluded.`);
@@ -182,7 +206,7 @@ function projectSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
182
206
  function projectSettingsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
183
207
  const optedIn = Boolean(a.profile?.projectSettings);
184
208
  if (!optedIn) return notApplicable("projectSettings", "off", "Project .claude/settings.json projection is off (opt in via the projectSettings profile/spawn option).");
185
- if (a.provider !== "claude") return notApplicable("projectSettings", "unsupported", "Project .claude/settings.json projection applies to Claude launches only.");
209
+ if (!getManifest(a.provider)?.provisioning?.bundledPlugin) return notApplicable("projectSettings", "unsupported", "Project settings projection applies to plugin-capable providers only.");
186
210
  const ignored = a.projectSettings.ignoredKeys.length ? `; ignored security keys: ${a.projectSettings.ignoredKeys.join(", ")}` : "";
187
211
  return applied("projectSettings", a.projectSettings.keys.length ? `${a.projectSettings.keys.length} keys` : "none", `Project .claude/settings.json was sanitized and injected through --settings; Relay approvalMode/permission gates remain authoritative${ignored}.`);
188
212
  }
@@ -194,9 +218,7 @@ function sharedMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
194
218
  const optedIn = Boolean(a.profile?.sharedMcp);
195
219
  const listener = a.mcp.sharedListener;
196
220
  if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off for this resolved profile; non-identity MCP is injected per-agent.");
197
- return applied("sharedMcp", "on", a.provider === "claude"
198
- ? `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"', injected into --mcp-config (survives --strict-mcp-config); session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`
199
- : `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"' emitted as -c mcp_servers.* overrides; session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`);
221
+ return applied("sharedMcp", "on", `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"'; session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent.`);
200
222
  }
201
223
 
202
224
  function globalInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
package/src/providers.ts CHANGED
@@ -8,11 +8,13 @@ interface RunnerProviderRegistration {
8
8
  createAdapter: () => ProviderAdapter;
9
9
  }
10
10
 
11
- const RUNNER_PROVIDERS: Record<SpawnProvider, RunnerProviderRegistration> = {
11
+ const RUNNER_PROVIDERS: Record<string, RunnerProviderRegistration> = {
12
12
  claude: { provider: "claude", createAdapter: () => new ClaudeAdapter() },
13
13
  codex: { provider: "codex", createAdapter: () => new CodexAdapter() },
14
14
  };
15
15
 
16
16
  export function createProviderAdapter(provider: SpawnProvider): ProviderAdapter {
17
- return RUNNER_PROVIDERS[provider].createAdapter();
17
+ const registration = RUNNER_PROVIDERS[provider];
18
+ if (!registration) throw new Error(`unsupported provider adapter: ${provider}`);
19
+ return registration.createAdapter();
18
20
  }
@@ -1,4 +1,5 @@
1
- import type { MessageSessionMeta, RelayInjectionCategory, SpawnProvider } from "agent-relay-sdk";
1
+ import type { MessageSessionMeta, RelayInjectionCategory } from "agent-relay-sdk";
2
+ import { isSpawnProvider } from "agent-relay-sdk";
2
3
  import type { ProviderConfig, RunnerSpawnConfig } from "./adapter";
3
4
  import { assembleLaunch } from "./launch-assembly";
4
5
 
@@ -30,13 +31,9 @@ interface RunnerLaunchInjectionInput {
30
31
  timestamp?: number;
31
32
  }
32
33
 
33
- function supportsLaunchAssembly(provider: string): provider is SpawnProvider {
34
- return provider === "claude" || provider === "codex";
35
- }
36
-
37
34
  function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInjectionEvent[] {
38
35
  const events: KeyedRelayInjectionEvent[] = (input.carried ?? []).map((event, index) => ({ key: `spawn-${index}`, event }));
39
- if (!supportsLaunchAssembly(input.provider)) return events;
36
+ if (!isSpawnProvider(input.provider)) return events;
40
37
  const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
41
38
  const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
42
39
  + assembled.projectSkills.length
@@ -11,13 +11,14 @@ import { computeLivenessSignal, type LivenessInputs } from "./liveness";
11
11
  import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
12
12
  import { messagesWithCachedAttachments } from "./attachment-cache";
13
13
  import { ClaimTracker } from "./claim-tracker";
14
- import { ClaudeQuotaHarvest } from "./claude-quota-harvest";
14
+ import { ClaudeQuotaHarvest } from "./adapters/claude-quota-harvest";
15
15
  import { readRunnerContextProbeState } from "./context-probe-state";
16
16
  import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
17
17
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
18
18
  import { Outbox, type OutboxRecord } from "./outbox";
19
19
  import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
20
- import { profileUsesClaudeHostProviderGlobals, profileUsesHostProviderGlobals } from "./profile-home";
20
+ import { getManifest } from "agent-relay-providers";
21
+ import { profileUsesProviderHostGlobals } from "./profile-home";
21
22
  import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
22
23
  import { RelayMcpProxy } from "./relay-mcp-proxy";
23
24
  import { runtimeMetadata } from "./version";
@@ -530,7 +531,7 @@ export class AgentRunner {
530
531
  private async spawnProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<ManagedProcess> {
531
532
  this.providerSessionId = crypto.randomUUID();
532
533
  this.lastTranscriptPath = undefined;
533
- const includeProviderGlobals = this.options.provider === "claude" ? profileUsesClaudeHostProviderGlobals(this.options) : profileUsesHostProviderGlobals(this.options);
534
+ const includeProviderGlobals = profileUsesProviderHostGlobals(this.options);
534
535
  const env = {
535
536
  ...process.env as Record<string, string>,
536
537
  ...(includeProviderGlobals ? this.options.providerConfig.env : {}),
@@ -874,7 +875,7 @@ export class AgentRunner {
874
875
  ...(answers ? { answers } : {}),
875
876
  };
876
877
  if (this.control?.resolvePermissionDecision(input)) {
877
- return { approvalId, decision, provider: "claude" };
878
+ return { approvalId, decision, provider: this.options.provider };
878
879
  }
879
880
  if (!this.process) throw new Error("provider process is unavailable");
880
881
  if (!this.options.adapter.respondToPermissionDecision) throw new Error("provider does not support permission decisions");
@@ -987,7 +988,7 @@ export class AgentRunner {
987
988
  });
988
989
 
989
990
  const nativeSelfResume = this.nativeSelfResume.active;
990
- if (nativeSelfResume && this.options.provider === "claude") {
991
+ if (nativeSelfResume && getManifest(this.options.provider)?.resume?.supported) {
991
992
  await recoverNativeSelfResumeExit({ handoff: nativeSelfResume, tracker: this.nativeSelfResume, providerSessionId: this.providerSessionId, status, diagnostics, now, publishTimeline: (event) => this.publishRunnerTimelineEvent(event), setTerminalProviderExit: (marker) => { this.terminalProviderExit = marker; }, setProviderStatus: (update) => this.setProviderStatus(update), restartProvider: (resumeId) => this.restartProvider(resumeId ? { resumeId } : { suppressPrompt: true }), publishStatus: () => this.publishStatus(), scheduleDrain: () => this.scheduleDrain() });
992
993
  return;
993
994
  }
@@ -1089,7 +1090,7 @@ export class AgentRunner {
1089
1090
  }
1090
1091
 
1091
1092
  private shouldStopUnexpectedProviderExit(diagnostics: Record<string, unknown>): boolean {
1092
- return this.options.provider === "claude" && diagnostics.exitCommandInProgress !== true;
1093
+ return getManifest(this.options.provider)?.launch?.managedPolicyPrompt?.alwaysOn === "append-system-prompt" && diagnostics.exitCommandInProgress !== true;
1093
1094
  }
1094
1095
 
1095
1096
  private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<void> {
@@ -1246,9 +1247,9 @@ export class AgentRunner {
1246
1247
  this.compactionMidTurn = false;
1247
1248
  this.busyReconciler.disarm();
1248
1249
  this.stopReasoningTail();
1249
- // #417: claude enforces obligations via the stop.sh hook; for every other provider
1250
- // re-inject any pending spawner obligation so it surfaces the /reply command.
1251
- if (this.options.provider !== "claude") void this.reInjectPendingObligation();
1250
+ // Providers with append-system-prompt policy use lifecycle hooks for obligation handling;
1251
+ // others need the runner to re-inject pending obligations on idle.
1252
+ if (getManifest(this.options.provider)?.launch?.managedPolicyPrompt?.alwaysOn !== "append-system-prompt") void this.reInjectPendingObligation();
1252
1253
  }
1253
1254
  // A genuine restart back to forward progress clears the terminal-exit marker so a later offline
1254
1255
  // doesn't re-announce a stale provider exit.
@@ -1,6 +1,8 @@
1
1
  import { closeSync, openSync, readSync, statSync } from "node:fs";
2
2
  import type { AgentProfile, ContextState, Message, ProviderCapabilities, ProviderState, QuotaState } from "agent-relay-sdk";
3
- import { errMessage } from "agent-relay-sdk";
3
+ import { errMessage, isSpawnProvider } from "agent-relay-sdk";
4
+ import { getManifest } from "agent-relay-providers";
5
+ import type { BooleanCapabilityDefault, ProviderCapabilityCondition } from "agent-relay-providers";
4
6
  import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
5
7
  import type { ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
6
8
  import { agentProfileProjectionReport } from "./profile-projection";
@@ -8,7 +10,6 @@ import { assembleLaunch } from "./launch-assembly";
8
10
 
9
11
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
10
12
  const LOG_TAIL_BYTES = 128 * 1024;
11
- const CLAUDE_RESUME_RE = /\bclaude\s+--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
12
13
  const RUNNER_CLAIMED_GUIDANCE_RE = /\s*Claim this task before working it, then update task status when finished\.\s*$/;
13
14
 
14
15
  interface RuntimeProviderOptions {
@@ -131,16 +132,24 @@ export function runnerShouldRestartUnexpectedProviderExit(
131
132
  && input.hasTerminalSession;
132
133
  }
133
134
 
134
- export function latestClaudeResumeIdFromText(text: string): string | undefined {
135
+ function resumeLogExtractionRe(provider: string): RegExp | undefined {
136
+ const manifest = getManifest(provider);
137
+ const resume = manifest?.resume as { supported: boolean; idPattern?: string } | undefined;
138
+ if (!resume?.supported || !resume.idPattern || !manifest?.probe?.command) return undefined;
139
+ return new RegExp(`\\b${manifest.probe.command}\\s+--resume\\s+(${resume.idPattern})\\b`, "gi");
140
+ }
141
+
142
+ export function latestResumeIdFromText(provider: string, text: string): string | undefined {
143
+ const re = resumeLogExtractionRe(provider);
144
+ if (!re) return undefined;
135
145
  let latest: string | undefined;
136
- CLAUDE_RESUME_RE.lastIndex = 0;
137
- for (let match = CLAUDE_RESUME_RE.exec(text); match; match = CLAUDE_RESUME_RE.exec(text)) {
146
+ for (let match = re.exec(text); match; match = re.exec(text)) {
138
147
  latest = match[1];
139
148
  }
140
149
  return latest;
141
150
  }
142
151
 
143
- export function latestClaudeResumeIdFromLogFile(path: string): string | undefined {
152
+ export function latestResumeIdFromLogFile(provider: string, path: string): string | undefined {
144
153
  let fd: number | undefined;
145
154
  try {
146
155
  const stat = statSync(path);
@@ -149,7 +158,7 @@ export function latestClaudeResumeIdFromLogFile(path: string): string | undefine
149
158
  const buffer = Buffer.alloc(length);
150
159
  fd = openSync(path, "r");
151
160
  readSync(fd, buffer, 0, length, offset);
152
- return latestClaudeResumeIdFromText(buffer.toString("utf8"));
161
+ return latestResumeIdFromText(provider, buffer.toString("utf8"));
153
162
  } catch {
154
163
  return undefined;
155
164
  } finally {
@@ -173,7 +182,7 @@ export function providerExitDiagnostics(input: {
173
182
  const tmuxSession = typeof input.processMeta?.tmuxSession === "string" ? input.processMeta.tmuxSession : undefined;
174
183
  const tmuxSocket = typeof input.processMeta?.tmuxSocket === "string" ? input.processMeta.tmuxSocket : undefined;
175
184
  const exitSource = tmuxSession ? "tmux-session-ended" : input.hasProcess ? "process-exit" : "provider-status";
176
- const claudeResumeId = input.provider === "claude" && input.logFile ? latestClaudeResumeIdFromLogFile(input.logFile) : undefined;
185
+ const claudeResumeId = getManifest(input.provider)?.resume?.supported && input.logFile ? latestResumeIdFromLogFile(input.provider, input.logFile) : undefined;
177
186
  return {
178
187
  status: input.status,
179
188
  runtimeMs: Number.isFinite(input.runtimeMs) ? input.runtimeMs : null,
@@ -225,18 +234,39 @@ export function lifecycleCapabilities(): Record<string, true> {
225
234
  };
226
235
  }
227
236
 
237
+ function resolveCondition(condition: ProviderCapabilityCondition, options: RuntimeProviderOptions): boolean {
238
+ switch (condition) {
239
+ case "always": return true;
240
+ case "never": return false;
241
+ case "headless": return options.headless;
242
+ case "interactive": return !options.headless;
243
+ case "approval:read-only": return options.approvalMode === "read-only";
244
+ case "approval:not-read-only": return options.approvalMode !== "read-only";
245
+ case "adapter:compact-supports-instructions": return Boolean(options.adapter.compactSupportsInstructions);
246
+ case "adapter:clear-context": return Boolean(options.adapter.clearContext);
247
+ default: return false;
248
+ }
249
+ }
250
+
251
+ function resolveBooleanDefault(def: BooleanCapabilityDefault, options: RuntimeProviderOptions): boolean {
252
+ if (typeof def === "boolean") return def;
253
+ return resolveCondition(def.when, options) ? def.value : def.otherwise;
254
+ }
255
+
228
256
  export function runtimeProviderCapabilities(options: RuntimeProviderOptions, contextState?: ContextState, probeModel?: ProbeModelInfo, quotaState?: QuotaState): ProviderCapabilities {
257
+ const manifest = getManifest(options.provider);
258
+ const capDefaults = manifest?.capabilityDefaults;
229
259
  const model = options.model ?? probeModel?.model;
230
260
  const effort = options.effort ?? probeModel?.effort;
231
261
  const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
232
262
  const shellMode = runtimeShellMode(options);
233
263
  return {
234
264
  lifecycle: {
235
- managed: true,
236
- shutdownHard: options.provider === "claude" ? options.headless : true,
237
- restartHard: options.provider === "claude" ? options.headless : true,
238
- semanticStatus: true,
239
- reconnect: true,
265
+ managed: capDefaults?.lifecycle?.managed ?? true,
266
+ shutdownHard: capDefaults?.lifecycle?.shutdownHard !== undefined ? resolveBooleanDefault(capDefaults.lifecycle.shutdownHard, options) : true,
267
+ restartHard: capDefaults?.lifecycle?.restartHard !== undefined ? resolveBooleanDefault(capDefaults.lifecycle.restartHard, options) : true,
268
+ semanticStatus: capDefaults?.lifecycle?.semanticStatus ?? true,
269
+ reconnect: capDefaults?.lifecycle?.reconnect ?? true,
240
270
  },
241
271
  model: {
242
272
  provider: options.provider,
@@ -266,7 +296,7 @@ export function runtimeProviderCapabilities(options: RuntimeProviderOptions, con
266
296
  interrupt: Boolean(options.adapter.interrupt),
267
297
  promptEcho: true,
268
298
  reasoning: true,
269
- slashCommands: options.provider === "claude" || options.provider === "codex",
299
+ slashCommands: capDefaults?.liveSession?.slashCommands ?? false,
270
300
  },
271
301
  source: "runtime",
272
302
  confidence: "reported",
@@ -275,34 +305,23 @@ export function runtimeProviderCapabilities(options: RuntimeProviderOptions, con
275
305
  }
276
306
 
277
307
  function runtimeShellMode(options: RuntimeProviderOptions): NonNullable<NonNullable<ProviderCapabilities["session"]>["shellMode"]> {
278
- if (options.approvalMode === "read-only") {
279
- return options.provider === "claude" ? "read-only-guarded" : "none";
280
- }
281
- return options.approvalMode === "open" ? "unrestricted" : "guarded";
308
+ const manifest = getManifest(options.provider);
309
+ const shellModeMap = manifest?.capabilityDefaults?.session?.shellMode;
310
+ if (options.approvalMode === "read-only") return (shellModeMap?.readOnly ?? "none") as NonNullable<NonNullable<ProviderCapabilities["session"]>["shellMode"]>;
311
+ if (options.approvalMode === "open") return (shellModeMap?.open ?? "unrestricted") as NonNullable<NonNullable<ProviderCapabilities["session"]>["shellMode"]>;
312
+ return (shellModeMap?.guarded ?? "guarded") as NonNullable<NonNullable<ProviderCapabilities["session"]>["shellMode"]>;
282
313
  }
283
314
 
284
315
  function runtimeProviderTerminalCapabilities(options: RuntimeProviderOptions): Pick<ProviderCapabilities, "terminal"> {
285
- if (options.provider === "claude" && options.headless) {
286
- return {
287
- terminal: {
288
- live: {
289
- read: true,
290
- write: true,
291
- },
292
- },
293
- };
316
+ const manifest = getManifest(options.provider);
317
+ const termDefaults = manifest?.capabilityDefaults?.terminal;
318
+ if (!termDefaults || termDefaults.mode === "none") return {};
319
+ if (termDefaults.condition && !resolveCondition(termDefaults.condition, options)) return {};
320
+ if (termDefaults.mode === "live" && termDefaults.live) {
321
+ return { terminal: { live: termDefaults.live } };
294
322
  }
295
- if (options.provider === "codex") {
296
- return {
297
- terminal: {
298
- attach: {
299
- create: true,
300
- read: true,
301
- write: true,
302
- detach: true,
303
- },
304
- },
305
- };
323
+ if (termDefaults.mode === "attach" && termDefaults.attach) {
324
+ return { terminal: { attach: termDefaults.attach } };
306
325
  }
307
326
  return {};
308
327
  }
@@ -316,7 +335,7 @@ export function appliedAgentProfileMetadata(
316
335
  config?: RunnerSpawnConfig,
317
336
  providerConfig?: ProviderConfig,
318
337
  ): Record<string, unknown> {
319
- const projection = (provider === "claude" || provider === "codex") && config && providerConfig
338
+ const projection = isSpawnProvider(provider) && config && providerConfig
320
339
  ? agentProfileProjectionReport({ assembled: assembleLaunch(provider, config, providerConfig) })
321
340
  : undefined;
322
341
  return {
@@ -340,29 +359,30 @@ export function appliedAgentProfileMetadata(
340
359
  }
341
360
 
342
361
  function runtimeProviderContextCapabilities(options: RuntimeProviderOptions, contextState?: ContextState): Pick<ProviderCapabilities, "context"> {
362
+ const manifest = getManifest(options.provider);
363
+ const ctxDefaults = manifest?.capabilityDefaults?.context;
343
364
  const context: NonNullable<ProviderCapabilities["context"]> = { resume: "none" };
344
- const supportsManagedContext = options.provider === "codex" || (options.provider === "claude" && options.headless);
365
+ const supportsManagedContext = ctxDefaults?.managed ? resolveCondition(ctxDefaults.managed, options) : false;
345
366
  if (contextState) {
346
367
  context.stats = { source: contextState.source, confidence: contextState.confidence };
347
368
  if (typeof contextState.tokensMax === "number" && Number.isFinite(contextState.tokensMax)) {
348
369
  context.windowTokens = contextState.tokensMax;
349
370
  }
350
371
  }
351
- if (supportsManagedContext) {
352
- context.compact = true;
353
- context.clear = true;
372
+ if (ctxDefaults) {
373
+ if (resolveBooleanDefault(ctxDefaults.compact, options)) context.compact = true;
374
+ if (resolveBooleanDefault(ctxDefaults.clear, options)) context.clear = true;
375
+ context.inject = ctxDefaults.inject;
354
376
  }
355
- context.inject = true;
356
377
  if (supportsManagedContext && options.adapter.compact && options.adapter.compactSupportsInstructions) context.resume = "native";
357
378
  else if (supportsManagedContext && options.adapter.clearContext) context.resume = "clear-inject";
358
379
  return Object.keys(context).length ? { context } : {};
359
380
  }
360
381
 
361
382
  function runtimeProviderQuotaCapabilities(options: RuntimeProviderOptions, quotaState?: QuotaState): Pick<ProviderCapabilities, "quota"> {
362
- if (options.provider !== "claude" && options.provider !== "codex") return {};
363
- const fallbackWindows = options.provider === "claude"
364
- ? [{ name: "five_hour", unit: "%" as const, reset: true }, { name: "seven_day", unit: "%" as const, reset: true }]
365
- : [{ name: "primary", unit: "%" as const, reset: true }, { name: "secondary", unit: "%" as const, reset: true }];
383
+ const manifest = getManifest(options.provider);
384
+ const quotaDefaults = manifest?.capabilityDefaults?.quota;
385
+ if (!quotaDefaults?.supported) return {};
366
386
  return {
367
387
  quota: {
368
388
  supported: true,
@@ -372,7 +392,7 @@ function runtimeProviderQuotaCapabilities(options: RuntimeProviderOptions, quota
372
392
  name: window.name,
373
393
  unit: window.unit,
374
394
  reset: window.resetsAt !== undefined,
375
- })) ?? fallbackWindows,
395
+ })) ?? manifest!.quotaWindows.map(w => ({ name: w.name, unit: w.unit as "%" | "tokens" | "requests", reset: w.reset })),
376
396
  },
377
397
  };
378
398
  }
package/src/runner.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { AgentRunner } from "./runner-core";
2
2
  export {
3
- latestClaudeResumeIdFromText,
3
+ latestResumeIdFromText,
4
4
  runnerAgentStatus,
5
5
  runnerBusErrorAction,
6
6
  runnerMessageMatches,
package/src/version.ts CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { CONTRACT_VERSIONS } from "agent-relay-sdk";
5
+ import { getManifest } from "agent-relay-providers";
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
@@ -26,6 +27,7 @@ export const RUNTIME_CAPABILITIES = {
26
27
  } as const;
27
28
 
28
29
  export function runtimeMetadata(provider?: string) {
30
+ const manifest = provider ? getManifest(provider) : undefined;
29
31
  return {
30
32
  package: {
31
33
  name: PACKAGE_NAME,
@@ -34,8 +36,8 @@ export function runtimeMetadata(provider?: string) {
34
36
  contracts: CONTRACTS,
35
37
  capabilities: {
36
38
  ...RUNTIME_CAPABILITIES,
37
- ...(provider === "claude" ? { bundledClaudePlugin: true } : {}),
38
- ...(provider === "codex" ? { bundledCodexSkills: true } : {}),
39
+ ...(manifest?.provisioning?.bundledPlugin ? { bundledPlugin: true } : {}),
40
+ ...(manifest?.provisioning?.bundledSkills ? { bundledSkills: true } : {}),
39
41
  },
40
42
  };
41
43
  }