agent-relay-runner 0.129.10 → 0.129.12
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
package/src/rate-limit.ts
CHANGED
|
@@ -64,13 +64,20 @@ function formatResetClock(resetAtMs: number): string {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
// The subscription limit line wordings seen in the wild (claude-auto-retry patterns +
|
|
67
|
-
// claude-auto-retry#15: "session"/"weekly limit" slipped past older regexes).
|
|
68
|
-
//
|
|
69
|
-
//
|
|
67
|
+
// claude-auto-retry#15: "session"/"weekly limit" slipped past older regexes). These match
|
|
68
|
+
// PROSE as readily as the modal, so they are never sufficient on their own — see
|
|
69
|
+
// parseClaudeUsageLimitModal for the structural gate that decides what is a real hold.
|
|
70
70
|
const LIMIT_PHRASE_RE = /(?:hit (?:your|the)\s*(?:[\w-]+\s+)*limit|usage limit|\d+-hour limit|limit reached|out of (?:extra )?usage|rate limit(?:\s+(?:hit|reached|exceeded))?|weekly limit|session limit)/i;
|
|
71
71
|
const RESET_AT_RE = /\bresets?\b(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
|
|
72
72
|
const TRY_AGAIN_RE = /\btry again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
|
|
73
73
|
|
|
74
|
+
// #1611 — the limit phrase and its reset/retry must sit on the same line or within this many
|
|
75
|
+
// lines of each other. The pairing was originally allowed to span the WHOLE pane so a modal
|
|
76
|
+
// wrapping across rows still matched; that made the two halves collectable from unrelated
|
|
77
|
+
// places on screen. A modal wraps onto the very next row, so a tight window keeps the wrapped
|
|
78
|
+
// case (proven in rate-limit.test.ts) without the pane-wide collection.
|
|
79
|
+
const MAX_PAIR_GAP_LINES = 2;
|
|
80
|
+
|
|
74
81
|
interface PaneRateLimit {
|
|
75
82
|
/** The matched limit line, for the chat notice / observability. */
|
|
76
83
|
message: string;
|
|
@@ -83,56 +90,119 @@ interface PaneRateLimit {
|
|
|
83
90
|
|
|
84
91
|
/**
|
|
85
92
|
* Detect a Claude usage/rate-limit modal in a captured tmux pane and parse its reset time.
|
|
86
|
-
*
|
|
87
|
-
* to a modal that wraps the two onto separate lines) — that pairing is what distinguishes the
|
|
88
|
-
* real modal from normal output mentioning "rate limit". Returns null otherwise. `nowMs` is
|
|
89
|
-
* injectable for tests.
|
|
93
|
+
* Returns null when the pane shows no live hold. `nowMs` is injectable for tests.
|
|
90
94
|
*/
|
|
91
95
|
export function parseClaudeRateLimitPane(text: string, nowMs: number = Date.now()): PaneRateLimit | null {
|
|
92
96
|
if (!text) return null;
|
|
93
97
|
const overload = parseClaudeTransientOverloadPane(text);
|
|
94
98
|
if (overload) return overload;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
99
|
+
return parseClaudeUsageLimitModal(text, nowMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* #1611 — the usage-limit modal, distinguished from the agent's OWN pane output.
|
|
104
|
+
*
|
|
105
|
+
* The capture (`capture-pane -S -80`) contains the agent's rendered conversation, so a
|
|
106
|
+
* phrase test over the whole pane fires on any agent that WRITES about rate limits. It did:
|
|
107
|
+
* a coordinator briefing a worker rendered `usage limit · resets 21:00` — which is the very
|
|
108
|
+
* label buildRateLimitProviderState() above produces — and marked itself blocked. Requiring
|
|
109
|
+
* the limit/reset PAIRING does not help either, because that one string carries both halves;
|
|
110
|
+
* neither does any test of line shape, since an agent working on this file renders exact
|
|
111
|
+
* modal fixtures (this comment is itself on screen while you read it).
|
|
112
|
+
*
|
|
113
|
+
* So the gate is POSITIONAL, not textual. Claude Code pins its composer (input box +
|
|
114
|
+
* status/hint footer) to the bottom of the screen, separated from the conversation by a blank
|
|
115
|
+
* row, and draws blocking dialogs in that same spot. Everything the agent itself renders is
|
|
116
|
+
* therefore ABOVE the last blank row; the live modal is BELOW it. We only scan that trailing
|
|
117
|
+
* block — no phrase the agent can emit reaches it.
|
|
118
|
+
*
|
|
119
|
+
* Break condition (accepted, deliberate): if Claude Code ever draws the limit dialog ABOVE a
|
|
120
|
+
* blank row (e.g. above a persistent status bar), this stops firing and the hold is missed —
|
|
121
|
+
* a false negative, which costs one retry via the resume sweep. That is the cheap direction:
|
|
122
|
+
* a false positive marks a healthy agent blocked, and `isAgentUnavailable` (src/db/
|
|
123
|
+
* agent-predicates.ts) then QUEUES its inbound messages until the fabricated reset — real
|
|
124
|
+
* work idled on a fabricated signal. See #1611.
|
|
125
|
+
*/
|
|
126
|
+
function parseClaudeUsageLimitModal(text: string, nowMs: number): PaneRateLimit | null {
|
|
127
|
+
const block = livePaneBlock(text);
|
|
128
|
+
for (let i = 0; i < block.length; i++) {
|
|
129
|
+
const line = block[i]!;
|
|
130
|
+
if (!LIMIT_PHRASE_RE.test(line)) continue;
|
|
131
|
+
const message = cleanPaneLine(line);
|
|
132
|
+
// The reset may wrap onto a neighbouring row of the same modal, never further.
|
|
133
|
+
const window = block.slice(Math.max(0, i - MAX_PAIR_GAP_LINES), i + MAX_PAIR_GAP_LINES + 1).join("\n");
|
|
134
|
+
|
|
135
|
+
const relative = TRY_AGAIN_RE.exec(window);
|
|
136
|
+
if (relative) {
|
|
137
|
+
const unitMs = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 }[relative[2]!.toLowerCase()] ?? 0;
|
|
138
|
+
const resetAt = clampReset(nowMs + Number(relative[1]) * unitMs, nowMs);
|
|
139
|
+
return { message, ...(resetAt ? { resetAt } : {}) };
|
|
140
|
+
}
|
|
141
|
+
const reset = RESET_AT_RE.exec(window);
|
|
142
|
+
if (reset) {
|
|
143
|
+
const resetAt = clampReset(parseClockReset(reset, nowMs), nowMs);
|
|
144
|
+
return { message, ...(resetAt ? { resetAt } : {}) };
|
|
145
|
+
}
|
|
146
|
+
// Limit phrase but no reset/retry beside it — not confident it's the modal; skip.
|
|
109
147
|
}
|
|
110
|
-
// Limit phrase but no reset/retry anywhere — not confident it's the modal; skip to
|
|
111
|
-
// avoid false-positiving on normal output (docs, the agent's own text) that says "limit".
|
|
112
148
|
return null;
|
|
113
149
|
}
|
|
114
150
|
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
/**
|
|
152
|
+
* The live bottom block of a captured pane: the last run of consecutive non-blank rows
|
|
153
|
+
* (trailing blanks ignored). On a healthy Claude pane that is the composer + footer chrome;
|
|
154
|
+
* on a held one it is the modal that replaced them. Panes with no blank row at all (unit-test
|
|
155
|
+
* one-liners, a short modal-only capture) yield the whole text, which is what we want.
|
|
156
|
+
*/
|
|
157
|
+
function livePaneBlock(text: string): string[] {
|
|
158
|
+
const lines = text.split(/\r?\n/);
|
|
159
|
+
let end = lines.length;
|
|
160
|
+
while (end > 0 && lines[end - 1]!.trim() === "") end--;
|
|
161
|
+
let start = end;
|
|
162
|
+
while (start > 0 && lines[start - 1]!.trim() !== "") start--;
|
|
163
|
+
return lines.slice(start, end);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Unlike the usage-limit modal, Claude Code renders a transient API error as a CONVERSATION
|
|
167
|
+
// row (`● API Error: 529 Overloaded…`), i.e. in the body, above the composer — so the
|
|
168
|
+
// positional gate above cannot apply here without losing the detection entirely.
|
|
169
|
+
// #1611 — but the arm had the same own-output hole: the phrase was matched anywhere on any
|
|
170
|
+
// line, so an agent rendering this file's diff or a quoted error tripped it (reproduced live:
|
|
171
|
+
// the fragment `+ane("● API Error: 529 Ove` in a rendered patch marked the agent blocked).
|
|
172
|
+
// Claude's own error row STARTS with the error, after nothing but its bullet/box chrome, so
|
|
173
|
+
// anchor there: text that merely embeds the phrase mid-line no longer matches. A genuine
|
|
174
|
+
// error pane is unchanged.
|
|
175
|
+
const TRANSIENT_API_ERROR_RE = /^API Error:\s*((?:5\d\d)|529)\b[^\n]*(?:overload|server-side|temporar|try again|status\.claude\.com)?[^\n]*/i;
|
|
176
|
+
const TRANSIENT_5XX_RE = /^(?:5(?:00|02|03|04|20|29)|529)\b[^\n]*(?:overload|server-side|temporar|try again|status\.claude\.com)\b[^\n]*/i;
|
|
177
|
+
// Row chrome Claude prefixes its rendered lines with: whitespace, bullets/spinners, and the
|
|
178
|
+
// box-drawing block (U+2500–U+257F).
|
|
179
|
+
const PANE_ROW_PREFIX_RE = /^[\s─-╿●⏺✳✻✽⎿>❯»→✗✘ו*-]+/;
|
|
117
180
|
|
|
118
181
|
function parseClaudeTransientOverloadPane(text: string): PaneRateLimit | null {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
182
|
+
for (const line of text.split(/\r?\n/)) {
|
|
183
|
+
const row = line.replace(PANE_ROW_PREFIX_RE, "");
|
|
184
|
+
const match = TRANSIENT_API_ERROR_RE.exec(row) ?? TRANSIENT_5XX_RE.exec(row);
|
|
185
|
+
if (!match) continue;
|
|
186
|
+
const code = match[1] ?? /\b(5\d\d|529)\b/.exec(match[0])?.[1];
|
|
187
|
+
return {
|
|
188
|
+
message: cleanPaneLine(line) || match[0].trim(),
|
|
189
|
+
kind: "transient_overload",
|
|
190
|
+
errorType: code === "529" ? "overloaded" : "server_error",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
128
194
|
}
|
|
129
195
|
|
|
130
|
-
// The clean
|
|
196
|
+
// The clean line containing the match offset, stripped of box-drawing chrome.
|
|
131
197
|
function limitLineAt(text: string, index: number): string {
|
|
132
198
|
const start = text.lastIndexOf("\n", index) + 1;
|
|
133
199
|
const endNl = text.indexOf("\n", index);
|
|
134
200
|
const end = endNl === -1 ? text.length : endNl;
|
|
135
|
-
return text.slice(start, end)
|
|
201
|
+
return cleanPaneLine(text.slice(start, end));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function cleanPaneLine(line: string): string {
|
|
205
|
+
return line.replace(/[│┌┐└┘─┤├╭╮╯╰]/g, " ").replace(/\s+/g, " ").trim();
|
|
136
206
|
}
|
|
137
207
|
|
|
138
208
|
function clampReset(resetAt: number | undefined, nowMs: number): number | undefined {
|
|
@@ -35,6 +35,30 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
35
35
|
const events: KeyedRelayInjectionEvent[] = (input.carried ?? []).map((event, index) => ({ key: `spawn-${index}`, event }));
|
|
36
36
|
if (!isSpawnProvider(input.provider)) return events;
|
|
37
37
|
const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
|
|
38
|
+
// #1565 P5.7 — the native-memory lever gets its OWN `memory`-category event (the channel #1612
|
|
39
|
+
// established for the spawn-seam memory block), not a line in the tools summary: this decides
|
|
40
|
+
// whether the agent has one memory store or two, and a SUPPRESSED lever must be visible even on
|
|
41
|
+
// a launch that provisions nothing else and would otherwise return early below.
|
|
42
|
+
const lever = assembled.nativeMemory;
|
|
43
|
+
if (lever.policy !== "default") {
|
|
44
|
+
events.push({
|
|
45
|
+
key: "native-memory",
|
|
46
|
+
event: {
|
|
47
|
+
category: "memory",
|
|
48
|
+
summary: lever.suppressed
|
|
49
|
+
? `native memory ${lever.policy}: PARTIALLY applied — ${lever.suppressed.reason}`
|
|
50
|
+
: `native memory ${lever.policy} (${lever.applied.join(", ") || "no lever"})`,
|
|
51
|
+
detail: {
|
|
52
|
+
source: "native-memory-lever",
|
|
53
|
+
provider: assembled.provider,
|
|
54
|
+
base: assembled.base,
|
|
55
|
+
policy: lever.policy,
|
|
56
|
+
applied: lever.applied,
|
|
57
|
+
...(lever.suppressed ? { suppressed: lever.suppressed } : {}),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
38
62
|
const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
|
|
39
63
|
+ assembled.hooks.length
|
|
40
64
|
+ assembled.projectSkills.length
|
|
@@ -20,6 +20,15 @@ export function relayReplyReminderText(messageId: number): string {
|
|
|
20
20
|
return `If this batch needs a response, send one useful reply to the latest relevant message with ${relayReplyActionText(messageId)}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// #1570 SUB-A — the 4-line reply-reminder block repeats a fact the agent already knows after
|
|
24
|
+
// the first delivery: it can reply, and how. Show it on the first delivery (so the awareness
|
|
25
|
+
// lands at least once) and then periodically, rather than on every replyable message.
|
|
26
|
+
export const REMINDER_EVERY_DELIVERIES = 5;
|
|
27
|
+
|
|
28
|
+
export function shouldShowReplyReminder(deliveryCount: number): boolean {
|
|
29
|
+
return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
export function workspaceDepsNote(input: { mode?: string | null; depsMode?: string | null }): string {
|
|
24
33
|
if (input.mode !== "isolated") return "";
|
|
25
34
|
switch (input.depsMode) {
|
package/src/relay-mcp-proxy.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
2
2
|
import { logger } from "./logger";
|
|
3
|
+
import { RELAY_MCP_ONDEMAND_SURFACE_HEADER, RELAY_MCP_ONDEMAND_SURFACE_VALUE } from "./relay-mcp";
|
|
3
4
|
|
|
4
5
|
// Loose fetch signature so tests can inject a plain async stub without Bun's `preconnect`
|
|
5
6
|
// member; the real global `fetch` satisfies it.
|
|
@@ -30,6 +31,16 @@ export type FetchLike = (input: string | URL | Request, init?: RequestInit) => P
|
|
|
30
31
|
// NEVER enforcement. The proxy can only ever surface a subset of what the relay's scope filter
|
|
31
32
|
// already returned, so a proxy bug can never become an auth bypass — the relay owns the lock at
|
|
32
33
|
// `tools/call`, the proxy owns the menu.
|
|
34
|
+
//
|
|
35
|
+
// #1450 (the #1570 unify) adds a SECOND narrowing dimension for Codex lean workers: the on-demand
|
|
36
|
+
// relay set. The agent's DIRECT relay client has its on-demand tools hidden here so only the eager
|
|
37
|
+
// hot-path set boots; the SAME tools are simultaneously fronted by the per-agent metaOnly callmux
|
|
38
|
+
// (a second relay downstream over THIS proxy), reachable via callmux_call. callmux's requests carry
|
|
39
|
+
// RELAY_MCP_ONDEMAND_SURFACE_HEADER, which opts OUT of this hiding so callmux sees the full set the
|
|
40
|
+
// relay returned and can route to any of it. This is still strictly narrow-never-widen (the direct
|
|
41
|
+
// client loses exactly what callmux gains; the header only restores the relay's own set, never the
|
|
42
|
+
// call — tools/call stays scope-gated), and it is coupled at launch: a worker is only narrowed when
|
|
43
|
+
// callmux is simultaneously fronting the on-demand set, so no capability is ever lost.
|
|
33
44
|
|
|
34
45
|
const PROXY_PATH = "/mcp";
|
|
35
46
|
// Relay-side failures we treat as "relay down" (buffer/serve-cache), as opposed to a real 4xx
|
|
@@ -60,6 +71,35 @@ const WORKTREE_ONLY_TOOLS = new Set<string>([
|
|
|
60
71
|
interface ProxyContext {
|
|
61
72
|
// The agent owns a live (non-terminal) isolated git worktree → workspace tools apply.
|
|
62
73
|
isolatedWorktree: boolean;
|
|
74
|
+
// #1502 — this worktree agent's own workspace descriptor (from AGENT_RELAY_WORKSPACE_JSON). The
|
|
75
|
+
// relay writes the workspace ROW lazily via the orchestrator's managed-agents report, so a worker
|
|
76
|
+
// that delivers before that write (or whose registration was lost under concurrent-spawn load)
|
|
77
|
+
// would 404. We inject this descriptor into `relay_task_deliver` so the relay can self-heal the
|
|
78
|
+
// missing registration from the worker's own truth instead of stranding the delivery.
|
|
79
|
+
workspaceDescriptor?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// #1502 — tool calls the proxy augments with the agent's workspace descriptor for self-heal.
|
|
83
|
+
const WORKSPACE_SELF_HEAL_TOOLS = new Set(["relay_task_deliver"]);
|
|
84
|
+
|
|
85
|
+
// #1502 — the minimal descriptor the relay needs to (re)register a dropped workspace row, distilled
|
|
86
|
+
// from the agent's WorkspaceMetadata. Undefined unless this agent owns a live isolated worktree with
|
|
87
|
+
// the required id/repo/worktree present, so a shared/non-worktree agent never sends one.
|
|
88
|
+
export function isolatedWorkspaceDescriptor(
|
|
89
|
+
workspace: { id?: string; repoRoot?: string; worktreePath?: string; sourceCwd?: string; branch?: string; baseRef?: string; baseSha?: string } | undefined,
|
|
90
|
+
ownsIsolatedWorktree: boolean,
|
|
91
|
+
): Record<string, unknown> | undefined {
|
|
92
|
+
if (!ownsIsolatedWorktree || !workspace?.id || !workspace.repoRoot || !workspace.worktreePath) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
id: workspace.id,
|
|
95
|
+
repoRoot: workspace.repoRoot,
|
|
96
|
+
worktreePath: workspace.worktreePath,
|
|
97
|
+
mode: "isolated",
|
|
98
|
+
...(workspace.sourceCwd ? { sourceCwd: workspace.sourceCwd } : {}),
|
|
99
|
+
...(workspace.branch ? { branch: workspace.branch } : {}),
|
|
100
|
+
...(workspace.baseRef ? { baseRef: workspace.baseRef } : {}),
|
|
101
|
+
...(workspace.baseSha ? { baseSha: workspace.baseSha } : {}),
|
|
102
|
+
};
|
|
63
103
|
}
|
|
64
104
|
|
|
65
105
|
export interface BufferedToolCall {
|
|
@@ -80,6 +120,11 @@ export interface RelayMcpProxyOptions {
|
|
|
80
120
|
enqueueBuffered(call: BufferedToolCall): void;
|
|
81
121
|
initialContext?: ProxyContext;
|
|
82
122
|
bufferableTools?: Set<string>;
|
|
123
|
+
// #1450 — the Codex-worker on-demand relay set to hide from the agent's DIRECT relay client so
|
|
124
|
+
// only the eager hot-path set boots. Undefined for every agent that isn't a lean Codex worker with
|
|
125
|
+
// callmux simultaneously fronting these tools (Claude, coordinators, reviewers, proxy-off) → no
|
|
126
|
+
// hiding, full surface. callmux's own relay downstream opts out via RELAY_MCP_ONDEMAND_SURFACE_HEADER.
|
|
127
|
+
onDemandTools?: Set<string>;
|
|
83
128
|
// Test seam.
|
|
84
129
|
fetchImpl?: FetchLike;
|
|
85
130
|
}
|
|
@@ -102,6 +147,7 @@ export class RelayMcpProxy {
|
|
|
102
147
|
private readonly authSecret: string;
|
|
103
148
|
private readonly enqueueBuffered: (call: BufferedToolCall) => void;
|
|
104
149
|
private readonly bufferableTools: Set<string>;
|
|
150
|
+
private readonly onDemandTools?: Set<string>;
|
|
105
151
|
private readonly fetchImpl: FetchLike;
|
|
106
152
|
private readonly encoder = new TextEncoder();
|
|
107
153
|
|
|
@@ -119,6 +165,7 @@ export class RelayMcpProxy {
|
|
|
119
165
|
this.authSecret = options.authSecret;
|
|
120
166
|
this.enqueueBuffered = options.enqueueBuffered;
|
|
121
167
|
this.bufferableTools = options.bufferableTools ?? DEFAULT_BUFFERABLE_TOOLS;
|
|
168
|
+
this.onDemandTools = options.onDemandTools;
|
|
122
169
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
123
170
|
this.context = options.initialContext ?? { isolatedWorktree: false };
|
|
124
171
|
}
|
|
@@ -192,6 +239,10 @@ export class RelayMcpProxy {
|
|
|
192
239
|
}
|
|
193
240
|
|
|
194
241
|
private async handleRpc(req: Request): Promise<Response> {
|
|
242
|
+
// #1450 — read the on-demand-front header BEFORE consuming the body. The per-agent metaOnly
|
|
243
|
+
// callmux's relay downstream sets it to opt OUT of the on-demand hiding (it fronts those tools);
|
|
244
|
+
// the agent's direct relay client never sets it, so it gets the eager hot-path set only.
|
|
245
|
+
const frontsOnDemand = req.headers.get(RELAY_MCP_ONDEMAND_SURFACE_HEADER) === RELAY_MCP_ONDEMAND_SURFACE_VALUE;
|
|
195
246
|
let message: JsonRpcMessage;
|
|
196
247
|
try {
|
|
197
248
|
const body = await req.json();
|
|
@@ -206,7 +257,7 @@ export class RelayMcpProxy {
|
|
|
206
257
|
const method = message.method;
|
|
207
258
|
|
|
208
259
|
if (method === "initialize") return this.handleInitialize(id, message);
|
|
209
|
-
if (method === "tools/list") return this.handleToolsList(id, message);
|
|
260
|
+
if (method === "tools/list") return this.handleToolsList(id, message, frontsOnDemand);
|
|
210
261
|
if (method === "tools/call") return this.handleToolsCall(id, message);
|
|
211
262
|
// Everything else (notifications/initialized, ping, …) forwards verbatim.
|
|
212
263
|
return this.forwardRaw(message);
|
|
@@ -230,7 +281,7 @@ export class RelayMcpProxy {
|
|
|
230
281
|
return Response.json(jsonRpcResult(id, result));
|
|
231
282
|
}
|
|
232
283
|
|
|
233
|
-
private async handleToolsList(id: string | number | null, message: JsonRpcMessage): Promise<Response> {
|
|
284
|
+
private async handleToolsList(id: string | number | null, message: JsonRpcMessage, frontsOnDemand = false): Promise<Response> {
|
|
234
285
|
const relay = await this.forward({ ...message, method: "tools/list", id }).catch(() => null);
|
|
235
286
|
const tools = relay && isRecord(relay.body?.result) && Array.isArray((relay.body!.result as Record<string, unknown>).tools)
|
|
236
287
|
? ((relay.body!.result as Record<string, unknown>).tools as Array<Record<string, unknown>>)
|
|
@@ -239,17 +290,24 @@ export class RelayMcpProxy {
|
|
|
239
290
|
// If the relay is down and we have no cache yet, surface an empty list rather than erroring —
|
|
240
291
|
// the agent can still operate (writes buffer; tools/list refreshes on the next call).
|
|
241
292
|
const base = tools ?? this.lastRelayTools;
|
|
242
|
-
|
|
243
|
-
|
|
293
|
+
// #1450 — callmux's relay downstream (frontsOnDemand) sees the full set so it can route
|
|
294
|
+
// callmux_call to any of it; the direct client has the on-demand set hidden.
|
|
295
|
+
const narrowed = this.narrow(base, frontsOnDemand);
|
|
296
|
+
if (!frontsOnDemand) this.lastNarrowedNames = toolNames(narrowed);
|
|
244
297
|
return Response.json(jsonRpcResult(id, { tools: narrowed }));
|
|
245
298
|
}
|
|
246
299
|
|
|
247
|
-
// Strict subset of the relay's already-scope-filtered list. Only removes — never adds.
|
|
248
|
-
//
|
|
249
|
-
|
|
300
|
+
// Strict subset of the relay's already-scope-filtered list. Only removes — never adds. Narrowing
|
|
301
|
+
// rules: (1) workspace tools apply only to an agent that owns a live worktree; (2) #1450 — the
|
|
302
|
+
// on-demand relay set is hidden from the agent's DIRECT client (only when `hideOnDemand`), since
|
|
303
|
+
// the per-agent metaOnly callmux fronts those tools on demand. `frontsOnDemand` (callmux's own
|
|
304
|
+
// request) skips rule 2 so callmux sees the full relay set.
|
|
305
|
+
private narrow(tools: Array<Record<string, unknown>>, frontsOnDemand = false): Array<Record<string, unknown>> {
|
|
306
|
+
const hideOnDemand = !frontsOnDemand && this.onDemandTools !== undefined && this.onDemandTools.size > 0;
|
|
250
307
|
return tools.filter((tool) => {
|
|
251
308
|
const name = typeof tool.name === "string" ? tool.name : "";
|
|
252
309
|
if (WORKTREE_ONLY_TOOLS.has(name) && !this.context.isolatedWorktree) return false;
|
|
310
|
+
if (hideOnDemand && this.onDemandTools!.has(name)) return false;
|
|
253
311
|
return true;
|
|
254
312
|
});
|
|
255
313
|
}
|
|
@@ -257,7 +315,20 @@ export class RelayMcpProxy {
|
|
|
257
315
|
private async handleToolsCall(id: string | number | null, message: JsonRpcMessage): Promise<Response> {
|
|
258
316
|
const params = isRecord(message.params) ? message.params : {};
|
|
259
317
|
const toolName = typeof params.name === "string" ? params.name : "";
|
|
260
|
-
|
|
318
|
+
let args = isRecord(params.arguments) ? params.arguments : {};
|
|
319
|
+
|
|
320
|
+
// #1502 — inject this agent's workspace descriptor so the relay can self-heal a missing/dropped
|
|
321
|
+
// workspace registration during delivery. Only for worktree agents, only when the agent didn't
|
|
322
|
+
// already supply one, and the augmented args are forwarded (not just used locally).
|
|
323
|
+
if (
|
|
324
|
+
WORKSPACE_SELF_HEAL_TOOLS.has(toolName) &&
|
|
325
|
+
this.context.isolatedWorktree &&
|
|
326
|
+
this.context.workspaceDescriptor &&
|
|
327
|
+
args.workspace === undefined
|
|
328
|
+
) {
|
|
329
|
+
args = { ...args, workspace: this.context.workspaceDescriptor };
|
|
330
|
+
message = { ...message, params: { ...params, arguments: args } };
|
|
331
|
+
}
|
|
261
332
|
|
|
262
333
|
const relay = await this.forward(message).catch((error) => {
|
|
263
334
|
// A thrown fetch = transport failure (relay unreachable / DNS / connection refused).
|