agent-relay-runner 0.129.11 → 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/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). We REQUIRE a
68
- // limit phrase AND a reset/retry phrase on the same line so normal output mentioning
69
- // "rate limit" can't false-positive the hold.
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
- * Requires BOTH a limit phrase and a reset/retry phrase (anywhere in the pane, so it's robust
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
- const limit = LIMIT_PHRASE_RE.exec(text);
96
- if (!limit) return null;
97
- const message = limitLineAt(text, limit.index);
98
-
99
- const relative = TRY_AGAIN_RE.exec(text);
100
- if (relative) {
101
- const unitMs = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 }[relative[2]!.toLowerCase()] ?? 0;
102
- const resetAt = clampReset(nowMs + Number(relative[1]) * unitMs, nowMs);
103
- return { message, ...(resetAt ? { resetAt } : {}) };
104
- }
105
- const reset = RESET_AT_RE.exec(text);
106
- if (reset) {
107
- const resetAt = clampReset(parseClockReset(reset, nowMs), nowMs);
108
- return { message, ...(resetAt ? { resetAt } : {}) };
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
- const TRANSIENT_API_ERROR_RE = /\bAPI Error:\s*((?:5\d\d)|529)\b[^\n]*(?:overload|server-side|temporar|try again|status\.claude\.com)?[^\n]*/i;
116
- const TRANSIENT_5XX_RE = /\b(?:5(?:00|02|03|04|20|29)|529)\b[^\n]*(?:overload|server-side|temporar|try again|status\.claude\.com)\b[^\n]*/i;
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 match = TRANSIENT_API_ERROR_RE.exec(text) ?? TRANSIENT_5XX_RE.exec(text);
120
- if (!match) return null;
121
- const message = limitLineAt(text, match.index);
122
- const code = match[1] ?? /\b(5\d\d|529)\b/.exec(match[0])?.[1];
123
- return {
124
- message: message || match[0].trim(),
125
- kind: "transient_overload",
126
- errorType: code === "529" ? "overloaded" : "server_error",
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 limit line containing the match offset, stripped of box-drawing chrome.
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).replace(/[│┌┐└┘─┤├╭╮╯╰]/g, " ").replace(/\s+/g, " ").trim();
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) {
@@ -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
- const narrowed = this.narrow(base);
243
- this.lastNarrowedNames = toolNames(narrowed);
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. The one
248
- // narrowing rule today: workspace tools apply only to an agent that owns a live worktree.
249
- private narrow(tools: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
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
- const args = isRecord(params.arguments) ? params.arguments : {};
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).