agent-relay-runner 0.122.1 → 0.123.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.122.1",
3
+ "version": "0.123.1",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,8 +20,8 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-providers": "0.104.3",
24
- "agent-relay-sdk": "0.2.114",
23
+ "agent-relay-providers": "0.104.4",
24
+ "agent-relay-sdk": "0.2.115",
25
25
  "callmux": "0.23.0"
26
26
  },
27
27
  "devDependencies": {
@@ -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.122.1",
4
+ "version": "0.123.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -12,7 +12,7 @@ import type { LivenessSignal } from "agent-relay-sdk";
12
12
  import { collectClaudeSessionEvents } from "./claude-transcript";
13
13
  import type { SessionEvent } from "../session-insights";
14
14
  import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
15
- import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
15
+ import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs, stripSettingsArgs } from "../launch-assembly";
16
16
  import { claudeProviderMessageText } from "./claude-delivery";
17
17
  import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
18
18
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
@@ -294,10 +294,13 @@ export class ClaudeAdapter implements ProviderAdapter {
294
294
  const providerArgs = config.headless
295
295
  ? claudeLaunchArgs(defaultArgs, config.providerArgs, config.approvalMode)
296
296
  : [...defaultArgs, ...config.providerArgs];
297
+ // #1096 — when the assembler merged the caller's own `--settings` into relay's (so the
298
+ // baseline hooks/projectSettings/statusLine survive), drop the caller's now-redundant
299
+ // `--settings` here so exactly ONE reaches the launch (relay's merged copy in assembled.args).
297
300
  const args = [
298
301
  ...rigPrefix,
299
302
  ...assembled.args,
300
- ...providerArgs,
303
+ ...(assembled.callerSettingsConsumed ? stripSettingsArgs(providerArgs) : providerArgs),
301
304
  ];
302
305
  if (config.model) args.push("--model", config.model);
303
306
  // #352: seed the prompt as Claude's positional arg for ALL launches (headless included) —
@@ -0,0 +1,63 @@
1
+ // #1096 review, finding 4 — Codex hooks (CODEX_HOME/hooks.json with Claude-compatible
2
+ // event names) are only consumed by Codex >= 0.124. An older Codex silently ignores the
3
+ // file, so writing it and reporting `provisioning.hooks: applied` would be a lie. This
4
+ // module probes the installed Codex version once and answers whether hook delivery is
5
+ // actually honored, so the assembler can emit a truthful projection (a LOUD warning when
6
+ // the guard cannot fire) instead of a silent absence.
7
+ //
8
+ // TODO(#1096): a post-deploy LIVE check is still owed — assert that a Codex >= 0.124 actually
9
+ // executes CODEX_HOME/hooks.json for these event names (Stop/PreToolUse/…), not just that the
10
+ // version string clears the gate. The version probe is necessary but not sufficient.
11
+
12
+ import { codexVersionOverrideFromEnv } from "./config";
13
+
14
+ // Codex releases from this version on ship stable hooks with Claude-compatible events.
15
+ const CODEX_HOOKS_MIN_VERSION = [0, 124, 0] as const;
16
+
17
+ let cachedProbe: boolean | undefined;
18
+
19
+ export function codexVersionSupportsHooks(version: string): boolean {
20
+ const parts = version.split(".").map((n) => parseInt(n, 10));
21
+ const major = Number.isFinite(parts[0]) ? parts[0]! : 0;
22
+ const minor = Number.isFinite(parts[1]) ? parts[1]! : 0;
23
+ const patch = Number.isFinite(parts[2]) ? parts[2]! : 0;
24
+ const [rMajor, rMinor, rPatch] = CODEX_HOOKS_MIN_VERSION;
25
+ if (major !== rMajor) return major > rMajor;
26
+ if (minor !== rMinor) return minor > rMinor;
27
+ return patch >= rPatch;
28
+ }
29
+
30
+ export function parseCodexVersion(output: string): string | null {
31
+ const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
32
+ return match ? match[0] : null;
33
+ }
34
+
35
+ function probeCodexVersion(command: string): string | null {
36
+ try {
37
+ const result = Bun.spawnSync([command, "--version"], { stdout: "pipe", stderr: "ignore" });
38
+ if (result.exitCode !== 0) return null;
39
+ return parseCodexVersion(result.stdout.toString());
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ // Whether the installed Codex actually consumes managed hooks. An explicit
46
+ // AGENT_RELAY_CODEX_VERSION override wins (deterministic tests + operator escape hatch);
47
+ // otherwise the real `codex --version` is probed once and cached. An UNKNOWN version (probe
48
+ // failed / Codex not installed) is treated as supported: we neither block launches nor cry
49
+ // wolf on a machine that can't be inspected — we only claim "not applied" when we can PROVE
50
+ // the version is too old.
51
+ export function codexHooksSupported(command = "codex"): boolean {
52
+ const override = codexVersionOverrideFromEnv();
53
+ if (override !== undefined) return override.trim() === "" ? true : codexVersionSupportsHooks(override.trim());
54
+ if (cachedProbe !== undefined) return cachedProbe;
55
+ const version = probeCodexVersion(command);
56
+ cachedProbe = version ? codexVersionSupportsHooks(version) : true;
57
+ return cachedProbe;
58
+ }
59
+
60
+ // Test-only: drop the memoized probe so a suite can re-probe under a changed environment.
61
+ export function resetCodexHooksProbeCache(): void {
62
+ cachedProbe = undefined;
63
+ }
package/src/config.ts CHANGED
@@ -82,6 +82,13 @@ function relayUrlFromEnv(): string | undefined {
82
82
  return process.env.AGENT_RELAY_URL;
83
83
  }
84
84
 
85
+ // #1096 — override for the Codex hooks version gate (finding 4). An explicit version string
86
+ // bypasses the `codex --version` probe: deterministic in tests, and an operator escape hatch
87
+ // when the probe can't run. Empty string means "assume supported".
88
+ export function codexVersionOverrideFromEnv(): string | undefined {
89
+ return process.env.AGENT_RELAY_CODEX_VERSION;
90
+ }
91
+
85
92
  function textEnvOrFile(name: string): string | undefined {
86
93
  const inline = process.env[name];
87
94
  if (inline) return inline;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { resolve, join } from "node:path";
3
- import type { AgentProfile, AgentProfileBase, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
3
+ import type { AgentProfile, AgentProfileBase, ProvisioningHookSet, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
4
4
  import { shellQuote } from "agent-relay-sdk/shell-utils";
5
5
  import {
6
6
  profileAllowsRelayFeature,
@@ -17,17 +17,22 @@ import {
17
17
  providerProvisioningHomePathFor,
18
18
  } from "./profile-home";
19
19
  import { getManifest } from "agent-relay-providers";
20
+ import { codexHooksSupported } from "./codex-version";
20
21
  import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
21
22
  import {
22
23
  materializeResolvedAssets,
24
+ renderProvisionedHookSet,
23
25
  resolveClaudeProjectSkills,
24
26
  resolveClaudeHostSkills,
25
27
  resolveProjectClaudeSettings,
26
28
  resolveClaudeProvisionedPlugins,
27
29
  resolveClaudeProvisionedSkills,
28
30
  resolveCodexProvisionedSkills,
31
+ resolveProvisionedHooks,
29
32
  resolveProjectMcpServers,
30
33
  profileProvisioning,
34
+ writeCodexHooksJson,
35
+ writeClaudeSettingsJson,
31
36
  } from "./provisioning";
32
37
  import {
33
38
  claudeMcpLaunchArgs,
@@ -134,6 +139,20 @@ export interface AssembledLaunch {
134
139
  hostSkills: AssembledAsset[];
135
140
  /** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
136
141
  projectSettings: { applied: boolean; keys: string[]; ignoredKeys: string[]; file?: string };
142
+ /** Relay-provisioned hooks materialized for this launch. Empty when hooks were resolved
143
+ * but could not be delivered — see `hooksSuppressed` (the anti-lie invariant, #1096). */
144
+ hooks: AssembledAsset[];
145
+ /** #1096 review — set when this launch RESOLVED provisioned hooks (incl. a baseline safety
146
+ * guard) that could NOT be delivered: caller `--settings` was unmergeable (Claude), a
147
+ * host-base Codex agent has no managed CODEX_HOME, or the installed Codex is too old to
148
+ * consume hooks.json. Drives a LOUD projection warning so a baseline guard is never
149
+ * SILENTLY absent while the report says "applied". */
150
+ hooksSuppressed?: { reason: string; names: string[] };
151
+ /** #1096 review — Claude only: true when the caller's own `--settings` was folded into the
152
+ * relay-emitted `--settings` (relay hooks/projectSettings/statusLine merged in). The adapter
153
+ * must then STRIP the caller's `--settings` from the provider args so exactly one reaches the
154
+ * launch. False when relay emitted its own settings standalone or added none. */
155
+ callerSettingsConsumed: boolean;
137
156
  /** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
138
157
  plugins: AssembledAsset[];
139
158
  /** Host/global provider plugin dirs explicitly inherited without enabling host MCP. */
@@ -196,17 +215,137 @@ function claudeStatusLineProbePath(): string {
196
215
  return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
197
216
  }
198
217
 
218
+ export interface ClaudeSettingsResolution {
219
+ args: string[];
220
+ /** The caller's own --settings was merged in and must be stripped from the provider args. */
221
+ callerSettingsConsumed: boolean;
222
+ /** Provisioned hook names that could NOT be delivered (caller --settings unmergeable). */
223
+ suppressedHookNames: string[];
224
+ }
225
+
226
+ // #1096 review, finding 1 — when the caller (spawn providerArgs / provider defaultArgs) already
227
+ // carries `--settings`, DO NOT silently drop relay's settings (which used to skip the baseline
228
+ // safety hooks + projectSettings + statusLine while projection still claimed "applied"). Merge
229
+ // relay's settings into the caller's INLINE JSON so the guard survives, and signal the adapter to
230
+ // strip the caller's now-redundant `--settings`. If the caller's `--settings` is a FILE PATH or
231
+ // malformed JSON (unmergeable), relay's provisioned hooks are reported as SUPPRESSED (a loud
232
+ // projection warning) rather than a silent absence with an "applied" lie.
199
233
  function claudeSettingsArgs(
200
234
  projectSettings: Record<string, unknown>,
235
+ provisionedHooks: ProvisioningHookSet,
236
+ hookNames: string[],
201
237
  includeStatusLine: boolean,
202
238
  ...argLists: string[][]
203
- ): string[] {
204
- if (argLists.some(hasSettingsArg)) return [];
205
- const settings = {
239
+ ): ClaudeSettingsResolution {
240
+ const relaySettings: Record<string, unknown> = {
206
241
  ...projectSettings,
242
+ ...(Object.keys(provisionedHooks).length ? { hooks: provisionedHooks } : {}),
207
243
  ...(includeStatusLine ? relayStatusLineSettings() : {}),
208
244
  };
209
- return Object.keys(settings).length ? ["--settings", JSON.stringify(settings)] : [];
245
+ const hasProvisionedHooks = Object.keys(provisionedHooks).length > 0;
246
+ const caller = extractCallerSettings(argLists);
247
+ if (!caller) {
248
+ return {
249
+ args: Object.keys(relaySettings).length ? ["--settings", JSON.stringify(relaySettings)] : [],
250
+ callerSettingsConsumed: false,
251
+ suppressedHookNames: [],
252
+ };
253
+ }
254
+ if (caller.settings) {
255
+ const merged = mergeClaudeSettings(caller.settings, relaySettings);
256
+ return { args: ["--settings", JSON.stringify(merged)], callerSettingsConsumed: true, suppressedHookNames: [] };
257
+ }
258
+ // Unmergeable caller --settings (a file path or malformed JSON): relay cannot fold its
259
+ // settings in without clobbering the caller's file, so the provisioned hooks don't reach
260
+ // the launch. Report them suppressed (loud) — never claim "applied".
261
+ return { args: [], callerSettingsConsumed: false, suppressedHookNames: hasProvisionedHooks ? hookNames : [] };
262
+ }
263
+
264
+ // The caller's `--settings` value, parsed as inline JSON when possible. `settings` is null for a
265
+ // file-path / malformed value (an inline object is required to merge relay settings in).
266
+ function extractCallerSettings(argLists: string[][]): { settings: Record<string, unknown> | null } | null {
267
+ for (const list of argLists) {
268
+ for (let i = 0; i < list.length; i += 1) {
269
+ const arg = list[i];
270
+ if (arg === "--settings") return { settings: parseInlineSettings(list[i + 1]) };
271
+ if (arg?.startsWith("--settings=")) return { settings: parseInlineSettings(arg.slice("--settings=".length)) };
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
277
+ function parseInlineSettings(value: string | undefined): Record<string, unknown> | null {
278
+ if (!value) return null;
279
+ const trimmed = value.trim();
280
+ if (!trimmed.startsWith("{")) return null; // a settings file path, not inline JSON
281
+ try {
282
+ const parsed = JSON.parse(trimmed);
283
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ // Merge relay settings into the caller's inline settings. The safety-critical `hooks` map is
290
+ // UNIONED per event (caller entries first, relay's baseline guard appended) so both fire; every
291
+ // other relay key is fill-if-absent (the caller's explicit value wins), preserving the historical
292
+ // "caller --settings wins" spirit for non-safety settings.
293
+ function mergeClaudeSettings(caller: Record<string, unknown>, relay: Record<string, unknown>): Record<string, unknown> {
294
+ const out: Record<string, unknown> = { ...caller };
295
+ for (const [key, value] of Object.entries(relay)) {
296
+ if (key === "hooks") out.hooks = mergeHookSets(caller.hooks, value);
297
+ else if (!(key in out)) out[key] = value;
298
+ }
299
+ return out;
300
+ }
301
+
302
+ function mergeHookSets(caller: unknown, relay: unknown): ProvisioningHookSet {
303
+ const base: ProvisioningHookSet = isHookSet(caller) ? { ...caller } : {};
304
+ if (!isHookSet(relay)) return base;
305
+ for (const [event, entries] of Object.entries(relay)) {
306
+ base[event] = [...(base[event] ?? []), ...entries];
307
+ }
308
+ return base;
309
+ }
310
+
311
+ function isHookSet(value: unknown): value is ProvisioningHookSet {
312
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
313
+ }
314
+
315
+ // Strip every `--settings <json>` / `--settings=<json>` occurrence from a provider arg list. The
316
+ // adapter applies this to the caller args once the assembler has folded them into its own merged
317
+ // `--settings` (callerSettingsConsumed), so exactly one `--settings` reaches the launch (#1096).
318
+ export function stripSettingsArgs(args: string[]): string[] {
319
+ const result: string[] = [];
320
+ for (let i = 0; i < args.length; i += 1) {
321
+ const arg = args[i];
322
+ if (arg === "--settings") { i += 1; continue; }
323
+ if (arg?.startsWith("--settings=")) continue;
324
+ if (arg !== undefined) result.push(arg);
325
+ }
326
+ return result;
327
+ }
328
+
329
+ // #1096 — the settings.json relay WRITES into an isolated managed Claude home so the provisioned
330
+ // hooks (baseline guard included) land in the DOCUMENTED hook source Claude honors
331
+ // ($CLAUDE_CONFIG_DIR/settings.json — loaded under `--setting-sources "user"` and the default),
332
+ // rather than riding ONLY the inline `--settings` CLI flag. Claude does not document `--settings`
333
+ // as a hook source, and — unlike a file written straight to disk — that flag's JSON must survive
334
+ // shell/tmux launcher transit and passes Claude's "silently ignore settings that fail validation"
335
+ // gate; a written settings.json bypasses both, so the baseline guard can't be silently absent
336
+ // (the #1096 inert-at-runtime symptom). Returns null when:
337
+ // • there is NO isolated managed home (host base reuses the real ~/.claude — relay must not
338
+ // clobber it; that path keeps the inline --settings delivery), or
339
+ // • a caller --settings is present (the merge/suppression path owns hook delivery inline; see
340
+ // claudeSettingsArgs) — so the file and inline channels never DOUBLE-register the same hook.
341
+ // Both assembleClaude (to keep these hooks OUT of the inline --settings) and materializeClaudeAssets
342
+ // (to write the file) call this, so the launch and the disk agree.
343
+ export function claudeManagedHookSettings(config: RunnerSpawnConfig, configHome: string | undefined): { hooks: ProvisioningHookSet } | null {
344
+ if (!configHome) return null;
345
+ const hostDefaultArgs = profileUsesProviderHostGlobals(config) ? config.providerConfig.defaultArgs : [];
346
+ if (hasSettingsArg(hostDefaultArgs) || hasSettingsArg(config.providerArgs)) return null;
347
+ const hookSet = renderProvisionedHookSet(resolveProvisionedHooks(configHome, config));
348
+ return Object.keys(hookSet).length ? { hooks: hookSet } : null;
210
349
  }
211
350
 
212
351
  // ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
@@ -255,6 +394,30 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
255
394
  : [];
256
395
  const projectSettings = resolveProjectClaudeSettings(config);
257
396
  const provisionedPlugins = assetHome ? resolveClaudeProvisionedPlugins(assetHome, config) : [];
397
+ const hookHome = configHome ?? assetHome;
398
+ const provisionedHooks = hookHome ? resolveProvisionedHooks(hookHome, config) : [];
399
+ const provisionedHookSet = renderProvisionedHookSet(provisionedHooks);
400
+ // #1096 — for an isolated managed home with no caller --settings, the provisioned hooks are
401
+ // delivered through the managed settings.json (the documented, transit-safe hook source), so
402
+ // keep them OUT of the inline --settings to avoid double-registering the same guard. Host-base
403
+ // and caller-`--settings` launches keep inline delivery (see claudeManagedHookSettings).
404
+ const managedHookSettings = claudeManagedHookSettings(config, configHome);
405
+ const inlineHookSet = managedHookSettings ? {} : provisionedHookSet;
406
+ const settings = claudeSettingsArgs(
407
+ projectSettings.settings,
408
+ inlineHookSet,
409
+ provisionedHooks.map((h) => h.name),
410
+ statusLine,
411
+ hostDefaultArgs,
412
+ config.providerArgs,
413
+ );
414
+ // The anti-lie invariant (#1096): only report hooks as materialized when they actually reach
415
+ // the launch. If the caller's --settings suppressed them, empty the reported set and surface a
416
+ // loud suppression reason instead of an "applied" report.
417
+ const hooksSuppressed = settings.suppressedHookNames.length
418
+ ? { reason: "caller-provided --settings could not be merged (file path or malformed JSON); relay hooks were not injected", names: settings.suppressedHookNames }
419
+ : undefined;
420
+ const deliveredHooks = hooksSuppressed ? [] : provisionedHooks;
258
421
 
259
422
  // Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
260
423
  // relay-bundled plugin → only when relay.plugins (carries the relay monitor +
@@ -332,7 +495,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
332
495
  hostServers: hostMcp,
333
496
  strict: strictMcp,
334
497
  }),
335
- ...claudeSettingsArgs(projectSettings.settings, statusLine, hostDefaultArgs, config.providerArgs),
498
+ ...settings.args,
336
499
  ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
337
500
  ];
338
501
 
@@ -355,6 +518,9 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
355
518
  ignoredKeys: projectSettings.ignoredKeys,
356
519
  ...(projectSettings.file ? { file: projectSettings.file } : {}),
357
520
  },
521
+ hooks: deliveredHooks.map((h) => ({ name: h.name, dir: h.dir })),
522
+ ...(hooksSuppressed ? { hooksSuppressed } : {}),
523
+ callerSettingsConsumed: settings.callerSettingsConsumed,
358
524
  plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
359
525
  hostPluginDirs,
360
526
  mcp: {
@@ -367,7 +533,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
367
533
  };
368
534
  }
369
535
 
370
- function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfig): AssembledLaunch {
536
+ function assembleCodex(config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
371
537
  const configHome = providerHomePathFor("codex", config);
372
538
  const assetHome = providerProvisioningHomePathFor("codex", config);
373
539
  const vanilla = profileIsVanillaBase(config);
@@ -375,6 +541,22 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
375
541
  const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
376
542
 
377
543
  const provisionedSkills = assetHome ? resolveCodexProvisionedSkills(assetHome, config) : [];
544
+ // #1096 review, findings 3 & 4 — Codex delivers hooks via CODEX_HOME/hooks.json, so a launch
545
+ // can only carry them when it has an isolated CODEX_HOME (host-base profiles reuse the real
546
+ // ~/.codex, which relay must not clobber) AND the installed Codex is new enough to consume the
547
+ // file. Resolve the profile's hook definitions to decide whether there was anything to deliver;
548
+ // any absence is reported LOUDLY (hooksSuppressed) instead of a silent "not-applicable".
549
+ const resolvedHookDefs = profileProvisioning(config).hooks ?? [];
550
+ const hooksSupported = codexHooksSupported(providerConfig.command);
551
+ const provisionedHooks = configHome && hooksSupported ? resolveProvisionedHooks(configHome, config) : [];
552
+ const hooksSuppressed = resolvedHookDefs.length && provisionedHooks.length === 0
553
+ ? {
554
+ reason: !configHome
555
+ ? "Codex host-base profile has no managed CODEX_HOME; relay will not overwrite the host ~/.codex/hooks.json, so the baseline guard cannot be delivered"
556
+ : "installed Codex is older than 0.124 and ignores CODEX_HOME/hooks.json (set AGENT_RELAY_CODEX_VERSION to override the probe)",
557
+ names: resolvedHookDefs.map((h) => h.name),
558
+ }
559
+ : undefined;
378
560
  const bundledSkillDirs = !config.agentProfile && relaySkills ? bundledCodexSkillDirs() : [];
379
561
  const skillDirs = [...bundledSkillDirs, ...provisionedSkills.map((s) => s.dir)];
380
562
 
@@ -429,6 +611,9 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
429
611
  hostSkills: [],
430
612
  projectSkills: [],
431
613
  projectSettings: { applied: false, keys: [], ignoredKeys: [] },
614
+ hooks: provisionedHooks.map((h) => ({ name: h.name, dir: h.dir })),
615
+ ...(hooksSuppressed ? { hooksSuppressed } : {}),
616
+ callerSettingsConsumed: false,
432
617
  plugins: [],
433
618
  hostPluginDirs: [],
434
619
  mcp: {
@@ -472,13 +657,27 @@ function materializeClaudeAssets(config: RunnerSpawnConfig): void {
472
657
  const hostSkills = resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)));
473
658
  materializeResolvedAssets(hostSkills);
474
659
  materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name))));
660
+ materializeResolvedAssets(resolveProvisionedHooks(configHome, config));
661
+ // #1096 — land the provisioned hooks in the managed home's settings.json (the documented
662
+ // hook source), matching the assembler's decision to keep them out of the inline --settings.
663
+ const managedHookSettings = claudeManagedHookSettings(config, configHome);
664
+ if (managedHookSettings) writeClaudeSettingsJson(configHome, managedHookSettings);
475
665
  }
476
666
  if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
667
+ if (!configHome && assetHome) materializeResolvedAssets(resolveProvisionedHooks(assetHome, config));
477
668
  }
478
669
 
479
670
  function materializeCodexAssets(config: RunnerSpawnConfig): void {
480
671
  const assetHome = providerProvisioningHomePathFor("codex", config);
481
672
  if (assetHome) materializeResolvedAssets(resolveCodexProvisionedSkills(assetHome, config));
673
+ const configHome = providerHomePathFor("codex", config);
674
+ // #1096 review, finding 4 — don't write CODEX_HOME/hooks.json for a Codex that would ignore it;
675
+ // keep disk == report (the assembler reports these hooks suppressed for such a launch).
676
+ if (configHome && codexHooksSupported(config.providerConfig.command)) {
677
+ const hooks = resolveProvisionedHooks(configHome, config);
678
+ materializeResolvedAssets(hooks);
679
+ writeCodexHooksJson(configHome, renderProvisionedHookSet(hooks));
680
+ }
482
681
  }
483
682
 
484
683
  /**
@@ -494,8 +693,8 @@ export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfi
494
693
  }
495
694
 
496
695
  /**
497
- * Write the provisioned assets the assembly describes (Claude skills + plugins, Codex
498
- * skills). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
696
+ * Write the provisioned assets the assembly describes (skills/plugins/hooks and Codex
697
+ * hooks.json). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
499
698
  * report. Idempotent; the isolated home must already exist (prepare*ProfileHome). No-op
500
699
  * for a host-base profile.
501
700
  */
@@ -33,6 +33,7 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
33
33
  add(hooksEntry(assembled));
34
34
  add(provisioningSkillsEntry(assembled));
35
35
  add(provisioningPluginsEntry(assembled));
36
+ add(provisioningHooksEntry(assembled));
36
37
  add(hostSkillsEntry(assembled));
37
38
  add(hostPluginsEntry(assembled));
38
39
  add(provisioningMcpEntry(assembled));
@@ -168,6 +169,24 @@ function provisioningPluginsEntry(a: AssembledLaunch): AgentProfileProjectionEnt
168
169
  return applied("provisioning.plugins", `${count}`, `Relay-provisioned plugins passed to ${label} via the plugin dir mechanism${names}.`);
169
170
  }
170
171
 
172
+ function provisioningHooksEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
173
+ // #1096 review — the anti-lie invariant: hooks that RESOLVED but did NOT reach the launch
174
+ // (baseline safety guard included) are reported LOUDLY as a warning, never "applied" and never
175
+ // a benign "not-applicable". `hooksSuppressed` is set only when there were hooks to deliver.
176
+ if (a.hooksSuppressed?.names.length) {
177
+ return partial(
178
+ "provisioning.hooks",
179
+ "suppressed",
180
+ `Relay-provisioned hooks were NOT applied (${a.hooksSuppressed.names.join(", ")}): ${a.hooksSuppressed.reason}. A baseline safety guard is absent for this launch.`,
181
+ );
182
+ }
183
+ const count = a.hooks.length;
184
+ if (!count) return notApplicable("provisioning.hooks", "none", "No relay-provisioned hooks materialized for this launch.");
185
+ const names = ` (${a.hooks.map((h) => h.name).join(", ")})`;
186
+ const label = getManifest(a.provider)?.label ?? a.provider;
187
+ return applied("provisioning.hooks", `${count}`, `Relay-provisioned hooks materialized for ${label} from the managed provider config home${names}.`);
188
+ }
189
+
171
190
  function hostSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
172
191
  const optedIn = Boolean(a.profile?.hostAssets?.skills);
173
192
  if (!optedIn) return notApplicable("hostAssets.skills", "off", "Host/global skill inheritance is off for this resolved profile.");
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, isAbsolute, join, resolve } from "node:path";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
- import type { ProvisioningAssetFile, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
4
+ import type { ProvisioningAssetFile, ProvisioningHookSet, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
5
5
  import type { RunnerSpawnConfig } from "./adapter";
6
6
  import { profileAllowsHostAssets, providerSourceHome } from "./profile-home";
7
7
 
@@ -17,12 +17,12 @@ import { profileAllowsHostAssets, providerSourceHome } from "./profile-home";
17
17
  // materialize* writers consume the same resolved entries, so what's written can never
18
18
  // drift from what's reported either. One resolution, three consumers.
19
19
 
20
- const EMPTY: ResolvedProvisioning = { skills: [], plugins: [], mcpServers: {} };
20
+ const EMPTY: ResolvedProvisioning = { skills: [], plugins: [], hooks: [], mcpServers: {} };
21
21
 
22
22
  export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfile">): ResolvedProvisioning {
23
23
  const p = config.agentProfile?.provisioning;
24
24
  if (!p) return EMPTY;
25
- return { skills: p.skills ?? [], plugins: p.plugins ?? [], mcpServers: p.mcpServers ?? {}, ...(p.skipped?.length ? { skipped: p.skipped } : {}) };
25
+ return { skills: p.skills ?? [], plugins: p.plugins ?? [], hooks: p.hooks ?? [], mcpServers: p.mcpServers ?? {}, ...(p.skipped?.length ? { skipped: p.skipped } : {}) };
26
26
  }
27
27
 
28
28
  // The `mcpServers` keys a Claude `.mcp.json` entry may carry; coerced into the
@@ -194,6 +194,10 @@ export interface ResolvedAsset {
194
194
  source: ResolvedAssetSource;
195
195
  }
196
196
 
197
+ export interface ResolvedHookAsset extends ResolvedAsset {
198
+ hooks: ProvisioningHookSet;
199
+ }
200
+
197
201
  function assetDirName(name: string): string {
198
202
  return sanitizeFsName(name, { replacement: "-", collapse: false, maxLen: 120, fallback: "asset" });
199
203
  }
@@ -211,9 +215,11 @@ function hasRootSkillManifest(files: ProvisioningAssetFile[]): boolean {
211
215
  // registry entry. Shared by every files-source materialization.
212
216
  function writeInlineFiles(dir: string, files: ProvisioningAssetFile[]): void {
213
217
  mkdirSync(dir, { recursive: true });
218
+ const root = resolve(dir);
214
219
  for (const file of files) {
215
220
  const dest = resolve(dir, file.path);
216
- if (!dest.startsWith(resolve(dir))) continue;
221
+ const rel = relative(root, dest);
222
+ if (rel.startsWith("..") || isAbsolute(rel)) continue;
217
223
  mkdirSync(dirname(dest), { recursive: true });
218
224
  writeFileSync(dest, file.content, { mode: 0o600 });
219
225
  }
@@ -303,6 +309,69 @@ export function resolveCodexProvisionedSkills(codexHome: string, config: RunnerS
303
309
  return resolved;
304
310
  }
305
311
 
312
+ export function resolveProvisionedHooks(home: string, config: RunnerSpawnConfig): ResolvedHookAsset[] {
313
+ const { hooks } = profileProvisioning(config);
314
+ const parent = join(home, "relay-provisioned-hooks");
315
+ const resolved: ResolvedHookAsset[] = [];
316
+ for (const hook of hooks ?? []) {
317
+ const dir = join(parent, assetDirName(hook.name));
318
+ if (hook.files?.length) {
319
+ resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "files", files: hook.files } });
320
+ continue;
321
+ }
322
+ if (hook.dir) {
323
+ const source = resolveExistingDir(hook.dir, config.cwd);
324
+ if (source) resolved.push({ name: hook.name, dir: source, hooks: hook.hooks, source: { kind: "inPlace" } });
325
+ continue;
326
+ }
327
+ resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "inPlace" } });
328
+ }
329
+ return resolved;
330
+ }
331
+
332
+ export function renderProvisionedHookSet(hooks: ResolvedHookAsset[]): ProvisioningHookSet {
333
+ const out: ProvisioningHookSet = {};
334
+ for (const hook of hooks) {
335
+ for (const [event, entries] of Object.entries(hook.hooks)) {
336
+ out[event] = [
337
+ ...(out[event] ?? []),
338
+ ...entries.map((entry) => ({
339
+ ...entry,
340
+ hooks: entry.hooks.map((command) => ({
341
+ ...command,
342
+ command: command.command.replaceAll("${HOOK_DIR}", hook.dir),
343
+ })),
344
+ })),
345
+ ];
346
+ }
347
+ }
348
+ return out;
349
+ }
350
+
351
+ export function writeCodexHooksJson(codexHome: string, hooks: ProvisioningHookSet): void {
352
+ mkdirSync(codexHome, { recursive: true });
353
+ if (!Object.keys(hooks).length) {
354
+ rmSync(join(codexHome, "hooks.json"), { force: true });
355
+ return;
356
+ }
357
+ writeFileSync(join(codexHome, "hooks.json"), JSON.stringify({ hooks }, null, 2), { mode: 0o600 });
358
+ }
359
+
360
+ // #1096 — write relay-owned settings into an ISOLATED Claude managed home's `settings.json`
361
+ // (the DOCUMENTED hook source Claude honors under `--setting-sources "user"` or the default,
362
+ // unlike the inline `--settings` CLI flag). The managed home is relay-owned and freshly keyed
363
+ // per spawn, so an unconditional write is safe (profile-home seeds only `.claude.json`/`CLAUDE.md`,
364
+ // never `settings.json`). Removes the file when there is nothing relay-owned to deliver so disk
365
+ // stays == report. Never called for a host-base launch (which reuses the real `~/.claude`).
366
+ export function writeClaudeSettingsJson(claudeHome: string, settings: Record<string, unknown>): void {
367
+ mkdirSync(claudeHome, { recursive: true });
368
+ if (!Object.keys(settings).length) {
369
+ rmSync(join(claudeHome, "settings.json"), { force: true });
370
+ return;
371
+ }
372
+ writeFileSync(join(claudeHome, "settings.json"), JSON.stringify(settings, null, 2), { mode: 0o600 });
373
+ }
374
+
306
375
  // Write the resolved assets to disk; returns the dirs that materialized successfully,
307
376
  // in resolution order. Idempotent — safe to re-run against an existing home.
308
377
  export function materializeResolvedAssets(assets: ResolvedAsset[]): string[] {
@@ -36,6 +36,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
36
36
  if (!isSpawnProvider(input.provider)) return events;
37
37
  const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
38
38
  const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
39
+ + assembled.hooks.length
39
40
  + assembled.projectSkills.length
40
41
  + assembled.mcp.projectServers.length
41
42
  + (assembled.projectSettings.applied ? 1 : 0)
@@ -52,6 +53,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
52
53
  assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
53
54
  assembled.projectSkills.length ? `${assembled.projectSkills.length} project skill${assembled.projectSkills.length === 1 ? "" : "s"}` : "",
54
55
  assembled.projectSettings.applied ? "project settings" : "",
56
+ assembled.hooks.length ? `${assembled.hooks.length} hook${assembled.hooks.length === 1 ? "" : "s"}` : "",
55
57
  assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
56
58
  assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
57
59
  assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
@@ -73,6 +75,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
73
75
  skills: assembled.skills,
74
76
  projectSkills: assembled.projectSkills,
75
77
  projectSettings: assembled.projectSettings,
78
+ hooks: assembled.hooks,
76
79
  plugins: assembled.plugins,
77
80
  mcp: assembled.mcp,
78
81
  },
@@ -356,7 +356,7 @@ export function appliedAgentProfileMetadata(
356
356
  skills: profile.skills.filter((item) => item.enabled).length,
357
357
  plugins: profile.plugins.filter((item) => item.enabled).length,
358
358
  mcp: { mode: profile.mcp.mode },
359
- hooks: { mode: profile.hooks.mode },
359
+ hooks: { mode: profile.hooks.mode, refs: profile.hooks.refs?.filter((item) => item.enabled).length ?? 0 },
360
360
  permissions: profile.permissions,
361
361
  projection,
362
362
  };
package/src/version.ts CHANGED
@@ -38,6 +38,7 @@ export function runtimeMetadata(provider?: string) {
38
38
  ...RUNTIME_CAPABILITIES,
39
39
  ...(manifest?.provisioning?.bundledPlugin ? { bundledPlugin: true } : {}),
40
40
  ...(manifest?.provisioning?.bundledSkills ? { bundledSkills: true } : {}),
41
+ ...(manifest?.provisioning?.bundledHooks?.length ? { bundledHooks: true } : {}),
41
42
  },
42
43
  };
43
44
  }