agent-relay-server 0.122.0 → 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 (142) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +7 -8
  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-delivery.ts +127 -0
  86. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  87. package/runner/src/adapters/claude-session-probe.ts +225 -0
  88. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  89. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  90. package/runner/src/adapters/claude-transcript.ts +414 -0
  91. package/runner/src/adapters/claude.ts +675 -0
  92. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  93. package/runner/src/adapters/codex-client.ts +279 -0
  94. package/runner/src/adapters/codex.ts +1345 -0
  95. package/runner/src/attachment-cache.ts +189 -0
  96. package/runner/src/busy-reconciler.ts +102 -0
  97. package/runner/src/claim-tracker.ts +140 -0
  98. package/runner/src/claude-prompt-gates.ts +268 -0
  99. package/runner/src/codex-version.ts +63 -0
  100. package/runner/src/config.ts +7 -0
  101. package/runner/src/context-probe-state.ts +6 -0
  102. package/runner/src/continuation-archive.ts +179 -0
  103. package/runner/src/control-server.ts +639 -0
  104. package/runner/src/index.ts +396 -0
  105. package/runner/src/launch-assembly.ts +674 -0
  106. package/runner/src/liveness.ts +50 -0
  107. package/runner/src/logger.ts +99 -0
  108. package/runner/src/mcp-outbox.ts +120 -0
  109. package/runner/src/message-body-config/config.ts +3 -0
  110. package/runner/src/message-body-config.ts +1 -0
  111. package/runner/src/native-self-resume.ts +202 -0
  112. package/runner/src/outbox.ts +342 -0
  113. package/runner/src/process-meta.ts +9 -0
  114. package/runner/src/profile-home.ts +260 -0
  115. package/runner/src/profile-projection.ts +324 -0
  116. package/runner/src/providers.ts +20 -0
  117. package/runner/src/provisioning.ts +368 -0
  118. package/runner/src/quota.ts +40 -0
  119. package/runner/src/rate-limit.ts +189 -0
  120. package/runner/src/relay-injection-events.ts +105 -0
  121. package/runner/src/relay-instructions.ts +60 -0
  122. package/runner/src/relay-mcp-proxy.ts +383 -0
  123. package/runner/src/relay-mcp.ts +156 -0
  124. package/runner/src/reply-obligation-cache.ts +136 -0
  125. package/runner/src/response-capture-report.ts +63 -0
  126. package/runner/src/runner-core.ts +2409 -0
  127. package/runner/src/runner-helpers.ts +435 -0
  128. package/runner/src/runner-insights.ts +105 -0
  129. package/runner/src/runner.ts +14 -0
  130. package/runner/src/session-destroy.ts +22 -0
  131. package/runner/src/session-insights.ts +118 -0
  132. package/runner/src/session-scratch.ts +271 -0
  133. package/runner/src/template-resolver.ts +29 -0
  134. package/runner/src/version.ts +44 -0
  135. package/src/config-store.ts +2 -0
  136. package/src/db/provisioning-registry.ts +40 -9
  137. package/src/db/provisioning-resolve.ts +66 -5
  138. package/src/gate-resolver.ts +7 -12
  139. package/src/routes/provisioning-registry.ts +1 -1
  140. package/src/services/spawn-agent.ts +1 -0
  141. package/public/assets/types-BeJUSfDj.js +0 -2
  142. package/public/assets/types-BeJUSfDj.js.map +0 -1
@@ -0,0 +1,674 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import type { AgentProfile, AgentProfileBase, ProvisioningHookSet, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
4
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
5
+ import {
6
+ profileAllowsRelayFeature,
7
+ profileIsVanillaBase,
8
+ type ProviderConfig,
9
+ type RunnerSpawnConfig,
10
+ } from "./adapter";
11
+ import {
12
+ projectConfigProjectionOn,
13
+ hostUserScopeMcpServers,
14
+ profileAllowsHostAssets,
15
+ profileUsesProviderHostGlobals,
16
+ providerHomePathFor,
17
+ providerProvisioningHomePathFor,
18
+ } from "./profile-home";
19
+ import { getManifest } from "agent-relay-providers";
20
+ import { codexHooksSupported } from "./codex-version";
21
+ import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
22
+ import {
23
+ materializeResolvedAssets,
24
+ renderProvisionedHookSet,
25
+ resolveClaudeProjectSkills,
26
+ resolveClaudeHostSkills,
27
+ resolveProjectClaudeSettings,
28
+ resolveClaudeProvisionedPlugins,
29
+ resolveClaudeProvisionedSkills,
30
+ resolveCodexProvisionedSkills,
31
+ resolveProvisionedHooks,
32
+ resolveProjectMcpServers,
33
+ profileProvisioning,
34
+ writeCodexHooksJson,
35
+ } from "./provisioning";
36
+ import {
37
+ claudeMcpLaunchArgs,
38
+ codexProvisionedMcpArgs,
39
+ relayMcpCodexConfigArgs,
40
+ resolveSharedMcpUrl,
41
+ sharedMcpBridgeServer,
42
+ tomlString,
43
+ RELAY_MCP_SERVER_NAME,
44
+ SHARED_MCP_SERVER_NAME,
45
+ } from "./relay-mcp";
46
+
47
+ // #557 — THE single launch-assembly contract. Given a resolved AgentProfile (+ its
48
+ // spawn-time ResolvedProvisioning), `assembleLaunch` produces ONE provider-neutral
49
+ // description of the launch: the relay-provisioned skills/plugins/MCP, the bundled relay
50
+ // surface, the instruction-source disposition, and `base:"vanilla"` as the zero-host
51
+ // floor. Both adapters' buildSpawnArgs splice `assembled.args`/`assembled.env`, and
52
+ // profile-projection describes `assembled` directly — so projection and reality come
53
+ // from one source and cannot drift.
54
+ //
55
+ // `assembleLaunch` is PURE (it reads the filesystem to decide which declared dirs
56
+ // resolve, but writes nothing). `materializeLaunchAssembly` performs the disk writes the
57
+ // description implies, driven by the SAME resolvers — so what's written equals what's
58
+ // reported equals what's launched.
59
+
60
+ export interface AssembledAsset {
61
+ name: string;
62
+ /** Materialized/in-place dir feeding the launch (e.g. a --plugin-dir or skills.config path). */
63
+ dir: string;
64
+ }
65
+
66
+ export type AssembledInstructionDisposition = "allow" | "suppressed" | "isolated" | "unsupported";
67
+
68
+ export interface AssembledInstructions {
69
+ /** Repo CLAUDE.md/AGENTS.md cascade: "allow" left available, "suppressed" by the
70
+ * vanilla launch mechanism, "unsupported" requested-but-not-enforced (non-vanilla isolated). */
71
+ repo: AssembledInstructionDisposition;
72
+ /** Host/global provider instructions: "allow", "isolated" (dropped by the isolated home), or "unsupported". */
73
+ global: AssembledInstructionDisposition;
74
+ /** A composed systemPromptAppend block (project preamble + profile system/append +
75
+ * caller context, composed relay-side by composeSpawnInstructions) flows into this launch. */
76
+ systemPromptAppend: boolean;
77
+ // ── #559 SEAM ──────────────────────────────────────────────────────────────────
78
+ // Scoped {profile|user} memory does not exist yet (#559 generalizes Projects' anchor
79
+ // to project|profile|user). When it lands, the assembled instruction world becomes
80
+ // "instructions + provisioned assets + scoped knowledge": the relay-side composer
81
+ // (composeSpawnInstructions) gains a profile/user-scoped preamble beside the existing
82
+ // project preamble, and THIS field reports which scopes contributed. Until then it is
83
+ // always "none" — a thin add for #559, not a refactor.
84
+ scopedKnowledge: "none";
85
+ }
86
+
87
+ export interface AssembledMcp {
88
+ /** The identity-bearing relay HTTP MCP endpoint is injected. */
89
+ relayEndpoint: boolean;
90
+ /** Only the assembled servers may load (Claude --strict-mcp-config). #662: true for ALL
91
+ * managed Claude launches so the project `.mcp.json` enable-modal can't wedge a headless
92
+ * agent; relay's endpoint + provisioned servers (via --mcp-config) are preserved. Always
93
+ * false for Codex (no equivalent discovery modal). */
94
+ strict: boolean;
95
+ /** Relay-provisioned server names (excludes the relay endpoint itself). */
96
+ servers: string[];
97
+ /** #667 — project `.mcp.json` server names projected from the spawn cwd's committed
98
+ * `.mcp.json` (via `--mcp-config`, so they survive strict). Sourced from the project,
99
+ * not ambient host MCP. Empty unless the profile/spawn opted into `projectMcp`, and a
100
+ * name already declared by relay-provisioned `servers` wins (not double-listed here). */
101
+ projectServers: string[];
102
+ /** #662 — host USER-scope MCP server names (`~/.claude.json` `mcpServers`) re-injected
103
+ * into `--mcp-config` for an `mcp.mode:"host"` profile, so `--strict-mcp-config` keeps
104
+ * the ambient host MCP (callmux/qmd/gh) instead of dropping it. Empty for non-host mcp
105
+ * modes, isolated bases (own config home, never had host MCP), and Codex. */
106
+ hostServers: string[];
107
+ /** #672 — when the profile/spawn opted into `sharedMcp`, the agent's NON-IDENTITY MCP
108
+ * (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via
109
+ * a per-agent `callmux bridge --cwd "$WORKTREE"` (session-cwd isolation), injected through
110
+ * the same `--mcp-config` / `-c` channel so it survives strict. `url` is the listener the
111
+ * bridge connects to. Undefined when projection is OFF (the default). The bridge is opaque
112
+ * at assembly time — its server SET is owned by the listener config, not enumerated here.
113
+ * The identity-bearing relay endpoint is never on the shared listener (#215). */
114
+ sharedListener?: { url: string };
115
+ }
116
+
117
+ export interface AssembledLaunch {
118
+ provider: SpawnProvider;
119
+ /** The resolved profile this launch was assembled from; undefined for a profile-less
120
+ * (legacy default) spawn, which behaves as host base with relay features default-on. */
121
+ profile?: AgentProfile;
122
+ base: AgentProfileBase;
123
+ vanilla: boolean;
124
+ /** Isolated provider config home (CLAUDE_CONFIG_DIR / CODEX_HOME), or undefined for host base. */
125
+ configHome?: string;
126
+ relayContext: boolean;
127
+ /** Bundled relay skills are available (Codex skills.config / Claude via the relay plugin). */
128
+ relaySkills: boolean;
129
+ /** Bundled relay plugin dir is passed (Claude only). */
130
+ relayPlugin: boolean;
131
+ /** Relay status-line context probe is injected (Claude only). */
132
+ statusLine: boolean;
133
+ /** Relay-provisioned skills materialized for this launch. */
134
+ skills: AssembledAsset[];
135
+ /** Project `.claude/skills` materialized for this Claude launch (#668). */
136
+ projectSkills: AssembledAsset[];
137
+ /** Host/global Claude skills explicitly inherited without enabling host MCP. */
138
+ hostSkills: AssembledAsset[];
139
+ /** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
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;
155
+ /** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
156
+ plugins: AssembledAsset[];
157
+ /** Host/global provider plugin dirs explicitly inherited without enabling host MCP. */
158
+ hostPluginDirs: string[];
159
+ mcp: AssembledMcp;
160
+ instructions: AssembledInstructions;
161
+ /** Provider provisioning/vanilla/instruction launch-arg block, in launch order. */
162
+ args: string[];
163
+ /** Provisioning-derived env additions (the isolated config-home var). */
164
+ env: Record<string, string>;
165
+ }
166
+
167
+ // ── Bundled relay assets (relocated from the adapters so the assembler — not each
168
+ // adapter — owns launch-arg construction; #557). Adapters re-export for test parity. ──
169
+
170
+ const CLAUDE_RELAY_PLUGIN_DIR = resolve(import.meta.dir, "../plugins/claude");
171
+ const CODEX_BUNDLED_SKILLS_DIR = resolve(import.meta.dir, "../plugins/codex/skills");
172
+
173
+ export function bundledCodexSkillDirs(baseDir = CODEX_BUNDLED_SKILLS_DIR): string[] {
174
+ if (!existsSync(baseDir)) return [];
175
+ return readdirSync(baseDir, { withFileTypes: true })
176
+ .filter((entry) => entry.isDirectory() && existsSync(join(baseDir, entry.name, "SKILL.md")))
177
+ .map((entry) => join(baseDir, entry.name))
178
+ .sort();
179
+ }
180
+
181
+ export function bundledSkillConfigArgs(skillDirs = bundledCodexSkillDirs()): string[] {
182
+ if (skillDirs.length === 0) return [];
183
+ const skillsConfig = skillDirs.map((dir) => `{path=${tomlString(dir)},enabled=true}`).join(",");
184
+ return ["-c", `skills.config=[${skillsConfig}]`];
185
+ }
186
+
187
+ // #552 vanilla base (Codex): suppress the AGENTS.md project-doc cascade. project_doc_max_bytes
188
+ // (default 32768) caps bytes Codex reads from AGENTS.md walking root→cwd; 0 stops before adding
189
+ // any, so no repo (or fresh-CODEX_HOME global) project doc loads. The isolated CODEX_HOME already
190
+ // drops host config.toml MCP/hooks; this closes the cwd-resident repo instruction channel.
191
+ export function codexProjectDocSuppressionArgs(config: Pick<RunnerSpawnConfig, "agentProfile">): string[] {
192
+ return profileIsVanillaBase(config) ? ["-c", "project_doc_max_bytes=0"] : [];
193
+ }
194
+
195
+ function hasSettingsArg(args: string[]): boolean {
196
+ return args.some((arg) => arg === "--settings" || arg.startsWith("--settings="));
197
+ }
198
+
199
+ // Claude relay status-line + readable-thinking settings, injected only when no caller
200
+ // --settings is already present (caller wins). Relocated from claude.ts (#557).
201
+ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
202
+ if (argLists.some(hasSettingsArg)) return [];
203
+ return ["--settings", JSON.stringify(relayStatusLineSettings())];
204
+ }
205
+
206
+ function relayStatusLineSettings(): Record<string, unknown> {
207
+ return {
208
+ statusLine: { type: "command", command: `node ${shellQuote(claudeStatusLineProbePath())}`, refreshInterval: 30 },
209
+ showThinkingSummaries: true,
210
+ };
211
+ }
212
+
213
+ function claudeStatusLineProbePath(): string {
214
+ return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
215
+ }
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.
232
+ function claudeSettingsArgs(
233
+ projectSettings: Record<string, unknown>,
234
+ provisionedHooks: ProvisioningHookSet,
235
+ hookNames: string[],
236
+ includeStatusLine: boolean,
237
+ ...argLists: string[][]
238
+ ): ClaudeSettingsResolution {
239
+ const relaySettings: Record<string, unknown> = {
240
+ ...projectSettings,
241
+ ...(Object.keys(provisionedHooks).length ? { hooks: provisionedHooks } : {}),
242
+ ...(includeStatusLine ? relayStatusLineSettings() : {}),
243
+ };
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;
326
+ }
327
+
328
+ // ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
329
+
330
+ function repoInstructionDisposition(profile: AgentProfile): AssembledInstructionDisposition {
331
+ if (profile.instructions.repoInstructions === "allow") return "allow";
332
+ if (profile.base === "vanilla") return "suppressed";
333
+ // Non-vanilla isolated bases can request ignore but have no launch-time enforcement yet.
334
+ return "unsupported";
335
+ }
336
+
337
+ function globalInstructionDisposition(profile: AgentProfile): AssembledInstructionDisposition {
338
+ if (profile.instructions.globalInstructions === "allow") return "allow";
339
+ if (profile.base !== "host") return "isolated";
340
+ return "unsupported";
341
+ }
342
+
343
+ function assembleInstructions(config: RunnerSpawnConfig): AssembledInstructions {
344
+ const profile = config.agentProfile;
345
+ return {
346
+ // A profile-less spawn is host-base: the full host instruction cascade is left available.
347
+ repo: profile ? repoInstructionDisposition(profile) : "allow",
348
+ global: profile ? globalInstructionDisposition(profile) : "allow",
349
+ systemPromptAppend: Boolean(config.systemPromptAppend?.trim()),
350
+ scopedKnowledge: "none",
351
+ };
352
+ }
353
+
354
+ // ── Per-provider arg assembly ──────────────────────────────────────────────────────
355
+
356
+ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]): AssembledLaunch {
357
+ const configHome = providerHomePathFor("claude", config);
358
+ const assetHome = providerProvisioningHomePathFor("claude", config);
359
+ const vanilla = profileIsVanillaBase(config);
360
+ const projectConfigProjection = projectConfigProjectionOn(config);
361
+ const relayPlugin = profileAllowsRelayFeature(config, "plugins");
362
+ const relaySkills = profileAllowsRelayFeature(config, "skills");
363
+ const statusLine = profileAllowsRelayFeature(config, "statusLine");
364
+
365
+ const skills = configHome ? resolveClaudeProvisionedSkills(configHome, config) : [];
366
+ const hostSkills = configHome
367
+ ? resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)))
368
+ : [];
369
+ const projectSkills = configHome
370
+ ? resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name)))
371
+ : [];
372
+ const projectSettings = resolveProjectClaudeSettings(config);
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;
392
+
393
+ // Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
394
+ // relay-bundled plugin → only when relay.plugins (carries the relay monitor +
395
+ // turn-lifecycle hooks #556, not reproducible via settings.json).
396
+ // relay-provisioned plugins (#556) → materialized into the isolated home.
397
+ // host plugin dirs → ONLY host-base profiles; isolated/vanilla must not pull them back in.
398
+ const relayPluginDirs = !config.agentProfile && relayPlugin ? [CLAUDE_RELAY_PLUGIN_DIR] : [];
399
+ const provisionedPluginDirs = provisionedPlugins.map((p) => p.dir);
400
+ const hostPluginDirs = profileAllowsHostAssets(config, "plugins") ? providerConfig.pluginDirs : [];
401
+ const pluginDirs = [...new Set([...relayPluginDirs, ...provisionedPluginDirs, ...hostPluginDirs])];
402
+
403
+ const provisionedMcp = profileProvisioning(config).mcpServers;
404
+ const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
405
+ const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
406
+
407
+ // #667 — project `.mcp.json` projection (opt-in). The spawn cwd's committed servers are
408
+ // injected through the SAME `--mcp-config` channel as relay-provisioned servers, so they
409
+ // survive `--strict-mcp-config` without the ambient project-discovery modal. A relay-
410
+ // provisioned server of the same name wins (the profile is the explicit, relay-owned
411
+ // source); the relay endpoint name is reserved. `declaredMcp` is the union relay injects.
412
+ const projectMcpRaw = resolveProjectMcpServers(config);
413
+ const projectMcp = Object.fromEntries(
414
+ Object.entries(projectMcpRaw).filter(
415
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
416
+ ),
417
+ );
418
+ const projectServers = Object.keys(projectMcp);
419
+
420
+ // #672 — shared host-listener MCP (opt-in, default OFF). When on, route the agent's
421
+ // NON-IDENTITY MCP (tokenlean, github) through ONE orchestrator-owned shared `callmux`
422
+ // listener via a per-agent `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry:
423
+ // the MCP session survives a listener/downstream restart, and --cwd gives each isolated
424
+ // worktree session-cwd isolation. It's a plain stdio server descriptor, so it rides the same
425
+ // controlled --mcp-config channel as provisioned servers (survives --strict-mcp-config) — no
426
+ // ambient discovery, no modal. Relay-owned infra → authoritative on its name (spread last);
427
+ // the relay endpoint stays per-agent (#215), NEVER on the shared listener.
428
+ const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
429
+ const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
430
+ const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
431
+ ? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
432
+ : {};
433
+ const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
434
+
435
+ // #662/#663 — strict controlled MCP is a Claude prompt-gate prevention entry, not a
436
+ // launch-assembly special case. It prevents the blocking "N new MCP servers found in
437
+ // this project" modal while preserving every relay-declared server in --mcp-config.
438
+ const strictMcp = claudeLaunchPromptGateSettings(config).strictMcpConfig;
439
+
440
+ // #662 follow-up — strict drops ALL on-disk config, including the host user-scope
441
+ // `~/.claude.json` MCP that a host-base agent loaded ambiently before strict. That
442
+ // over-reached: it stopped honoring `mcp.mode:"host"`, so default-relay workers/reviewers
443
+ // lost callmux/qmd/gh. Re-inject the host USER-scope servers (NOT the project `.mcp.json`,
444
+ // still excluded) when the launch both uses the host config home AND its mcp mode is host.
445
+ // An isolated/minimal base has its own config home and never had the real host's user MCP,
446
+ // so it stays relay+provisioned only even if a profile sets mode:"host".
447
+ const usesHostMcp = profileUsesProviderHostGlobals(config)
448
+ && (!config.agentProfile || config.agentProfile.mcp.mode === "host");
449
+ const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
450
+ const hostMcpServers = Object.keys(hostMcp).filter(
451
+ (name) => name !== RELAY_MCP_SERVER_NAME && !(name in declaredMcp),
452
+ );
453
+
454
+ const instructions = assembleInstructions(config);
455
+
456
+ // Launch order matches the historical claude buildSpawnArgs block exactly so existing
457
+ // arg assertions hold: vanilla scope → plugin dirs → MCP → status-line → append.
458
+ const args = [
459
+ ...(vanilla || projectConfigProjection ? ["--setting-sources", "user"] : []),
460
+ ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
461
+ ...claudeMcpLaunchArgs({
462
+ relayUrl: config.relayUrl,
463
+ ...(config.relayMcpEndpoint ? { endpoint: config.relayMcpEndpoint } : {}),
464
+ includeRelay: relayEndpoint,
465
+ servers: declaredMcp,
466
+ hostServers: hostMcp,
467
+ strict: strictMcp,
468
+ }),
469
+ ...settings.args,
470
+ ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
471
+ ];
472
+
473
+ return {
474
+ provider: "claude",
475
+ ...(config.agentProfile ? { profile: config.agentProfile } : {}),
476
+ base: config.agentProfile?.base ?? "host",
477
+ vanilla,
478
+ ...(configHome ? { configHome } : {}),
479
+ relayContext: profileAllowsRelayFeature(config, "context"),
480
+ relaySkills,
481
+ relayPlugin,
482
+ statusLine,
483
+ skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
484
+ hostSkills: hostSkills.map((s) => ({ name: s.name, dir: s.dir })),
485
+ projectSkills: projectSkills.map((s) => ({ name: s.name, dir: s.dir })),
486
+ projectSettings: {
487
+ applied: projectSettings.applied,
488
+ keys: projectSettings.keys,
489
+ ignoredKeys: projectSettings.ignoredKeys,
490
+ ...(projectSettings.file ? { file: projectSettings.file } : {}),
491
+ },
492
+ hooks: deliveredHooks.map((h) => ({ name: h.name, dir: h.dir })),
493
+ ...(hooksSuppressed ? { hooksSuppressed } : {}),
494
+ callerSettingsConsumed: settings.callerSettingsConsumed,
495
+ plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
496
+ hostPluginDirs,
497
+ mcp: {
498
+ relayEndpoint, strict: strictMcp, servers: mcpServers, projectServers, hostServers: hostMcpServers,
499
+ ...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
500
+ },
501
+ instructions,
502
+ args,
503
+ env: configHomeEnv("claude", configHome),
504
+ };
505
+ }
506
+
507
+ function assembleCodex(config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
508
+ const configHome = providerHomePathFor("codex", config);
509
+ const assetHome = providerProvisioningHomePathFor("codex", config);
510
+ const vanilla = profileIsVanillaBase(config);
511
+ const relaySkills = profileAllowsRelayFeature(config, "skills");
512
+ const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
513
+
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;
531
+ const bundledSkillDirs = !config.agentProfile && relaySkills ? bundledCodexSkillDirs() : [];
532
+ const skillDirs = [...bundledSkillDirs, ...provisionedSkills.map((s) => s.dir)];
533
+
534
+ const provisionedMcp = profileProvisioning(config).mcpServers;
535
+ const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
536
+
537
+ // #667 — project `.mcp.json` projection (opt-in), same union rule as Claude: a relay-
538
+ // provisioned server of the same name wins; the relay endpoint name is reserved. Codex has
539
+ // no `.mcp.json` discovery of its own, so this is purely additive (no strict/modal concern).
540
+ const projectMcpRaw = resolveProjectMcpServers(config);
541
+ const projectMcp = Object.fromEntries(
542
+ Object.entries(projectMcpRaw).filter(
543
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
544
+ ),
545
+ );
546
+ const projectServers = Object.keys(projectMcp);
547
+
548
+ // #672 — shared host-listener MCP (opt-in, default OFF), same as Claude: a per-agent
549
+ // `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry routes the agent's
550
+ // NON-IDENTITY MCP (tokenlean, github) through the shared listener (session survives a
551
+ // restart; --cwd gives session-cwd isolation). Emitted via the same `-c mcp_servers.*`
552
+ // overrides as provisioned servers. The relay endpoint stays per-agent (#215).
553
+ const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
554
+ const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
555
+ const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
556
+ ? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
557
+ : {};
558
+ const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
559
+
560
+ const instructions = assembleInstructions(config);
561
+
562
+ // The skills/MCP/project-doc block; core launch config (model/approval/listen) stays
563
+ // in the adapter. -c overrides are order-independent, so the assembled block is contiguous.
564
+ const args = [
565
+ ...bundledSkillConfigArgs(skillDirs),
566
+ ...(relayEndpoint ? relayMcpCodexConfigArgs(config.relayUrl, config.relayMcpEndpoint) : []),
567
+ ...codexProvisionedMcpArgs(declaredMcp),
568
+ ...codexProjectDocSuppressionArgs(config),
569
+ ];
570
+
571
+ return {
572
+ provider: "codex",
573
+ ...(config.agentProfile ? { profile: config.agentProfile } : {}),
574
+ base: config.agentProfile?.base ?? "host",
575
+ vanilla,
576
+ ...(configHome ? { configHome } : {}),
577
+ relayContext: profileAllowsRelayFeature(config, "context"),
578
+ relaySkills,
579
+ relayPlugin: false,
580
+ statusLine: false,
581
+ skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
582
+ hostSkills: [],
583
+ projectSkills: [],
584
+ projectSettings: { applied: false, keys: [], ignoredKeys: [] },
585
+ hooks: provisionedHooks.map((h) => ({ name: h.name, dir: h.dir })),
586
+ ...(hooksSuppressed ? { hooksSuppressed } : {}),
587
+ callerSettingsConsumed: false,
588
+ plugins: [],
589
+ hostPluginDirs: [],
590
+ mcp: {
591
+ relayEndpoint, strict: false, servers: mcpServers, projectServers, hostServers: [],
592
+ ...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
593
+ },
594
+ instructions,
595
+ args,
596
+ env: configHomeEnv("codex", configHome),
597
+ };
598
+ }
599
+
600
+ // ── Manifest-driven env + dispatch ────────────────────────────────────────────
601
+
602
+ function configHomeEnv(provider: string, configHome: string | undefined): Record<string, string> {
603
+ if (!configHome) return {};
604
+ const manifest = getManifest(provider);
605
+ const envVar = manifest?.home?.configDirEnvVar;
606
+ return envVar ? { [envVar]: configHome } : {};
607
+ }
608
+
609
+ type Assembler = (config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]) => AssembledLaunch;
610
+ type Materializer = (config: RunnerSpawnConfig) => void;
611
+
612
+ const ASSEMBLERS: Record<string, Assembler> = {
613
+ claude: assembleClaude,
614
+ codex: (config, providerConfig) => assembleCodex(config, providerConfig),
615
+ };
616
+
617
+ const MATERIALIZERS: Record<string, Materializer> = {
618
+ claude: materializeClaudeAssets,
619
+ codex: materializeCodexAssets,
620
+ };
621
+
622
+ function materializeClaudeAssets(config: RunnerSpawnConfig): void {
623
+ const configHome = providerHomePathFor("claude", config);
624
+ const assetHome = providerProvisioningHomePathFor("claude", config);
625
+ if (configHome) {
626
+ const skills = resolveClaudeProvisionedSkills(configHome, config);
627
+ materializeResolvedAssets(skills);
628
+ const hostSkills = resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)));
629
+ materializeResolvedAssets(hostSkills);
630
+ materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name))));
631
+ materializeResolvedAssets(resolveProvisionedHooks(configHome, config));
632
+ }
633
+ if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
634
+ if (!configHome && assetHome) materializeResolvedAssets(resolveProvisionedHooks(assetHome, config));
635
+ }
636
+
637
+ function materializeCodexAssets(config: RunnerSpawnConfig): void {
638
+ const assetHome = providerProvisioningHomePathFor("codex", config);
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
+ }
648
+ }
649
+
650
+ /**
651
+ * Assemble the launch for `provider` from the resolved profile + provisioning. PURE:
652
+ * resolves which declared assets exist (filesystem reads) but writes nothing. A missing
653
+ * `agentProfile` assembles the legacy default (host base, relay features on, no provisioning).
654
+ */
655
+ export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
656
+ const assembler = ASSEMBLERS[provider];
657
+ if (!assembler) throw new Error(`no launch assembler registered for provider: ${provider}`);
658
+ const hostDefaultArgs = profileUsesProviderHostGlobals(config) ? providerConfig.defaultArgs : [];
659
+ return assembler(config, providerConfig, hostDefaultArgs);
660
+ }
661
+
662
+ /**
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 ==
665
+ * report. Idempotent; the isolated home must already exist (prepare*ProfileHome). No-op
666
+ * for a host-base profile.
667
+ */
668
+ export function materializeLaunchAssembly(provider: SpawnProvider, config: RunnerSpawnConfig): void {
669
+ const configHome = providerHomePathFor(provider, config);
670
+ const assetHome = providerProvisioningHomePathFor(provider, config);
671
+ if (!configHome && !assetHome) return;
672
+ const materializer = MATERIALIZERS[provider];
673
+ if (materializer) materializer(config);
674
+ }