agent-relay-runner 0.129.11 → 0.129.13

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.
@@ -0,0 +1,308 @@
1
+ // #1565 P5.7 (design §10.3) — the provider-NATIVE-memory lever.
2
+ //
3
+ // WHAT THIS IS FOR. Every agent today can carry two competing memory stores: relay memory (which
4
+ // survives a respawn) and the provider's own (which, for 8 of 11 built-in profiles, is destroyed
5
+ // at every spawn because the managed provider home is instanceId-keyed). This module renders one
6
+ // profile field into the concrete per-provider switches that make relay memory THE store.
7
+ //
8
+ // TWO INVARIANTS, both load-bearing:
9
+ //
10
+ // 1. ABSENT ⇒ ON, and absent emits NOTHING. A profile without `nativeMemory` produces an empty
11
+ // application: no env, no args, no settings keys. The launch is byte-identical to today. This
12
+ // is what keeps P5.7 (the lever) separate from P5.8 (the rollout that flips profiles).
13
+ //
14
+ // 2. A DECLARED policy renders EXPLICITLY IN BOTH DIRECTIONS — never as "omit the flag"
15
+ // (§10.3.5). `policy:"on"` emits `CLAUDE_CODE_DISABLE_AUTO_MEMORY=0` / `-c
16
+ // features.memories=true`, not an absence. An absence cannot override an `autoMemoryEnabled:
17
+ // false` or `features.memories = false` written into a reused managed home on an earlier
18
+ // launch, so a K3 kill-switch pull would be silently defeated. An explicit value cannot be.
19
+ //
20
+ // REACH, per §10.3.1's table — and the reason the env/argv lever is the one that must always be
21
+ // present: relay writes a managed `settings.json`/`config.toml` only into an ISOLATED home, and
22
+ // must never clobber a host-base agent's real `~/.claude`/`~/.codex`. Claude's env var sits ABOVE
23
+ // the settings read in the CLI's own predicate and Codex's `-c` sits above `config.toml`, so the
24
+ // portable lever is also the authoritative one; the file channels are a belt, not the trousers.
25
+ //
26
+ // NEVER THROWS. Resolution is total: any unexpected input degrades to "nothing applied, reported
27
+ // as suppressed" rather than propagating. A failure to apply this lever must not fail a spawn
28
+ // (the #1612 discipline, applied to the switch instead of the preamble) — an agent that launches
29
+ // with its native memory still on is a two-store agent, which is exactly today's state; an agent
30
+ // that fails to launch is an outage.
31
+
32
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import type { AgentProfileNativeMemoryPolicy, SpawnProvider } from "agent-relay-sdk";
35
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
36
+ import type { RunnerSpawnConfig } from "./adapter";
37
+ import { providerHomeRootFromEnv } from "./config";
38
+ import { profileUsesProviderHostGlobals } from "./profile-home";
39
+ import { upsertTomlTable } from "./relay-mcp";
40
+
41
+ export const CLAUDE_DISABLE_AUTO_MEMORY_ENV = "CLAUDE_CODE_DISABLE_AUTO_MEMORY";
42
+ export const CLAUDE_AUTO_MEMORY_ENABLED_SETTING = "autoMemoryEnabled";
43
+ export const CLAUDE_AUTO_MEMORY_DIRECTORY_SETTING = "autoMemoryDirectory";
44
+ export const CODEX_MEMORIES_FEATURE_KEY = "features.memories";
45
+
46
+ /** `"default"` = the profile declared nothing, so no lever is emitted at all. */
47
+ export type ResolvedNativeMemoryPolicy = AgentProfileNativeMemoryPolicy | "default";
48
+
49
+ export interface NativeMemoryLever {
50
+ policy: ResolvedNativeMemoryPolicy;
51
+ /** Env additions for the launch. Empty for `"default"`. */
52
+ env: Record<string, string>;
53
+ /** Argv additions for the launch. Empty for `"default"`. */
54
+ args: string[];
55
+ /** Provider-settings keys relay folds into its OWN settings channel (Claude `--settings` and,
56
+ * for an isolated home, the managed `settings.json`). Empty for `"default"` and for Codex. */
57
+ settings: Record<string, unknown>;
58
+ /** Codex only: `config.toml` keys for an ISOLATED `CODEX_HOME`. Empty otherwise. */
59
+ configToml: Record<string, string>;
60
+ /** The levers that actually reach this launch, as short stable ids for the projection report. */
61
+ applied: string[];
62
+ /** Levers that were resolved but could NOT be delivered on this base/provider. Reported LOUDLY
63
+ * (the `hooksSuppressed` precedent, #1096) so a lever is never silently absent while the
64
+ * report claims it applied. */
65
+ suppressed?: { reason: string; levers: string[] };
66
+ }
67
+
68
+ const NONE: NativeMemoryLever = { policy: "default", env: {}, args: [], settings: {}, configToml: {}, applied: [] };
69
+
70
+ function none(): NativeMemoryLever {
71
+ return { ...NONE, env: {}, args: [], settings: {}, configToml: {}, applied: [] };
72
+ }
73
+
74
+ /**
75
+ * A STABLE, relay-owned, per-profile directory for `policy:"redirect"` (§10.3.4/F3). Deliberately
76
+ * NOT under the instanceId-keyed provider home: the entire point of the redirect is that a
77
+ * host-base profile's native store stops being destroyed every spawn and becomes durable and
78
+ * harvestable. Sibling to the per-instance homes, so one profile's native memory is one directory.
79
+ */
80
+ export function defaultNativeMemoryDirectory(provider: SpawnProvider, config: RunnerSpawnConfig): string {
81
+ const profileName = sanitizeFsName(config.agentProfile?.name || config.profile || "profile", {
82
+ replacement: "-",
83
+ collapse: false,
84
+ maxLen: 120,
85
+ fallback: "profile",
86
+ });
87
+ return join(providerHomeRootFromEnv(), provider, profileName, "native-memory");
88
+ }
89
+
90
+ interface ClaudeDeliveryChannels {
91
+ /** An isolated managed home exists, so relay owns a `settings.json` it may write. */
92
+ managedSettings: boolean;
93
+ /** Relay's inline `--settings` reaches this launch (false when a caller `--settings` is a file
94
+ * path or malformed JSON and therefore unmergeable — the same gate `claudeSettingsArgs` uses). */
95
+ inlineSettings: boolean;
96
+ }
97
+
98
+ function resolveClaudeLever(
99
+ policy: AgentProfileNativeMemoryPolicy,
100
+ directory: string | undefined,
101
+ channels: ClaudeDeliveryChannels,
102
+ resolveDefaultDirectory: () => string,
103
+ ): NativeMemoryLever {
104
+ // The env var is unconditional and authoritative — it works on EVERY base, host included, and
105
+ // outranks the settings read inside the CLI's own predicate.
106
+ const disable = policy === "off";
107
+ const env = { [CLAUDE_DISABLE_AUTO_MEMORY_ENV]: disable ? "1" : "0" };
108
+ const applied = [`env:${CLAUDE_DISABLE_AUTO_MEMORY_ENV}=${disable ? "1" : "0"}`];
109
+
110
+ const settings: Record<string, unknown> = { [CLAUDE_AUTO_MEMORY_ENABLED_SETTING]: !disable };
111
+ if (policy === "redirect") settings[CLAUDE_AUTO_MEMORY_DIRECTORY_SETTING] = directory ?? resolveDefaultDirectory();
112
+
113
+ const settingsLevers = Object.keys(settings).map((key) => `settings:${key}`);
114
+ const settingsReach = channels.managedSettings || channels.inlineSettings;
115
+ if (settingsReach) {
116
+ applied.push(...settingsLevers);
117
+ return { policy, env, args: [], settings, configToml: {}, applied };
118
+ }
119
+ // No settings channel: the env var still carries the on/off decision (and outranks settings
120
+ // anyway), so "off"/"on" lose nothing. A REDIRECT does lose something real — native memory
121
+ // stays on but writes wherever the provider defaults to — so say so instead of implying the
122
+ // redirect happened.
123
+ return {
124
+ policy,
125
+ env,
126
+ args: [],
127
+ settings: {},
128
+ configToml: {},
129
+ applied,
130
+ suppressed: {
131
+ reason: policy === "redirect"
132
+ ? `relay has no settings channel for this launch (no isolated managed home, and the caller's --settings could not be merged), so ${CLAUDE_AUTO_MEMORY_DIRECTORY_SETTING} was NOT applied: native memory stays ON at the provider's default location`
133
+ : `relay has no settings channel for this launch (no isolated managed home, and the caller's --settings could not be merged), so the ${CLAUDE_AUTO_MEMORY_ENABLED_SETTING} belt was not applied; the ${CLAUDE_DISABLE_AUTO_MEMORY_ENV} env lever still applies and outranks settings`,
134
+ levers: settingsLevers,
135
+ },
136
+ };
137
+ }
138
+
139
+ function resolveCodexLever(policy: AgentProfileNativeMemoryPolicy, isolatedHome: boolean): NativeMemoryLever {
140
+ // §10.3.4: Codex memories live in a fixed `CODEX_HOME` sqlite with no redirect knob, so
141
+ // `redirect` degrades to an EXPLICIT "on" — never to "off". Losing a redirect must not be a
142
+ // path that silently deletes a store.
143
+ const enable = policy !== "off";
144
+ const value = enable ? "true" : "false";
145
+ const args = ["-c", `${CODEX_MEMORIES_FEATURE_KEY}=${value}`];
146
+ const applied = [`arg:${CODEX_MEMORIES_FEATURE_KEY}=${value}`];
147
+ const configToml: Record<string, string> = isolatedHome ? { [CODEX_MEMORIES_FEATURE_KEY]: value } : {};
148
+ if (isolatedHome) applied.push(`config.toml:${CODEX_MEMORIES_FEATURE_KEY}=${value}`);
149
+
150
+ const suppressedLevers: string[] = [];
151
+ const reasons: string[] = [];
152
+ if (policy === "redirect") {
153
+ suppressedLevers.push("redirect");
154
+ reasons.push("Codex has no autoMemoryDirectory equivalent (memories live in a fixed CODEX_HOME sqlite), so the redirect could not be applied; native memory was left EXPLICITLY ON instead");
155
+ }
156
+ if (!isolatedHome) {
157
+ suppressedLevers.push(`config.toml:${CODEX_MEMORIES_FEATURE_KEY}`);
158
+ reasons.push(`this host-base launch reuses the real ~/.codex, which relay must not write, so the config.toml belt was not applied; the ${CODEX_MEMORIES_FEATURE_KEY} argv lever still applies and outranks config.toml`);
159
+ }
160
+ return {
161
+ policy,
162
+ env: {},
163
+ args,
164
+ settings: {},
165
+ configToml,
166
+ applied,
167
+ ...(suppressedLevers.length ? { suppressed: { reason: reasons.join("; "), levers: suppressedLevers } } : {}),
168
+ };
169
+ }
170
+
171
+ // Per-provider lever renderers, dispatched by NAME rather than by an identity branch — the same
172
+ // registry shape `launch-assembly`'s ASSEMBLERS/MATERIALIZERS use, so adding a provider is a table
173
+ // entry (and an unregistered provider degrades to "no lever, reported", never to a throw).
174
+ type NativeMemoryLeverRenderer = (
175
+ policy: AgentProfileNativeMemoryPolicy,
176
+ input: ResolveNativeMemoryLeverInput,
177
+ ) => NativeMemoryLever;
178
+
179
+ const LEVER_RENDERERS: Record<string, NativeMemoryLeverRenderer> = {
180
+ claude: (policy, input) => resolveClaudeLever(
181
+ policy,
182
+ input.config.agentProfile?.nativeMemory?.directory,
183
+ { managedSettings: Boolean(input.channels?.managedSettings), inlineSettings: Boolean(input.channels?.inlineSettings) },
184
+ () => defaultNativeMemoryDirectory(input.provider, input.config),
185
+ ),
186
+ codex: (policy, input) => resolveCodexLever(policy, Boolean(input.isolatedHome)),
187
+ };
188
+
189
+ export interface ResolveNativeMemoryLeverInput {
190
+ provider: SpawnProvider;
191
+ config: RunnerSpawnConfig;
192
+ /** Claude only — see {@link ClaudeDeliveryChannels}. Ignored for Codex. */
193
+ channels?: Partial<ClaudeDeliveryChannels>;
194
+ /** Codex only — true when this launch has an isolated `CODEX_HOME` relay may write. */
195
+ isolatedHome?: boolean;
196
+ }
197
+
198
+ /**
199
+ * Render the profile's `nativeMemory` field into concrete launch levers. Total: returns the
200
+ * empty application (policy `"default"`) when the profile declares nothing, and converts ANY
201
+ * unexpected failure into a reported suppression rather than a throw.
202
+ */
203
+ export function resolveNativeMemoryLever(input: ResolveNativeMemoryLeverInput): NativeMemoryLever {
204
+ try {
205
+ const declared = input.config.agentProfile?.nativeMemory;
206
+ if (!declared) return none();
207
+ const policy = declared.policy ?? (declared.directory ? "redirect" : "on");
208
+ if (policy !== "on" && policy !== "off" && policy !== "redirect") {
209
+ return {
210
+ ...none(),
211
+ suppressed: { reason: `unrecognized nativeMemory.policy ${JSON.stringify(policy)}; no lever was applied and the provider default (native memory ON) stands`, levers: ["nativeMemory"] },
212
+ };
213
+ }
214
+ const render = LEVER_RENDERERS[input.provider];
215
+ if (render) return render(policy, input);
216
+ return {
217
+ ...none(),
218
+ policy,
219
+ suppressed: { reason: `provider ${input.provider} has no known native-memory lever; nothing was applied`, levers: ["nativeMemory"] },
220
+ };
221
+ } catch (err) {
222
+ // The fail-safe (#1612's discipline applied to the switch). A lever we could not compute must
223
+ // degrade to "not applied, loudly reported" — never to a failed spawn.
224
+ return {
225
+ ...none(),
226
+ suppressed: { reason: `native-memory lever could not be resolved: ${(err as Error)?.message ?? String(err)}; no lever was applied and the provider default (native memory ON) stands`, levers: ["nativeMemory"] },
227
+ };
228
+ }
229
+ }
230
+
231
+ // ── Launch-seam adapters ───────────────────────────────────────────────────────
232
+ //
233
+ // The two entry points `launch-assembly` calls, kept here so the lever's whole surface — what it
234
+ // emits, where each half is delivered, and what happens when a delivery fails — reads as one file.
235
+
236
+ /**
237
+ * Resolve the Claude lever with its REAL delivery channels. The `inlineSettings` reach uses the
238
+ * same caller-`--settings` mergeability gate `claudeSettingsArgs` applies, so the lever's
239
+ * applied/suppressed report cannot claim a settings key the merge then drops. Both `assembleClaude`
240
+ * (for the launch) and `materializeClaudeAssets` (for the managed settings.json) call this, so the
241
+ * launch and the disk agree — the `claudeManagedHookSettings` pattern.
242
+ */
243
+ export function resolveClaudeNativeMemoryLever(
244
+ config: RunnerSpawnConfig,
245
+ configHome: string | undefined,
246
+ hostDefaultArgs: string[],
247
+ callerSettingsMergeable: (argLists: string[][]) => boolean,
248
+ ): NativeMemoryLever {
249
+ return resolveNativeMemoryLever({
250
+ provider: "claude",
251
+ config,
252
+ channels: {
253
+ managedSettings: Boolean(configHome),
254
+ inlineSettings: callerSettingsMergeable([hostDefaultArgs, config.providerArgs]),
255
+ },
256
+ });
257
+ }
258
+
259
+ /**
260
+ * The native-memory keys `materializeClaudeAssets` writes into an ISOLATED managed home's
261
+ * settings.json. Returns `{}` for host base (which reuses the real `~/.claude` — relay must not
262
+ * write it); that base is covered by the env lever plus the inline `--settings`.
263
+ */
264
+ export function claudeManagedNativeMemorySettings(
265
+ config: RunnerSpawnConfig,
266
+ configHome: string | undefined,
267
+ callerSettingsMergeable: (argLists: string[][]) => boolean,
268
+ ): Record<string, unknown> {
269
+ if (!configHome) return {};
270
+ const hostDefaultArgs = profileUsesProviderHostGlobals(config) ? config.providerConfig.defaultArgs : [];
271
+ return resolveClaudeNativeMemoryLever(config, configHome, hostDefaultArgs, callerSettingsMergeable).settings;
272
+ }
273
+
274
+ /**
275
+ * The fail-safe at the APPLY seam (#1612's discipline, moved from the preamble to the switch). The
276
+ * file-channel belt is best-effort by construction: the authoritative lever is the env var / `-c`
277
+ * arg, computed into the launch and touching no disk, so a failed belt write costs nothing an
278
+ * operator can observe — while a THROW here would fail the whole spawn. So: never propagate, and
279
+ * never swallow either. Returns the reason on failure (also logged), `undefined` on success.
280
+ */
281
+ export function applyNativeMemoryBelt(label: string, apply: () => void): string | undefined {
282
+ try {
283
+ apply();
284
+ return undefined;
285
+ } catch (err) {
286
+ const reason = `native-memory lever: ${label} could not be written: ${(err as Error)?.message ?? String(err)}. `
287
+ + "The authoritative env/argv lever still applies (it outranks the file channel), and the spawn continues — design #1565 §10.3.";
288
+ console.warn(reason);
289
+ return reason;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Idempotently upsert the `[features]` table into an ISOLATED `CODEX_HOME/config.toml`. Values are
295
+ * already rendered TOML literals (`"true"`/`"false"`) and are emitted BARE, not quoted:
296
+ * `features.memories` is a boolean, and a quoted string would not parse as one.
297
+ */
298
+ export function writeCodexFeaturesToml(codexHome: string, features: Record<string, string>): void {
299
+ const bodyLines = Object.entries(features)
300
+ .map(([key, value]) => [key.startsWith("features.") ? key.slice("features.".length) : key, value] as const)
301
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
302
+ .map(([key, value]) => `${key} = ${value}`);
303
+ if (!bodyLines.length) return;
304
+ mkdirSync(codexHome, { recursive: true });
305
+ const configPath = join(codexHome, "config.toml");
306
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
307
+ writeFileSync(configPath, upsertTomlTable(existing, "features", bodyLines), { mode: 0o600 });
308
+ }
package/src/outbox.ts CHANGED
@@ -163,7 +163,7 @@ export class Outbox {
163
163
  this.enforceBound();
164
164
  // Defer the drain to a microtask so a synchronous burst of enqueues (e.g. several
165
165
  // coalesce updates) all land — and coalesce — before the pump pulls the head.
166
- queueMicrotask(() => { void this.drain(); });
166
+ queueMicrotask(() => this.pump());
167
167
  return seq;
168
168
  }
169
169
 
@@ -187,11 +187,29 @@ export class Outbox {
187
187
  // Begin the background pump: an initial drain plus a poll timer as a backstop.
188
188
  start(): void {
189
189
  if (this.pollTimer || this.stopped) return;
190
- void this.drain();
191
- this.pollTimer = setInterval(() => { void this.drain(); }, this.pollMs);
190
+ this.pump();
191
+ this.pollTimer = setInterval(() => this.pump(), this.pollMs);
192
192
  this.pollTimer.unref?.();
193
193
  }
194
194
 
195
+ // (#1617) — the ONE way a background caller starts a drain. `drain()` is a bare try/finally with
196
+ // no catch, and drainOnce() touches SQLite outside the send() try/catch (the head SELECT, and the
197
+ // attempt/poison UPDATEs in the failure path), so a closed or erroring database rejects it. Every
198
+ // pump site is fire-and-forget — an enqueue microtask, start()'s initial pump and poll timer, the
199
+ // scheduleDue wake-up — with nobody to await the promise, and Bun exits(1) on an unhandled
200
+ // rejection with no handler installed anywhere in the runner (#1614). That crashed the whole
201
+ // agent over a queue error, on a runner nothing respawns (#1618).
202
+ //
203
+ // Reported through this module's own existing channel (logger, same as the bound-exceeded warn
204
+ // and the poison fatal) rather than swallowed: a pump that stops pumping is its own bug, and
205
+ // it's the only reason the durable queue exists. `drain()` itself keeps its rejecting contract
206
+ // for flush(), which awaits it directly and is bounded by its own deadline.
207
+ private pump(): void {
208
+ void this.drain().catch((error) => {
209
+ logger.error("outbox", `drain failed: ${errMessage(error)}`);
210
+ });
211
+ }
212
+
195
213
  // Drain everything we can before the process exits, ignoring per-row backoff. Used on
196
214
  // shutdown/kill/crash (#183): the capture seam durably enqueues the end-of-session
197
215
  // Insights datapoint, but the per-agent outbox is never reopened once the agent is gone,
@@ -289,7 +307,7 @@ export class Outbox {
289
307
  if (this.stopped || this.dueTimer) return;
290
308
  this.dueTimer = setTimeout(() => {
291
309
  this.dueTimer = undefined;
292
- void this.drain();
310
+ this.pump();
293
311
  }, Math.max(0, delayMs));
294
312
  this.dueTimer.unref?.();
295
313
  }
@@ -1,6 +1,7 @@
1
1
  import type { AgentProfile, AgentProfileProjectionEntry, AgentProfileProjectionReport, SpawnProvider } from "agent-relay-sdk";
2
2
  import { getManifest } from "agent-relay-providers";
3
3
  import type { AssembledLaunch } from "./launch-assembly";
4
+ import { SHARED_MCP_METAONLY_CONFIG_FILENAME } from "./relay-mcp";
4
5
 
5
6
  // #557 — the projection report DESCRIBES an AssembledLaunch, the same object the adapters
6
7
  // turn into launch args. It is no longer a separate re-derivation from the profile, so the
@@ -41,6 +42,7 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
41
42
  add(projectMcpEntry(assembled));
42
43
  add(projectSkillsEntry(assembled));
43
44
  add(projectSettingsEntry(assembled));
45
+ add(nativeMemoryEntry(assembled));
44
46
  add(sharedMcpEntry(assembled));
45
47
  if (profile) add(filesystemEntry(profile));
46
48
  add(configHomeEntry(assembled));
@@ -255,6 +257,36 @@ function projectSettingsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
255
257
  return applied("projectSettings", a.projectSettings.keys.length ? `${a.projectSettings.keys.length} keys` : "none", `Project .claude/settings.json was sanitized and injected through --settings; Relay approvalMode/permission gates remain authoritative${ignored}.`);
256
258
  }
257
259
 
260
+ // #1565 P5.7 — the provider-NATIVE-memory lever (design §10.3). Names which levers actually
261
+ // reached the launch and which were resolved but SUPPRESSED, following the `hooksSuppressed`
262
+ // anti-lie rule (#1096): a lever that could not be delivered is reported as `partial` (so it
263
+ // lands in `warnings`), never as an "applied" that silently didn't happen.
264
+ function nativeMemoryEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
265
+ const lever = a.nativeMemory;
266
+ if (lever.policy === "default") {
267
+ return notApplicable(
268
+ "nativeMemory",
269
+ "absent",
270
+ `The profile declares no nativeMemory policy, so no lever is emitted and ${a.provider}'s own memory keeps its default (ON). `
271
+ + "Relay memory and provider-native memory are both in play for this agent.",
272
+ );
273
+ }
274
+ const what = lever.policy === "off"
275
+ ? `${a.provider}'s OWN memory store is switched OFF, so relay memory is this agent's single store`
276
+ : lever.policy === "redirect"
277
+ ? `${a.provider}'s own memory store stays ON but is redirected to a stable relay-owned directory, so it survives a respawn and stays harvestable`
278
+ : `${a.provider}'s own memory store is EXPLICITLY re-enabled (an explicit value, not an absence — so it outranks any stale setting left in a reused managed home)`;
279
+ const applieds = lever.applied.length ? ` Levers applied: ${lever.applied.join(", ")}.` : "";
280
+ if (lever.suppressed) {
281
+ return partial(
282
+ "nativeMemory",
283
+ lever.policy,
284
+ `${what}.${applieds} SUPPRESSED (${lever.suppressed.levers.join(", ")}): ${lever.suppressed.reason}.`,
285
+ );
286
+ }
287
+ return applied("nativeMemory", lever.policy, `${what}.${applieds}`);
288
+ }
289
+
258
290
  // #672/#689 — shared host-listener MCP provenance. `applied` reports the per-agent `callmux
259
291
  // bridge` route. `not-applicable` means the resolved profile kept shared MCP off (for example a
260
292
  // zero-host profile, explicit false, or global kill-switch normalization before launch).
@@ -262,7 +294,19 @@ function sharedMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
262
294
  const optedIn = Boolean(a.profile?.sharedMcp);
263
295
  const listener = a.mcp.sharedListener;
264
296
  if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off for this resolved profile; non-identity MCP is injected per-agent.");
265
- return applied("sharedMcp", "on", `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"'; session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent.`);
297
+ // #1575 a Codex worker fronts the shared listener with a per-agent metaOnly callmux (~11
298
+ // meta-tools; the ~40 downstream schemas load on demand via callmux_call) instead of the flat
299
+ // bridge; every other profile keeps the flat bridge.
300
+ const route = listener.metaOnly
301
+ ? `a per-agent metaOnly 'callmux --config <home>/${SHARED_MCP_METAONLY_CONFIG_FILENAME}' (meta-tools only; downstream schemas reached on demand)`
302
+ : `a per-agent 'callmux bridge --cwd "$WORKTREE"'`;
303
+ // #1450 — a metaOnly callmux may ALSO front the relay endpoint on-demand (via the runner proxy),
304
+ // so the on-demand relay set (memory/blackboard/artifacts/timers/find_agents) loads through
305
+ // callmux_call instead of eager-booting; the eager hot-path relay set stays on the direct client.
306
+ const relayNote = listener.relayFronted
307
+ ? " The identity-bearing relay endpoint is ALSO fronted here on-demand (via the runner proxy — live token, never on disk): the on-demand relay tools load through callmux_call, the eager hot-path set stays on the per-agent relay client."
308
+ : " Relay endpoint stays per-agent.";
309
+ return applied("sharedMcp", "on", `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via ${route}; session survives a listener restart, session-cwd isolated.${relayNote}`);
266
310
  }
267
311
 
268
312
  function globalInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
@@ -0,0 +1,100 @@
1
+ // One agent, ONE provider input stream (#1565 F1).
2
+ //
3
+ // Every payload the runner hands to a provider — a relay message batch, an injected prompt, a
4
+ // self-resume continuation, a memory preamble — ends up in the same place: the provider's single
5
+ // input channel. None of the transports are atomic:
6
+ //
7
+ // • Claude tmux is paste → sleep → Enter (adapters/claude-tmux.ts). Two concurrent delivers
8
+ // paste BOTH buffers before either Enter fires, so two payloads are submitted as ONE prompt
9
+ // plus a stray Enter — the continuity envelope and the memory block merged into each other.
10
+ // • Codex issues `turn/start` per payload, and a second turn/start on a live turn is dropped by
11
+ // the app-server. Concurrent delivers both pass the adapter's in-turn guard and one payload is
12
+ // silently lost — while its command still reports `succeeded`, disarming the relay backstop
13
+ // that would otherwise re-send it.
14
+ //
15
+ // Nothing upstream serializes this: the relay creates the boundary payloads in one synchronous
16
+ // block, broadcasts them without a queue, and the runner dispatches commands fire-and-forget
17
+ // (`void this.handleCommand(...)`). So the gate belongs here, at the one seam every payload
18
+ // crosses — which fixes the general hazard rather than one pair of payloads.
19
+ //
20
+ // FIFO, AND THE ORDER IS TAKEN SYNCHRONOUSLY. `reserve()` claims a place in the queue with no
21
+ // `await` in between, so a caller that reserves first is served first. That is what makes the
22
+ // ordering guarantee meaningful for the boundary pair: the relay creates the continuation BEFORE
23
+ // the memory block, the bus delivers commands in that order, and the runner reserves in dispatch
24
+ // order — so if only one payload can survive (Codex, one turn), the survivor is the continuation.
25
+ //
26
+ // A ticket that is never released would wedge the stream forever, which is worse than the race it
27
+ // prevents. So every ticket is watchdogged: after `stuckAfterMs` it auto-releases and reports,
28
+ // rather than silently stopping all delivery to the agent.
29
+
30
+ const DEFAULT_STUCK_AFTER_MS = 120_000;
31
+
32
+ export interface ProviderInputTicket {
33
+ /** Resolves when it is this ticket's turn. Never rejects. */
34
+ readonly turn: Promise<void>;
35
+ /** Hand the stream to the next ticket. Idempotent. */
36
+ release(): void;
37
+ }
38
+
39
+ export interface ProviderInputQueueOptions {
40
+ /** How long a held ticket may block the stream before it is force-released. */
41
+ stuckAfterMs?: number;
42
+ /** Reported (never thrown) when a ticket is force-released. */
43
+ onStuck?: (heldMs: number) => void;
44
+ }
45
+
46
+ export class ProviderInputQueue {
47
+ private tail: Promise<void> = Promise.resolve();
48
+ private readonly stuckAfterMs: number;
49
+ private readonly onStuck?: (heldMs: number) => void;
50
+
51
+ constructor(options: ProviderInputQueueOptions = {}) {
52
+ this.stuckAfterMs = options.stuckAfterMs ?? DEFAULT_STUCK_AFTER_MS;
53
+ if (options.onStuck) this.onStuck = options.onStuck;
54
+ }
55
+
56
+ /**
57
+ * Take a place in the queue NOW, without awaiting. The caller may do other async work before
58
+ * awaiting `turn` — its position is already fixed, so arrival order is preserved even when the
59
+ * work before the delivery takes different amounts of time per payload.
60
+ */
61
+ reserve(): ProviderInputTicket {
62
+ const previous = this.tail;
63
+ let handOff!: () => void;
64
+ const held = new Promise<void>((resolve) => { handOff = resolve; });
65
+ // `previous` is built from settled-on-both-paths handlers below, so it never rejects.
66
+ this.tail = previous.then(() => held);
67
+ let released = false;
68
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
69
+ const release = (stuck?: number) => {
70
+ if (released) return;
71
+ released = true;
72
+ if (watchdog) clearTimeout(watchdog);
73
+ if (stuck !== undefined) this.onStuck?.(stuck);
74
+ handOff();
75
+ };
76
+ const turn = previous.then(() => {
77
+ if (released) return;
78
+ watchdog = setTimeout(() => release(this.stuckAfterMs), this.stuckAfterMs);
79
+ watchdog.unref?.();
80
+ }, () => {});
81
+ return { turn, release: () => release() };
82
+ }
83
+
84
+ /**
85
+ * Run `fn` with exclusive use of the provider's input stream. Pass a `ticket` reserved earlier to
86
+ * keep that reservation's place in the queue; otherwise a fresh one is taken here.
87
+ *
88
+ * Only the delivery itself belongs inside — never the bookkeeping around it — so a slow relay
89
+ * round-trip cannot hold the stream shut.
90
+ */
91
+ async run<T>(fn: () => Promise<T>, ticket?: ProviderInputTicket): Promise<T> {
92
+ const slot = ticket ?? this.reserve();
93
+ await slot.turn;
94
+ try {
95
+ return await fn();
96
+ } finally {
97
+ slot.release();
98
+ }
99
+ }
100
+ }