@@ -60,6 +60,16 @@ interface WriteExtensionDetails {
6060 hostLoadErrors : Array < { path : string ; error : string } > ;
6161}
6262
63+ /**
64+ * Result of a {@link HarnessExtensionHost.reload} call, so callers like the
65+ * `/reload` command can report honestly instead of assuming success:
66+ * - `reloaded` — extensions were re-discovered and re-applied.
67+ * - `coalesced` — a reload was already in flight; this request was latched onto
68+ * it rather than run concurrently, so nothing new was applied yet.
69+ * - `disposed` — the host was (or became) torn down, so no reload happened.
70+ */
71+ export type ReloadOutcome = "reloaded" | "coalesced" | "disposed" ;
72+
6373const WRITE_EXTENSION_TOOL_NAME = "write_extension" ;
6474
6575const WRITE_EXTENSION_DESCRIPTION = [
@@ -146,13 +156,21 @@ export class HarnessExtensionHost {
146156 /** Set by `write_extension`; drained into a single reload at the next idle boundary. */
147157 private reloadRequested = false ;
148158 /** The in-flight queued reload, so a drain can await one already running. */
149- private pendingReload : Promise < void > | undefined ;
159+ private pendingReload : Promise < ReloadOutcome > | undefined ;
150160 /** Sticky shutdown request raised by `ctx.shutdown()` or owner disposal. */
151161 private shutdownRequested = false ;
152162 /** Guards `dispose` so `ctx.shutdown()` and an owner call don't double-tear-down. */
153163 private disposed = false ;
164+ /** Guards the actual teardown (`disposeNow`); `disposed` is set before the await. */
165+ private teardownDone = false ;
154166 /** True once `load()` has run; guards against double-load and load-after-dispose. */
155167 private loaded = false ;
168+ /**
169+ * False until `load()` finishes emitting the startup `session_start`. While
170+ * false, an extension-initiated `sendUserMessage` does not consume the
171+ * first-turn screenshot, so it can't pre-empt the user's real first prompt.
172+ */
173+ private startedUp = false ;
156174 private sessionName : string | undefined ;
157175
158176 /** Load errors surfaced from the last discover; non-fatal. */
@@ -182,7 +200,9 @@ export class HarnessExtensionHost {
182200 getSignal : ( ) => undefined ,
183201 shutdown : ( ) => this . requestShutdown ( ) ,
184202 } ) ;
185- this . commandActions = makeExtensionCommandContextActions ( this . harness , ( ) => this . reload ( ) ) ;
203+ this . commandActions = makeExtensionCommandContextActions ( this . harness , async ( ) => {
204+ await this . reload ( ) ;
205+ } ) ;
186206 this . hostTools = options . selfExtend ? [ this . makeWriteExtensionTool ( ) ] : [ ] ;
187207 }
188208
@@ -203,6 +223,10 @@ export class HarnessExtensionHost {
203223 await this . reapplyTools ( ) ;
204224 this . installBridge ( ) ;
205225 await this . runner ?. emit ( { type : "session_start" , reason : "startup" } ) ;
226+ // Startup is over: from here an extension sendUserMessage may carry the
227+ // first-turn screenshot (it can no longer steal it from the user's first
228+ // prompt, which the CLI captured before extensions loaded).
229+ this . startedUp = true ;
206230 // An extension that calls ctx.shutdown() during session_start disposes via
207231 // requestShutdown; honor it so load doesn't resolve a torn-down host as ready.
208232 if ( this . shutdownRequested ) await this . dispose ( ) ;
@@ -215,14 +239,16 @@ export class HarnessExtensionHost {
215239 * the bridge, then emit `session_start`. No extension cache is cleared because
216240 * the loader imports each extension fresh from disk.
217241 */
218- async reload ( ) : Promise < void > {
219- if ( this . disposed ) return ;
242+ async reload ( ) : Promise < ReloadOutcome > {
243+ if ( this . disposed ) return "disposed" ;
220244 // Reentrancy guard: a reload triggered (e.g. via ctx.reload()) while one is
221245 // in flight must not run concurrently and double-tear-down the bridge. Re-arm
222- // the latch so the in-flight reload's drain picks up the newer request.
246+ // the latch so the in-flight reload's loop picks up the newer request, and
247+ // report `coalesced` so a caller (e.g. the /reload command) doesn't claim a
248+ // completed reload it didn't perform.
223249 if ( this . reloading ) {
224250 this . reloadRequested = true ;
225- return ;
251+ return "coalesced" ;
226252 }
227253 this . reloading = true ;
228254 try {
@@ -238,19 +264,19 @@ export class HarnessExtensionHost {
238264 // scheduled off-stack at agent_end), so this resolves promptly and
239265 // cannot deadlock on an awaited-in-loop reload.
240266 await this . harness . waitForIdle ( ) ;
241- if ( this . disposed ) return ;
267+ if ( this . disposed ) return "disposed" ;
242268 const flags = this . runner ?. getFlagValues ( ) ?? new Map < string , boolean | string > ( ) ;
243269 await this . runner ?. emit ( { type : "session_shutdown" , reason : "reload" } ) ;
244- if ( await this . disposeIfShutdownRequested ( ) ) return ;
270+ if ( await this . disposeIfShutdownRequested ( ) ) return "disposed" ;
245271 this . teardownBridge ?.( ) ;
246272 this . teardownBridge = undefined ;
247273 try {
248274 await this . buildRunner ( ) ;
249- if ( await this . disposeIfShutdownRequested ( ) ) return ;
275+ if ( await this . disposeIfShutdownRequested ( ) ) return "disposed" ;
250276 for ( const [ name , value ] of flags ) this . runner ?. setFlagValue ( name , value ) ;
251277
252278 await this . reapplyTools ( ) ;
253- if ( await this . disposeIfShutdownRequested ( ) ) return ;
279+ if ( await this . disposeIfShutdownRequested ( ) ) return "disposed" ;
254280 this . installBridge ( ) ;
255281 await this . runner ?. emit ( { type : "session_start" , reason : "reload" } ) ;
256282 } catch ( error ) {
@@ -265,7 +291,13 @@ export class HarnessExtensionHost {
265291 this . reloading = false ;
266292 }
267293 // Honor a shutdown requested during the final emit, after `reloading` cleared.
268- if ( this . shutdownRequested ) await this . dispose ( ) ;
294+ // Still inside reload() (pendingReload may point at us), so tear down via
295+ // disposeNow rather than dispose to avoid awaiting our own reload.
296+ if ( this . shutdownRequested ) {
297+ await this . disposeNow ( ) ;
298+ return "disposed" ;
299+ }
300+ return "reloaded" ;
269301 }
270302
271303 /**
@@ -280,6 +312,29 @@ export class HarnessExtensionHost {
280312 if ( this . disposed ) return ;
281313 this . shutdownRequested = true ;
282314 this . disposed = true ;
315+ // A write_extension reload scheduled off-stack at agent_end may still be in
316+ // flight (print/interactive/action cleanup runs in `finally`). Setting
317+ // `disposed` stops new reloads and makes the in-flight one bail at its next
318+ // await boundary; await it so teardown — and the caller closing the browser —
319+ // doesn't race a live reload. Shutdowns raised from inside reload() use
320+ // `disposeNow` directly, since awaiting the running reload from within its own
321+ // call stack would deadlock.
322+ const inFlight = this . pendingReload ;
323+ if ( inFlight ) await inFlight . catch ( ( ) => { } ) ;
324+ await this . disposeNow ( ) ;
325+ }
326+
327+ /**
328+ * The actual teardown, split from `dispose` so reload()'s own shutdown paths
329+ * can run it without awaiting the in-flight reload (which is their call stack).
330+ * Idempotent via `teardownDone` — `disposed` is set before `dispose` awaits, so
331+ * it can't double as the teardown guard.
332+ */
333+ private async disposeNow ( ) : Promise < void > {
334+ if ( this . teardownDone ) return ;
335+ this . teardownDone = true ;
336+ this . shutdownRequested = true ;
337+ this . disposed = true ;
283338 this . teardownBridge ?.( ) ;
284339 this . teardownBridge = undefined ;
285340 await this . runner ?. emit ( { type : "session_shutdown" , reason : "quit" } ) ;
@@ -423,12 +478,14 @@ export class HarnessExtensionHost {
423478 * handler — reloading there would swap the runner out from under the in-flight
424479 * loop and the listener dispatching the event. The `reloading` guard keeps an
425480 * in-flight reload from being re-entered; a write during a reload re-arms the
426- * latch for the next boundary. `disposed` makes this a no-op during teardown.
481+ * latch, which reload()'s own loop drains before it resolves. `disposed` makes
482+ * this a no-op during teardown.
427483 */
428484 async drainPendingReload ( ) : Promise < void > {
429485 // Await a reload already running (the bridge fires this fire-and-forget, so
430486 // a caller that awaits the drain — e.g. a test asserting the new tool is
431- // live — must observe that reload settle).
487+ // live — must observe that reload settle). reload() drains any request
488+ // latched mid-reload via its own loop, so a single pass suffices here.
432489 if ( this . pendingReload ) {
433490 await this . pendingReload ;
434491 return ;
@@ -564,7 +621,9 @@ export class HarnessExtensionHost {
564621 /** Honor a shutdown latched during reload. Returns true if the host disposed. */
565622 private async disposeIfShutdownRequested ( ) : Promise < boolean > {
566623 if ( ! this . shutdownRequested && ! this . disposed ) return false ;
567- await this . dispose ( ) ;
624+ // disposeNow, not dispose: this runs inside reload(), whose promise is the
625+ // `pendingReload` dispose() would await — awaiting it here would deadlock.
626+ await this . disposeNow ( ) ;
568627 return true ;
569628 }
570629
@@ -590,6 +649,9 @@ export class HarnessExtensionHost {
590649
591650 private async maybeInitialScreenshot ( ) : Promise < ImageContent [ ] | undefined > {
592651 if ( ! this . initialScreenshot ) return undefined ;
652+ // During startup the user's first prompt owns the first-turn screenshot; an
653+ // extension message here must not consume it (see `startedUp`).
654+ if ( ! this . startedUp ) return undefined ;
593655 if ( await sessionHasPriorTurn ( this . session ) ) return undefined ;
594656 return this . initialScreenshot ( ) ;
595657 }
0 commit comments