agent-relay-server 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.
Files changed (100) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +4 -4
  3. package/public/assets/{MessageBubble-DRdUD-kW.js → MessageBubble-BTeFExjj.js} +2 -2
  4. package/public/assets/{MessageBubble-DRdUD-kW.js.map → MessageBubble-BTeFExjj.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-siKMPCln.js → PlanGraphPanel-jU1DXg7w.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-siKMPCln.js.map → PlanGraphPanel-jU1DXg7w.js.map} +1 -1
  7. package/public/assets/{activity-D3MNcSaV.js → activity-DUig2iYW.js} +2 -2
  8. package/public/assets/{activity-D3MNcSaV.js.map → activity-DUig2iYW.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CKzttq6G.js → agent-profiles-D-oFmb6S.js} +2 -2
  10. package/public/assets/{agent-profiles-CKzttq6G.js.map → agent-profiles-D-oFmb6S.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-0ZJRP1Vy.js → agent-type-icon-Dan2l3Tm.js} +2 -2
  12. package/public/assets/{agent-type-icon-0ZJRP1Vy.js.map → agent-type-icon-Dan2l3Tm.js.map} +1 -1
  13. package/public/assets/{agents-CuVj_rfG.js → agents-BkC6mcTh.js} +2 -2
  14. package/public/assets/{agents-CuVj_rfG.js.map → agents-BkC6mcTh.js.map} +1 -1
  15. package/public/assets/{analytics-iob6C27d.js → analytics-C3Dlmqe9.js} +2 -2
  16. package/public/assets/{analytics-iob6C27d.js.map → analytics-C3Dlmqe9.js.map} +1 -1
  17. package/public/assets/{automation-B2ixxs4q.js → automation-AeMZ70WQ.js} +2 -2
  18. package/public/assets/{automation-B2ixxs4q.js.map → automation-AeMZ70WQ.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-CafBJWKo.js → branch-state-badge-CNW10MTC.js} +2 -2
  20. package/public/assets/{branch-state-badge-CafBJWKo.js.map → branch-state-badge-CNW10MTC.js.map} +1 -1
  21. package/public/assets/{channels-C-WsbL2H.js → channels-Bc_mEd56.js} +2 -2
  22. package/public/assets/{channels-C-WsbL2H.js.map → channels-Bc_mEd56.js.map} +1 -1
  23. package/public/assets/{chat-DeoVTVob.js → chat-BCNCuTgw.js} +2 -2
  24. package/public/assets/{chat-DeoVTVob.js.map → chat-BCNCuTgw.js.map} +1 -1
  25. package/public/assets/{connectors-C2Ac03LJ.js → connectors-h0d5Mc6e.js} +2 -2
  26. package/public/assets/{connectors-C2Ac03LJ.js.map → connectors-h0d5Mc6e.js.map} +1 -1
  27. package/public/assets/{display-CJzSoTMq.js → display-GFbGUG0O.js} +2 -2
  28. package/public/assets/{display-CJzSoTMq.js.map → display-GFbGUG0O.js.map} +1 -1
  29. package/public/assets/{formatted-body-35XBOY5L.js → formatted-body-CVr3Iev9.js} +3 -3
  30. package/public/assets/{formatted-body-35XBOY5L.js.map → formatted-body-CVr3Iev9.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-RmDxI5Ux.js → formatted-body-impl-Cq5SRGpm.js} +2 -2
  32. package/public/assets/{formatted-body-impl-RmDxI5Ux.js.map → formatted-body-impl-Cq5SRGpm.js.map} +1 -1
  33. package/public/assets/{index-D0xMQQb_.js → index--9UMzCFf.js} +6 -6
  34. package/public/assets/{index-D0xMQQb_.js.map → index--9UMzCFf.js.map} +1 -1
  35. package/public/assets/{insights-B643SFvm.js → insights-C8lIBXg3.js} +2 -2
  36. package/public/assets/{insights-B643SFvm.js.map → insights-C8lIBXg3.js.map} +1 -1
  37. package/public/assets/{integrations-hFIHeCF4.js → integrations-7OFsJtY7.js} +2 -2
  38. package/public/assets/{integrations-hFIHeCF4.js.map → integrations-7OFsJtY7.js.map} +1 -1
  39. package/public/assets/{maintenance-CURvdp_Z.js → maintenance-HDpg20OY.js} +2 -2
  40. package/public/assets/{maintenance-CURvdp_Z.js.map → maintenance-HDpg20OY.js.map} +1 -1
  41. package/public/assets/{managed-agents-DrZtdlSn.js → managed-agents-muCIMxng.js} +2 -2
  42. package/public/assets/{managed-agents-DrZtdlSn.js.map → managed-agents-muCIMxng.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-CVDO--ek.js → markdown-preview-impl-Cx7pCfA6.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-CVDO--ek.js.map → markdown-preview-impl-Cx7pCfA6.js.map} +1 -1
  45. package/public/assets/{memory-Bm7dt_Qf.js → memory-CTO1FfJ-.js} +2 -2
  46. package/public/assets/{memory-Bm7dt_Qf.js.map → memory-CTO1FfJ-.js.map} +1 -1
  47. package/public/assets/{messages-DTq4i2KZ.js → messages-RWSXG2UM.js} +2 -2
  48. package/public/assets/{messages-DTq4i2KZ.js.map → messages-RWSXG2UM.js.map} +1 -1
  49. package/public/assets/{orchestrators-Dra318Ap.js → orchestrators-5aiYw3yx.js} +2 -2
  50. package/public/assets/{orchestrators-Dra318Ap.js.map → orchestrators-5aiYw3yx.js.map} +1 -1
  51. package/public/assets/{overview-yr1Vmk64.js → overview-Du6XLt5l.js} +2 -2
  52. package/public/assets/{overview-yr1Vmk64.js.map → overview-Du6XLt5l.js.map} +1 -1
  53. package/public/assets/{pairs-mnlKDBGp.js → pairs-BDRnlEta.js} +2 -2
  54. package/public/assets/{pairs-mnlKDBGp.js.map → pairs-BDRnlEta.js.map} +1 -1
  55. package/public/assets/{projects-Dzdf-gkK.js → projects-DwsWE9es.js} +2 -2
  56. package/public/assets/{projects-Dzdf-gkK.js.map → projects-DwsWE9es.js.map} +1 -1
  57. package/public/assets/{prompt-settings-ByL8SNCi.js → prompt-settings-CaJX_LbA.js} +2 -2
  58. package/public/assets/{prompt-settings-ByL8SNCi.js.map → prompt-settings-CaJX_LbA.js.map} +1 -1
  59. package/public/assets/{registry-CXWyOtpr.js → registry-C-uIL0Nl.js} +2 -2
  60. package/public/assets/{registry-CXWyOtpr.js.map → registry-C-uIL0Nl.js.map} +1 -1
  61. package/public/assets/{security-0SBsc_4W.js → security-BpVrdYhJ.js} +2 -2
  62. package/public/assets/{security-0SBsc_4W.js.map → security-BpVrdYhJ.js.map} +1 -1
  63. package/public/assets/{select-Bw5z2KJ_.js → select-DallbU93.js} +2 -2
  64. package/public/assets/{select-Bw5z2KJ_.js.map → select-DallbU93.js.map} +1 -1
  65. package/public/assets/{settings-78Cyq_tA.js → settings-BUQirdyI.js} +2 -2
  66. package/public/assets/{settings-78Cyq_tA.js.map → settings-BUQirdyI.js.map} +1 -1
  67. package/public/assets/{store-BPrVuKiv.js → store-DMZSTozC.js} +2 -2
  68. package/public/assets/{store-BPrVuKiv.js.map → store-DMZSTozC.js.map} +1 -1
  69. package/public/assets/{tasks-Cg2MMeri.js → tasks-k8mQDfKy.js} +2 -2
  70. package/public/assets/{tasks-Cg2MMeri.js.map → tasks-k8mQDfKy.js.map} +1 -1
  71. package/public/assets/{teams-DWv6UwFV.js → teams-DI-Lhhbl.js} +2 -2
  72. package/public/assets/{teams-DWv6UwFV.js.map → teams-DI-Lhhbl.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-DBLDjbIg.js → terminal-viewer-impl-BL853Fpi.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-DBLDjbIg.js.map → terminal-viewer-impl-BL853Fpi.js.map} +1 -1
  75. package/public/assets/types-CRptbSYh.js +2 -0
  76. package/public/assets/types-CRptbSYh.js.map +1 -0
  77. package/public/assets/{user-avatar-BsOLyVJj.js → user-avatar-D9xyGi3u.js} +2 -2
  78. package/public/assets/{user-avatar-BsOLyVJj.js.map → user-avatar-D9xyGi3u.js.map} +1 -1
  79. package/public/assets/{work-queue-DmwvnF6F.js → work-queue-DrLIdvxB.js} +2 -2
  80. package/public/assets/{work-queue-DmwvnF6F.js.map → work-queue-DrLIdvxB.js.map} +1 -1
  81. package/public/assets/{workspaces-dIBivJN8.js → workspaces-CnNL6T8Q.js} +2 -2
  82. package/public/assets/{workspaces-dIBivJN8.js.map → workspaces-CnNL6T8Q.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/src/adapters/claude.ts +5 -2
  86. package/runner/src/codex-version.ts +63 -0
  87. package/runner/src/config.ts +7 -0
  88. package/runner/src/launch-assembly.ts +175 -9
  89. package/runner/src/profile-projection.ts +19 -0
  90. package/runner/src/provisioning.ts +59 -5
  91. package/runner/src/relay-injection-events.ts +3 -0
  92. package/runner/src/runner-helpers.ts +1 -1
  93. package/runner/src/version.ts +1 -0
  94. package/src/config-store.ts +2 -0
  95. package/src/db/provisioning-registry.ts +40 -9
  96. package/src/db/provisioning-resolve.ts +66 -5
  97. package/src/routes/provisioning-registry.ts +1 -1
  98. package/src/services/spawn-agent.ts +1 -0
  99. package/public/assets/types-BeJUSfDj.js +0 -2
  100. package/public/assets/types-BeJUSfDj.js.map +0 -1
@@ -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
  };
@@ -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
  }
@@ -335,6 +335,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Omit<
335
335
  },
336
336
  hooks: {
337
337
  mode: input.hooks?.mode ?? (isolated ? "none" : "host"),
338
+ ...(input.hooks?.refs?.length ? { refs: input.hooks.refs } : {}),
338
339
  },
339
340
  permissions: {
340
341
  mode: input.permissions?.mode,
@@ -419,6 +420,7 @@ function validateAgentProfile(key: string, value: unknown, opts: { allowBuiltIn?
419
420
  },
420
421
  hooks: {
421
422
  mode: hooks.mode === undefined ? defaults.hooks.mode : cleanEnum(hooks.mode, "hooks.mode", VALID_PROFILE_CATEGORY_MODES),
423
+ refs: cleanAgentProfileAssetArray(hooks.refs, "hooks.refs"),
422
424
  },
423
425
  permissions: {
424
426
  mode: permissions.mode === undefined || permissions.mode === null
@@ -9,6 +9,7 @@ import { parseJson } from "../utils";
9
9
  import { errMessage, isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
10
10
  import type {
11
11
  AgentProfileProvider,
12
+ HookAssetDefinition,
12
13
  McpAssetDefinition,
13
14
  PluginAssetDefinition,
14
15
  ProvisioningCapability,
@@ -26,7 +27,7 @@ import type {
26
27
  // the launch. #557 (per-profile assembly) is meant to be a thin unifier on top, so
27
28
  // this stays the single home for asset definition + resolution — not three modules.
28
29
 
29
- const PROVISIONING_ASSET_KINDS = ["skill", "mcp", "plugin"] as const;
30
+ const PROVISIONING_ASSET_KINDS = ["skill", "mcp", "plugin", "hook"] as const;
30
31
  const PROVISIONING_PROVIDERS = [...SPAWN_PROVIDERS, "any"];
31
32
  const GITHUB_API = "https://api.github.com";
32
33
  const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
@@ -213,6 +214,10 @@ function assertDefinition(kind: ProvisioningAssetKind, definition: ResolvedAsset
213
214
  const metadata = isRecord(def.metadata) ? def.metadata : {};
214
215
  if (isRecord(metadata.callmuxServer)) assertCallmuxServerPlaceholders(metadata.callmuxServer as ProvisioningMcpServer, def.name);
215
216
  }
217
+ if (kind === "hook") {
218
+ const def = definition as HookAssetDefinition;
219
+ if (!isRecord(def.hooks) || !Object.keys(def.hooks).length) throw new ValidationError("hook definition requires a hooks object");
220
+ }
216
221
  if ((kind === "skill" || kind === "plugin")) {
217
222
  const def = definition as SkillAssetDefinition | PluginAssetDefinition;
218
223
  if (!def.files?.length && !def.dir) throw new ValidationError(`${kind} definition requires files or dir`);
@@ -470,6 +475,24 @@ function mcpDefinitionFromFiles(name: string, files: VendoredFile[], mcpServerNa
470
475
  return { kind: "mcp", name, server: coerceMcpServer(parsed) };
471
476
  }
472
477
 
478
+ function hookDefinitionFromFiles(name: string, files: Array<{ path: string; content: string }>): HookAssetDefinition {
479
+ const preferred = files.find((file) => file.path === "hooks.json")
480
+ ?? files.find((file) => file.path.endsWith("/hooks.json"))
481
+ ?? files.find((file) => file.path === `${name}.json`);
482
+ if (!preferred) throw new ValidationError("hook imports require hooks.json");
483
+ let parsed: Record<string, unknown>;
484
+ try {
485
+ const value = JSON.parse(preferred.content);
486
+ if (!isRecord(value)) throw new Error("not an object");
487
+ parsed = value;
488
+ } catch {
489
+ throw new ValidationError(`${preferred.path} must be valid JSON object`);
490
+ }
491
+ const hooks = isRecord(parsed.hooks) ? parsed.hooks : parsed;
492
+ if (!isRecord(hooks) || !Object.keys(hooks).length) throw new ValidationError("hook import requires a non-empty hooks object");
493
+ return { kind: "hook", name, hooks: hooks as HookAssetDefinition["hooks"], files: files.map(({ path, content }) => ({ path, content })) };
494
+ }
495
+
473
496
  function definitionFromVendoredFiles(input: ImportProvisioningAssetFromGitHubInput, files: VendoredFile[], provider: AgentProfileProvider): ResolvedAssetDefinition {
474
497
  if (input.kind === "skill") {
475
498
  assertRootSkill(files);
@@ -479,6 +502,7 @@ function definitionFromVendoredFiles(input: ImportProvisioningAssetFromGitHubInp
479
502
  assertPlugin(files, provider);
480
503
  return { kind: "plugin", name: input.name, files: files.map(({ path, content }) => ({ path, content })) };
481
504
  }
505
+ if (input.kind === "hook") return hookDefinitionFromFiles(input.name, files);
482
506
  return mcpDefinitionFromFiles(input.name, files, input.mcpServerName);
483
507
  }
484
508
 
@@ -529,7 +553,7 @@ function updateBundledAssetMetadata(asset: ProvisioningAsset, root: string, file
529
553
  }
530
554
 
531
555
  function upsertBundledProvisioningAsset(input: {
532
- kind: "skill" | "plugin";
556
+ kind: "skill" | "plugin" | "hook";
533
557
  name: string;
534
558
  provider: AgentProfileProvider;
535
559
  files: BundledFile[];
@@ -538,7 +562,10 @@ function upsertBundledProvisioningAsset(input: {
538
562
  description: string;
539
563
  }): void {
540
564
  if (!input.files.length) return;
541
- const definition = { kind: input.kind, name: input.name, files: input.files.map(({ path, content }) => ({ path, content })) } as SkillAssetDefinition | PluginAssetDefinition;
565
+ const files = input.files.map(({ path, content }) => ({ path, content }));
566
+ const definition = input.kind === "hook"
567
+ ? hookDefinitionFromFiles(input.name, input.files)
568
+ : { kind: input.kind, name: input.name, files } as SkillAssetDefinition | PluginAssetDefinition;
542
569
  assertDefinition(input.kind, definition);
543
570
  const asset = upsertProvisioningAsset({ kind: input.kind, name: input.name, provider: input.provider, definition });
544
571
  updateBundledAssetMetadata(asset, input.root, input.files, input.title, input.description);
@@ -552,12 +579,16 @@ export function seedBundledProvisioningAssets(): void {
552
579
  const plugin = provisioning.bundledPlugin;
553
580
  upsertBundledProvisioningAsset({ kind: "plugin", name: plugin.ref, provider, root: plugin.dir, files: readBundledFiles(resolve(import.meta.dir, "../../", plugin.dir), { omitFiles: plugin.omitFiles, fileOverrides: plugin.fileOverrides }), title: plugin.title, description: plugin.description });
554
581
  }
555
- if (!provisioning.bundledSkills) continue;
556
- const skills = provisioning.bundledSkills;
557
- const skillsDir = resolve(import.meta.dir, "../../", skills.dir);
558
- for (const ref of bundledSkillRefs(skills)) {
559
- const skillName = ref.slice(skills.refPrefix.length);
560
- upsertBundledProvisioningAsset({ kind: "skill", name: ref, provider, root: posix.join(skills.dir, skillName), files: readBundledFiles(join(skillsDir, skillName)), title: `${skills.titlePrefix}${skillName}`, description: skills.description });
582
+ if (provisioning.bundledSkills) {
583
+ const skills = provisioning.bundledSkills;
584
+ const skillsDir = resolve(import.meta.dir, "../../", skills.dir);
585
+ for (const ref of bundledSkillRefs(skills)) {
586
+ const skillName = ref.slice(skills.refPrefix.length);
587
+ upsertBundledProvisioningAsset({ kind: "skill", name: ref, provider, root: posix.join(skills.dir, skillName), files: readBundledFiles(join(skillsDir, skillName)), title: `${skills.titlePrefix}${skillName}`, description: skills.description });
588
+ }
589
+ }
590
+ for (const hook of provisioning.bundledHooks ?? []) {
591
+ upsertBundledProvisioningAsset({ kind: "hook", name: hook.ref, provider, root: hook.dir, files: readBundledFiles(resolve(import.meta.dir, "../../", hook.dir), { omitFiles: hook.omitFiles, fileOverrides: hook.fileOverrides }), title: hook.title, description: hook.description });
561
592
  }
562
593
  }
563
594
  }