agent-relay-runner 0.122.1 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0",
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.0",
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,21 @@ 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,
31
35
  } from "./provisioning";
32
36
  import {
33
37
  claudeMcpLaunchArgs,
@@ -134,6 +138,20 @@ export interface AssembledLaunch {
134
138
  hostSkills: AssembledAsset[];
135
139
  /** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
136
140
  projectSettings: { applied: boolean; keys: string[]; ignoredKeys: string[]; file?: string };
141
+ /** Relay-provisioned hooks materialized for this launch. Empty when hooks were resolved
142
+ * but could not be delivered — see `hooksSuppressed` (the anti-lie invariant, #1096). */
143
+ hooks: AssembledAsset[];
144
+ /** #1096 review — set when this launch RESOLVED provisioned hooks (incl. a baseline safety
145
+ * guard) that could NOT be delivered: caller `--settings` was unmergeable (Claude), a
146
+ * host-base Codex agent has no managed CODEX_HOME, or the installed Codex is too old to
147
+ * consume hooks.json. Drives a LOUD projection warning so a baseline guard is never
148
+ * SILENTLY absent while the report says "applied". */
149
+ hooksSuppressed?: { reason: string; names: string[] };
150
+ /** #1096 review — Claude only: true when the caller's own `--settings` was folded into the
151
+ * relay-emitted `--settings` (relay hooks/projectSettings/statusLine merged in). The adapter
152
+ * must then STRIP the caller's `--settings` from the provider args so exactly one reaches the
153
+ * launch. False when relay emitted its own settings standalone or added none. */
154
+ callerSettingsConsumed: boolean;
137
155
  /** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
138
156
  plugins: AssembledAsset[];
139
157
  /** Host/global provider plugin dirs explicitly inherited without enabling host MCP. */
@@ -196,17 +214,115 @@ function claudeStatusLineProbePath(): string {
196
214
  return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
197
215
  }
198
216
 
217
+ export interface ClaudeSettingsResolution {
218
+ args: string[];
219
+ /** The caller's own --settings was merged in and must be stripped from the provider args. */
220
+ callerSettingsConsumed: boolean;
221
+ /** Provisioned hook names that could NOT be delivered (caller --settings unmergeable). */
222
+ suppressedHookNames: string[];
223
+ }
224
+
225
+ // #1096 review, finding 1 — when the caller (spawn providerArgs / provider defaultArgs) already
226
+ // carries `--settings`, DO NOT silently drop relay's settings (which used to skip the baseline
227
+ // safety hooks + projectSettings + statusLine while projection still claimed "applied"). Merge
228
+ // relay's settings into the caller's INLINE JSON so the guard survives, and signal the adapter to
229
+ // strip the caller's now-redundant `--settings`. If the caller's `--settings` is a FILE PATH or
230
+ // malformed JSON (unmergeable), relay's provisioned hooks are reported as SUPPRESSED (a loud
231
+ // projection warning) rather than a silent absence with an "applied" lie.
199
232
  function claudeSettingsArgs(
200
233
  projectSettings: Record<string, unknown>,
234
+ provisionedHooks: ProvisioningHookSet,
235
+ hookNames: string[],
201
236
  includeStatusLine: boolean,
202
237
  ...argLists: string[][]
203
- ): string[] {
204
- if (argLists.some(hasSettingsArg)) return [];
205
- const settings = {
238
+ ): ClaudeSettingsResolution {
239
+ const relaySettings: Record<string, unknown> = {
206
240
  ...projectSettings,
241
+ ...(Object.keys(provisionedHooks).length ? { hooks: provisionedHooks } : {}),
207
242
  ...(includeStatusLine ? relayStatusLineSettings() : {}),
208
243
  };
209
- return Object.keys(settings).length ? ["--settings", JSON.stringify(settings)] : [];
244
+ const hasProvisionedHooks = Object.keys(provisionedHooks).length > 0;
245
+ const caller = extractCallerSettings(argLists);
246
+ if (!caller) {
247
+ return {
248
+ args: Object.keys(relaySettings).length ? ["--settings", JSON.stringify(relaySettings)] : [],
249
+ callerSettingsConsumed: false,
250
+ suppressedHookNames: [],
251
+ };
252
+ }
253
+ if (caller.settings) {
254
+ const merged = mergeClaudeSettings(caller.settings, relaySettings);
255
+ return { args: ["--settings", JSON.stringify(merged)], callerSettingsConsumed: true, suppressedHookNames: [] };
256
+ }
257
+ // Unmergeable caller --settings (a file path or malformed JSON): relay cannot fold its
258
+ // settings in without clobbering the caller's file, so the provisioned hooks don't reach
259
+ // the launch. Report them suppressed (loud) — never claim "applied".
260
+ return { args: [], callerSettingsConsumed: false, suppressedHookNames: hasProvisionedHooks ? hookNames : [] };
261
+ }
262
+
263
+ // The caller's `--settings` value, parsed as inline JSON when possible. `settings` is null for a
264
+ // file-path / malformed value (an inline object is required to merge relay settings in).
265
+ function extractCallerSettings(argLists: string[][]): { settings: Record<string, unknown> | null } | null {
266
+ for (const list of argLists) {
267
+ for (let i = 0; i < list.length; i += 1) {
268
+ const arg = list[i];
269
+ if (arg === "--settings") return { settings: parseInlineSettings(list[i + 1]) };
270
+ if (arg?.startsWith("--settings=")) return { settings: parseInlineSettings(arg.slice("--settings=".length)) };
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+
276
+ function parseInlineSettings(value: string | undefined): Record<string, unknown> | null {
277
+ if (!value) return null;
278
+ const trimmed = value.trim();
279
+ if (!trimmed.startsWith("{")) return null; // a settings file path, not inline JSON
280
+ try {
281
+ const parsed = JSON.parse(trimmed);
282
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ // Merge relay settings into the caller's inline settings. The safety-critical `hooks` map is
289
+ // UNIONED per event (caller entries first, relay's baseline guard appended) so both fire; every
290
+ // other relay key is fill-if-absent (the caller's explicit value wins), preserving the historical
291
+ // "caller --settings wins" spirit for non-safety settings.
292
+ function mergeClaudeSettings(caller: Record<string, unknown>, relay: Record<string, unknown>): Record<string, unknown> {
293
+ const out: Record<string, unknown> = { ...caller };
294
+ for (const [key, value] of Object.entries(relay)) {
295
+ if (key === "hooks") out.hooks = mergeHookSets(caller.hooks, value);
296
+ else if (!(key in out)) out[key] = value;
297
+ }
298
+ return out;
299
+ }
300
+
301
+ function mergeHookSets(caller: unknown, relay: unknown): ProvisioningHookSet {
302
+ const base: ProvisioningHookSet = isHookSet(caller) ? { ...caller } : {};
303
+ if (!isHookSet(relay)) return base;
304
+ for (const [event, entries] of Object.entries(relay)) {
305
+ base[event] = [...(base[event] ?? []), ...entries];
306
+ }
307
+ return base;
308
+ }
309
+
310
+ function isHookSet(value: unknown): value is ProvisioningHookSet {
311
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
312
+ }
313
+
314
+ // Strip every `--settings <json>` / `--settings=<json>` occurrence from a provider arg list. The
315
+ // adapter applies this to the caller args once the assembler has folded them into its own merged
316
+ // `--settings` (callerSettingsConsumed), so exactly one `--settings` reaches the launch (#1096).
317
+ export function stripSettingsArgs(args: string[]): string[] {
318
+ const result: string[] = [];
319
+ for (let i = 0; i < args.length; i += 1) {
320
+ const arg = args[i];
321
+ if (arg === "--settings") { i += 1; continue; }
322
+ if (arg?.startsWith("--settings=")) continue;
323
+ if (arg !== undefined) result.push(arg);
324
+ }
325
+ return result;
210
326
  }
211
327
 
212
328
  // ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
@@ -255,6 +371,24 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
255
371
  : [];
256
372
  const projectSettings = resolveProjectClaudeSettings(config);
257
373
  const provisionedPlugins = assetHome ? resolveClaudeProvisionedPlugins(assetHome, config) : [];
374
+ const hookHome = configHome ?? assetHome;
375
+ const provisionedHooks = hookHome ? resolveProvisionedHooks(hookHome, config) : [];
376
+ const provisionedHookSet = renderProvisionedHookSet(provisionedHooks);
377
+ const settings = claudeSettingsArgs(
378
+ projectSettings.settings,
379
+ provisionedHookSet,
380
+ provisionedHooks.map((h) => h.name),
381
+ statusLine,
382
+ hostDefaultArgs,
383
+ config.providerArgs,
384
+ );
385
+ // The anti-lie invariant (#1096): only report hooks as materialized when they actually reach
386
+ // the launch. If the caller's --settings suppressed them, empty the reported set and surface a
387
+ // loud suppression reason instead of an "applied" report.
388
+ const hooksSuppressed = settings.suppressedHookNames.length
389
+ ? { reason: "caller-provided --settings could not be merged (file path or malformed JSON); relay hooks were not injected", names: settings.suppressedHookNames }
390
+ : undefined;
391
+ const deliveredHooks = hooksSuppressed ? [] : provisionedHooks;
258
392
 
259
393
  // Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
260
394
  // relay-bundled plugin → only when relay.plugins (carries the relay monitor +
@@ -332,7 +466,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
332
466
  hostServers: hostMcp,
333
467
  strict: strictMcp,
334
468
  }),
335
- ...claudeSettingsArgs(projectSettings.settings, statusLine, hostDefaultArgs, config.providerArgs),
469
+ ...settings.args,
336
470
  ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
337
471
  ];
338
472
 
@@ -355,6 +489,9 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
355
489
  ignoredKeys: projectSettings.ignoredKeys,
356
490
  ...(projectSettings.file ? { file: projectSettings.file } : {}),
357
491
  },
492
+ hooks: deliveredHooks.map((h) => ({ name: h.name, dir: h.dir })),
493
+ ...(hooksSuppressed ? { hooksSuppressed } : {}),
494
+ callerSettingsConsumed: settings.callerSettingsConsumed,
358
495
  plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
359
496
  hostPluginDirs,
360
497
  mcp: {
@@ -367,7 +504,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
367
504
  };
368
505
  }
369
506
 
370
- function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfig): AssembledLaunch {
507
+ function assembleCodex(config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
371
508
  const configHome = providerHomePathFor("codex", config);
372
509
  const assetHome = providerProvisioningHomePathFor("codex", config);
373
510
  const vanilla = profileIsVanillaBase(config);
@@ -375,6 +512,22 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
375
512
  const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
376
513
 
377
514
  const provisionedSkills = assetHome ? resolveCodexProvisionedSkills(assetHome, config) : [];
515
+ // #1096 review, findings 3 & 4 — Codex delivers hooks via CODEX_HOME/hooks.json, so a launch
516
+ // can only carry them when it has an isolated CODEX_HOME (host-base profiles reuse the real
517
+ // ~/.codex, which relay must not clobber) AND the installed Codex is new enough to consume the
518
+ // file. Resolve the profile's hook definitions to decide whether there was anything to deliver;
519
+ // any absence is reported LOUDLY (hooksSuppressed) instead of a silent "not-applicable".
520
+ const resolvedHookDefs = profileProvisioning(config).hooks ?? [];
521
+ const hooksSupported = codexHooksSupported(providerConfig.command);
522
+ const provisionedHooks = configHome && hooksSupported ? resolveProvisionedHooks(configHome, config) : [];
523
+ const hooksSuppressed = resolvedHookDefs.length && provisionedHooks.length === 0
524
+ ? {
525
+ reason: !configHome
526
+ ? "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"
527
+ : "installed Codex is older than 0.124 and ignores CODEX_HOME/hooks.json (set AGENT_RELAY_CODEX_VERSION to override the probe)",
528
+ names: resolvedHookDefs.map((h) => h.name),
529
+ }
530
+ : undefined;
378
531
  const bundledSkillDirs = !config.agentProfile && relaySkills ? bundledCodexSkillDirs() : [];
379
532
  const skillDirs = [...bundledSkillDirs, ...provisionedSkills.map((s) => s.dir)];
380
533
 
@@ -429,6 +582,9 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
429
582
  hostSkills: [],
430
583
  projectSkills: [],
431
584
  projectSettings: { applied: false, keys: [], ignoredKeys: [] },
585
+ hooks: provisionedHooks.map((h) => ({ name: h.name, dir: h.dir })),
586
+ ...(hooksSuppressed ? { hooksSuppressed } : {}),
587
+ callerSettingsConsumed: false,
432
588
  plugins: [],
433
589
  hostPluginDirs: [],
434
590
  mcp: {
@@ -472,13 +628,23 @@ function materializeClaudeAssets(config: RunnerSpawnConfig): void {
472
628
  const hostSkills = resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)));
473
629
  materializeResolvedAssets(hostSkills);
474
630
  materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name))));
631
+ materializeResolvedAssets(resolveProvisionedHooks(configHome, config));
475
632
  }
476
633
  if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
634
+ if (!configHome && assetHome) materializeResolvedAssets(resolveProvisionedHooks(assetHome, config));
477
635
  }
478
636
 
479
637
  function materializeCodexAssets(config: RunnerSpawnConfig): void {
480
638
  const assetHome = providerProvisioningHomePathFor("codex", config);
481
639
  if (assetHome) materializeResolvedAssets(resolveCodexProvisionedSkills(assetHome, config));
640
+ const configHome = providerHomePathFor("codex", config);
641
+ // #1096 review, finding 4 — don't write CODEX_HOME/hooks.json for a Codex that would ignore it;
642
+ // keep disk == report (the assembler reports these hooks suppressed for such a launch).
643
+ if (configHome && codexHooksSupported(config.providerConfig.command)) {
644
+ const hooks = resolveProvisionedHooks(configHome, config);
645
+ materializeResolvedAssets(hooks);
646
+ writeCodexHooksJson(configHome, renderProvisionedHookSet(hooks));
647
+ }
482
648
  }
483
649
 
484
650
  /**
@@ -494,8 +660,8 @@ export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfi
494
660
  }
495
661
 
496
662
  /**
497
- * Write the provisioned assets the assembly describes (Claude skills + plugins, Codex
498
- * skills). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
663
+ * Write the provisioned assets the assembly describes (skills/plugins/hooks and Codex
664
+ * hooks.json). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
499
665
  * report. Idempotent; the isolated home must already exist (prepare*ProfileHome). No-op
500
666
  * for a host-base profile.
501
667
  */
@@ -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,54 @@ 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
+
306
360
  // Write the resolved assets to disk; returns the dirs that materialized successfully,
307
361
  // in resolution order. Idempotent — safe to re-run against an existing home.
308
362
  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
  }