Summary
The agent's main reaction loop captures the session's hooks once at loop start and then uses that snapshot for every subsequent iteration. After extensions_reload is called from inside the loop, the snapshot still references the now-dead old extension's IPC proxy. The proxy's calls fail with EPIPE / "connection reset" / "stream was destroyed", which are caught and silently swallowed by the proxy's own error handler.
The result: preToolUse, userPromptSubmitted, and sessionStart invocations registered via that proxy are silently dropped for the rest of the loop. Meanwhile postToolUse (and other paths that call getEffectiveHooks() live each time) continue to fire on the freshly-launched extension instance.
For an extension that observes both pre and post hooks, every tool call that happens after a mid-loop extensions_reload becomes a postToolUse-only event with no matching preToolUse. The behavior persists until the user submits the next prompt and a fresh runAgenticLoop retakes the snapshot.
Repro
Minimal extension that registers preToolUse and postToolUse and logs both:
import { joinSession } from "@github/copilot-sdk/extension";
const session = await joinSession({
hooks: {
onPreToolUse: async (input) => { console.error("PRE", input.toolName); },
onPostToolUse: async (input) => { console.error("POST", input.toolName); },
},
});
Then in a Copilot CLI session with the extension installed:
- Send a prompt that triggers a tool call. Both
PRE and POST fire.
- Send a prompt that asks the agent to call
extensions_reload and then run another tool in the same response (e.g. "reload extensions, then list the files in the current directory").
- Observe extension logs (or the CLI's own debug logs).
Expected: PRE and POST both fire for the post-reload tool call.
Actual: Only POST fires for the post-reload tool call. PRE is silently dropped. Every subsequent tool call in the same agentic loop has the same behavior. The next user-submitted prompt restarts the loop and the behavior self-heals.
Root cause (reading shipped CLI 1.0.43-0)
Path: <copilot-pkg>/app.js.
Inside runAgenticLoop (one call per user prompt; iterates internally through many LLM-tool cycles):
async runAgenticLoop(...) {
...
let _ = this.getEffectiveHooks(); // ← SNAPSHOT taken once
try {
let T = await h1(_?.userPromptSubmitted, ...); // uses snapshot
...
new XIr(_, this.workingDir, this.sessionId, ...) // PreToolUseHooksProcessor uses snapshot
...
}
}
Vs. processToolExecutionResult (one call per tool result, anywhere in the same loop):
async processToolExecutionResult(toolName, toolArgs, toolResult) {
let s = (toolResult.resultType === "success"
? await h1(this.getEffectiveHooks()?.postToolUse, ...) // ← LIVE per call
: void 0);
...
}
Hook proxies for connected extensions are built via createHooksProxy(sessionId, connection). Each proxy closes over the IPC connection to that specific extension instance:
createHooksProxy(sessionId, connection) {
let n = async (hookType, input) => {
...
try {
return (await connection.sendRequest(c0.HOOKS_INVOKE, { sessionId, hookType, input })).output;
} catch (a) {
let l = q(a), c = l.toLowerCase();
// EPIPE / broken pipe / EOF / closed / connection reset / write after end /
// stream was destroyed / shutting down → just log; otherwise warn.
...
return; // ← swallowed
}
};
return {
preToolUse: [o => n("preToolUse", o)],
postToolUse: [o => n("postToolUse", o)],
...
};
}
When extensions_reload runs stopAllExtensions(), the old child process is killed (SIGTERM then SIGKILL after 5 s) and its connection is disposed. The new extension is launched and registers fresh hooks via addAdHocHooks(connectionId, createHooksProxy(sessionId, newConnection)).
But the snapshot _ captured at the top of runAgenticLoop still holds the old proxy in its hook arrays. preToolUse calls on the snapshot route through the dead proxy → sendRequest rejects → catch swallows → returns undefined → no extension sees the hook.
Suggested fix (one of)
-
Move the snapshot to live look-up. Replace _=this.getEffectiveHooks() with live calls at each hook invocation site (mirroring processToolExecutionResult). This is the smallest semantic change.
-
Refresh the snapshot reactively. When an extension connection drops or a new one is registered (addAdHocHooks / removeAdHocHooks), invalidate any in-flight runAgenticLoop snapshot.
-
Refresh the snapshot after extensions_reload specifically. Smaller scope, but doesn't cover other ways an extension might disconnect mid-loop (crashes, network issues for remote sessions, etc.).
Of these, (1) is the most consistent with how postToolUse already works.
Impact
- Extensions that observe
preToolUse for tool tracking, telemetry, audit logs, fault injection, or UI live-update have visible gaps after any mid-loop extensions_reload.
userPromptSubmitted is also affected (same snapshot), so any extension that relies on it for memory / state could miss prompts after a mid-loop reload (though the reload itself is rarely between submission and tool execution, so this is less observable).
sessionStart is fired through the same captured snapshot earlier in the loop, but is gated by !this._sessionStartHooksFired, so its impact is bounded to the very first iteration of the very first loop.
- Symptoms are silent — no warning is surfaced to the user, the agent, or the extension. Extensions just stop receiving certain hooks.
Environment
@github/copilot 1.0.43-0 (Windows win32-x64 package).
- Reproduced on Windows 11.
- Source confirmed by reading the bundled
app.js shipped in the CLI package.
Summary
The agent's main reaction loop captures the session's hooks once at loop start and then uses that snapshot for every subsequent iteration. After
extensions_reloadis called from inside the loop, the snapshot still references the now-dead old extension's IPC proxy. The proxy's calls fail withEPIPE/"connection reset"/"stream was destroyed", which are caught and silently swallowed by the proxy's own error handler.The result:
preToolUse,userPromptSubmitted, andsessionStartinvocations registered via that proxy are silently dropped for the rest of the loop. MeanwhilepostToolUse(and other paths that callgetEffectiveHooks()live each time) continue to fire on the freshly-launched extension instance.For an extension that observes both pre and post hooks, every tool call that happens after a mid-loop
extensions_reloadbecomes apostToolUse-only event with no matchingpreToolUse. The behavior persists until the user submits the next prompt and a freshrunAgenticLoopretakes the snapshot.Repro
Minimal extension that registers
preToolUseandpostToolUseand logs both:Then in a Copilot CLI session with the extension installed:
PREandPOSTfire.extensions_reloadand then run another tool in the same response (e.g. "reload extensions, then list the files in the current directory").Expected:
PREandPOSTboth fire for the post-reload tool call.Actual: Only
POSTfires for the post-reload tool call.PREis silently dropped. Every subsequent tool call in the same agentic loop has the same behavior. The next user-submitted prompt restarts the loop and the behavior self-heals.Root cause (reading shipped CLI
1.0.43-0)Path:
<copilot-pkg>/app.js.Inside
runAgenticLoop(one call per user prompt; iterates internally through many LLM-tool cycles):Vs.
processToolExecutionResult(one call per tool result, anywhere in the same loop):Hook proxies for connected extensions are built via
createHooksProxy(sessionId, connection). Each proxy closes over the IPCconnectionto that specific extension instance:When
extensions_reloadrunsstopAllExtensions(), the old child process is killed (SIGTERMthenSIGKILLafter 5 s) and its connection is disposed. The new extension is launched and registers fresh hooks viaaddAdHocHooks(connectionId, createHooksProxy(sessionId, newConnection)).But the snapshot
_captured at the top ofrunAgenticLoopstill holds the old proxy in its hook arrays.preToolUsecalls on the snapshot route through the dead proxy →sendRequestrejects → catch swallows → returnsundefined→ no extension sees the hook.Suggested fix (one of)
Move the snapshot to live look-up. Replace
_=this.getEffectiveHooks()with live calls at each hook invocation site (mirroringprocessToolExecutionResult). This is the smallest semantic change.Refresh the snapshot reactively. When an extension connection drops or a new one is registered (
addAdHocHooks/removeAdHocHooks), invalidate any in-flightrunAgenticLoopsnapshot.Refresh the snapshot after
extensions_reloadspecifically. Smaller scope, but doesn't cover other ways an extension might disconnect mid-loop (crashes, network issues for remote sessions, etc.).Of these, (1) is the most consistent with how
postToolUsealready works.Impact
preToolUsefor tool tracking, telemetry, audit logs, fault injection, or UI live-update have visible gaps after any mid-loopextensions_reload.userPromptSubmittedis also affected (same snapshot), so any extension that relies on it for memory / state could miss prompts after a mid-loop reload (though the reload itself is rarely between submission and tool execution, so this is less observable).sessionStartis fired through the same captured snapshot earlier in the loop, but is gated by!this._sessionStartHooksFired, so its impact is bounded to the very first iteration of the very first loop.Environment
@github/copilot1.0.43-0(Windowswin32-x64package).app.jsshipped in the CLI package.