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.
- package/package.json +3 -3
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +8 -8
- package/src/adapter.ts +113 -11
- package/src/adapters/claude-delivery.ts +1 -7
- package/src/adapters/claude-exit.ts +289 -0
- package/src/adapters/claude-prompt-gates.ts +59 -79
- package/src/adapters/claude-session-capture.ts +13 -2
- package/src/adapters/claude-session-probe.ts +98 -7
- package/src/adapters/claude-shutdown.ts +149 -0
- package/src/adapters/claude-status-detectors.ts +201 -69
- package/src/adapters/claude-tmux.ts +94 -5
- package/src/adapters/claude-transcript-tail.ts +22 -3
- package/src/adapters/claude-transcript.ts +95 -1
- package/src/adapters/claude.ts +179 -76
- package/src/adapters/codex.ts +46 -8
- package/src/busy-reconciler.ts +67 -29
- package/src/config.ts +16 -0
- package/src/launch-assembly.ts +251 -82
- package/src/native-memory-lever.ts +308 -0
- package/src/outbox.ts +22 -4
- package/src/profile-projection.ts +45 -1
- package/src/provider-input-queue.ts +100 -0
- package/src/rate-limit.ts +106 -36
- package/src/relay-injection-events.ts +24 -0
- package/src/relay-instructions.ts +9 -0
- package/src/relay-mcp-proxy.ts +79 -8
- package/src/relay-mcp.ts +217 -8
- package/src/runner-core.ts +323 -47
- package/src/runner-helpers.ts +10 -0
- package/src/transcript-context.ts +83 -0
- package/src/turn-reconcile-event.ts +48 -0
|
@@ -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(() =>
|
|
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
|
-
|
|
191
|
-
this.pollTimer = setInterval(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|