gsd-pi 2.78.1-dev.8a893322c → 2.78.1-dev.a7b6e59b7

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.
Files changed (79) hide show
  1. package/dist/cli-auto-routing.d.ts +1 -0
  2. package/dist/cli-auto-routing.js +5 -0
  3. package/dist/cli.js +5 -14
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/gsd/auto/run-unit.js +23 -11
  6. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +55 -21
  7. package/dist/resources/extensions/gsd/auto-prompts.js +6 -0
  8. package/dist/resources/extensions/gsd/auto-worktree.js +15 -0
  9. package/dist/resources/extensions/gsd/auto.js +25 -9
  10. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  11. package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
  12. package/dist/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
  13. package/dist/resources/extensions/gsd/worktree-resolver.js +24 -0
  14. package/dist/resources/skills/lint/SKILL.md +4 -0
  15. package/dist/resources/skills/review/SKILL.md +4 -0
  16. package/dist/resources/skills/test/SKILL.md +3 -0
  17. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  18. package/dist/web/standalone/.next/BUILD_ID +1 -1
  19. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  20. package/dist/web/standalone/.next/build-manifest.json +2 -2
  21. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  22. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.html +1 -1
  39. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  46. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  47. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  48. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  49. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  50. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  51. package/package.json +1 -1
  52. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +278 -0
  53. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +7 -0
  55. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/core/agent-session.js +125 -55
  57. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  58. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +319 -0
  59. package/packages/pi-coding-agent/src/core/agent-session.ts +128 -59
  60. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  61. package/src/resources/extensions/gsd/auto/run-unit.ts +23 -11
  62. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +60 -24
  63. package/src/resources/extensions/gsd/auto-prompts.ts +6 -0
  64. package/src/resources/extensions/gsd/auto-worktree.ts +15 -0
  65. package/src/resources/extensions/gsd/auto.ts +23 -6
  66. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  67. package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
  68. package/src/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
  69. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +1 -0
  70. package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +8 -2
  71. package/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +12 -6
  72. package/src/resources/extensions/gsd/tests/worktree-path-injection.test.ts +235 -0
  73. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +85 -0
  74. package/src/resources/extensions/gsd/worktree-resolver.ts +24 -0
  75. package/src/resources/skills/lint/SKILL.md +4 -0
  76. package/src/resources/skills/review/SKILL.md +4 -0
  77. package/src/resources/skills/test/SKILL.md +3 -0
  78. /package/dist/web/standalone/.next/static/{QK8fABiGPmonfTgboN0Y9 → GlYncvckBGG33CSoJaSnB}/_buildManifest.js +0 -0
  79. /package/dist/web/standalone/.next/static/{QK8fABiGPmonfTgboN0Y9 → GlYncvckBGG33CSoJaSnB}/_ssgManifest.js +0 -0
@@ -77,6 +77,38 @@ function recordCallOrder<O extends object>(
77
77
  return order;
78
78
  }
79
79
 
80
+ function makeAssistantMessage(text: string) {
81
+ return {
82
+ role: "assistant",
83
+ content: [{ type: "text", text }],
84
+ usage: {
85
+ input: 1,
86
+ output: 1,
87
+ cacheRead: 0,
88
+ cacheWrite: 0,
89
+ total: 2,
90
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
91
+ },
92
+ stopReason: "stop",
93
+ timestamp: Date.now(),
94
+ } as any;
95
+ }
96
+
97
+ function installAgentEndSessionTransition(
98
+ session: AgentSession,
99
+ transition: () => Promise<unknown>,
100
+ ): void {
101
+ (session as any)._extensionRunner = {
102
+ hasHandlers: () => false,
103
+ emit: async (event: any) => {
104
+ if (event.type === "agent_end") {
105
+ await transition();
106
+ }
107
+ },
108
+ emitStop: async () => {},
109
+ };
110
+ }
111
+
80
112
  describe("#4243 — abort() must run before _disconnectFromAgent()", () => {
81
113
  beforeEach(() => {
82
114
  testDir = mkdtempSync(join(tmpdir(), "agent-session-abort-"));
@@ -106,6 +138,293 @@ describe("#4243 — abort() must run before _disconnectFromAgent()", () => {
106
138
  );
107
139
  });
108
140
 
141
+ it("newSession() waits instead of aborting while agent_end processing is still streaming", async () => {
142
+ const session = await createSession();
143
+ const order: string[] = [];
144
+ let releaseIdle!: () => void;
145
+ const idle = new Promise<void>((resolve) => {
146
+ releaseIdle = resolve;
147
+ });
148
+
149
+ (session as any)._processingAgentEnd = true;
150
+ (session as any).agent.state.isStreaming = true;
151
+ (session as any).agent.waitForIdle = () => {
152
+ order.push("waitForIdle");
153
+ return idle;
154
+ };
155
+ (session as any).abort = async () => {
156
+ order.push("abort");
157
+ };
158
+ const originalDisconnect = (session as any)._disconnectFromAgent.bind(session);
159
+ (session as any)._disconnectFromAgent = () => {
160
+ order.push("_disconnectFromAgent");
161
+ originalDisconnect();
162
+ };
163
+
164
+ const pendingNewSession = session.newSession();
165
+ await Promise.resolve();
166
+ assert.deepEqual(order, ["waitForIdle"]);
167
+ assert.equal(order.includes("abort"), false);
168
+
169
+ releaseIdle();
170
+ const ok = await pendingNewSession;
171
+ assert.equal(ok, true);
172
+ assert.deepEqual(order, ["waitForIdle", "_disconnectFromAgent"]);
173
+ assert.equal(order.includes("abort"), false);
174
+ });
175
+
176
+ it("newSession() waits during agent_end processing even once already idle", async () => {
177
+ const session = await createSession();
178
+ const order: string[] = [];
179
+
180
+ (session as any)._processingAgentEnd = true;
181
+ (session as any).agent.state.isStreaming = false;
182
+ (session as any).agent.waitForIdle = async () => {
183
+ order.push("waitForIdle");
184
+ };
185
+ (session as any).abort = async () => {
186
+ order.push("abort");
187
+ };
188
+ const originalDisconnect = (session as any)._disconnectFromAgent.bind(session);
189
+ (session as any)._disconnectFromAgent = () => {
190
+ order.push("_disconnectFromAgent");
191
+ originalDisconnect();
192
+ };
193
+
194
+ const ok = await session.newSession();
195
+ assert.equal(ok, true);
196
+ assert.deepEqual(order, ["waitForIdle", "_disconnectFromAgent"]);
197
+ assert.equal(order.includes("abort"), false);
198
+ });
199
+
200
+ it("abort() marks synthetic agent_end processing while extension handlers run", async () => {
201
+ const session = await createSession();
202
+ const observedProcessingStates: boolean[] = [];
203
+
204
+ (session as any).agent.abort = () => {};
205
+ (session as any).agent.waitForIdle = async () => {};
206
+ (session as any)._extensionRunner = {
207
+ emit: async (event: any) => {
208
+ if (event.type === "agent_end") {
209
+ observedProcessingStates.push((session as any)._processingAgentEnd);
210
+ }
211
+ },
212
+ emitStop: async () => {
213
+ observedProcessingStates.push((session as any)._processingAgentEnd);
214
+ },
215
+ };
216
+
217
+ await session.abort();
218
+
219
+ assert.deepEqual(observedProcessingStates, [true, true]);
220
+ assert.equal((session as any)._processingAgentEnd, false);
221
+ });
222
+
223
+ it("newSession() during agent_end preserves the previous session for resume", async () => {
224
+ const session = await createSession({ persistSessions: true });
225
+ const previousSessionFile = session.sessionFile;
226
+ assert.ok(previousSessionFile, "need a persisted session file");
227
+
228
+ session.sessionManager.appendMessage({
229
+ role: "user",
230
+ content: [{ type: "text", text: "persisted prompt" }],
231
+ } as any);
232
+ session.sessionManager.appendMessage({
233
+ role: "assistant",
234
+ content: [{ type: "text", text: "persisted response" }],
235
+ usage: {
236
+ input: 1,
237
+ output: 1,
238
+ cacheRead: 0,
239
+ cacheWrite: 0,
240
+ total: 2,
241
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
242
+ },
243
+ stopReason: "stop",
244
+ timestamp: Date.now(),
245
+ } as any);
246
+ session.agent.replaceMessages(session.sessionManager.buildSessionContext().messages);
247
+
248
+ (session as any)._processingAgentEnd = true;
249
+ (session as any).agent.waitForIdle = async () => {};
250
+
251
+ const ok = await session.newSession();
252
+ assert.equal(ok, true);
253
+ assert.notEqual(session.sessionFile, previousSessionFile);
254
+ assert.deepEqual(session.messages, []);
255
+
256
+ (session as any)._processingAgentEnd = false;
257
+ const switched = await session.switchSession(previousSessionFile);
258
+ assert.equal(switched, true);
259
+
260
+ const restoredText = session.messages
261
+ .flatMap((message: any) => message.content ?? [])
262
+ .filter((part: any) => part.type === "text")
263
+ .map((part: any) => part.text);
264
+ assert.deepEqual(restoredText, ["persisted prompt", "persisted response"]);
265
+ });
266
+
267
+ it("switchSession() waits instead of aborting while agent_end processing is still streaming", async () => {
268
+ const session = await createSession({ persistSessions: true });
269
+ const previousSessionFile = session.sessionFile;
270
+ assert.ok(previousSessionFile, "need a persisted session file");
271
+
272
+ session.sessionManager.appendMessage({
273
+ role: "user",
274
+ content: [{ type: "text", text: "switch persisted prompt" }],
275
+ } as any);
276
+ session.sessionManager.appendMessage({
277
+ role: "assistant",
278
+ content: [{ type: "text", text: "switch persisted response" }],
279
+ usage: {
280
+ input: 1,
281
+ output: 1,
282
+ cacheRead: 0,
283
+ cacheWrite: 0,
284
+ total: 2,
285
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
286
+ },
287
+ stopReason: "stop",
288
+ timestamp: Date.now(),
289
+ } as any);
290
+ session.agent.replaceMessages(session.sessionManager.buildSessionContext().messages);
291
+
292
+ const ok = await session.newSession();
293
+ assert.equal(ok, true);
294
+ const activeSessionFile = session.sessionFile;
295
+ assert.ok(activeSessionFile, "need an active session file");
296
+ assert.notEqual(activeSessionFile, previousSessionFile);
297
+ assert.deepEqual(session.messages, []);
298
+
299
+ const order: string[] = [];
300
+ let releaseIdle!: () => void;
301
+ const idle = new Promise<void>((resolve) => {
302
+ releaseIdle = resolve;
303
+ });
304
+
305
+ (session as any)._processingAgentEnd = true;
306
+ (session as any).agent.state.isStreaming = true;
307
+ (session as any).agent.waitForIdle = () => {
308
+ order.push("waitForIdle");
309
+ return idle;
310
+ };
311
+ (session as any).abort = async () => {
312
+ order.push("abort");
313
+ };
314
+ const originalDisconnect = (session as any)._disconnectFromAgent.bind(session);
315
+ (session as any)._disconnectFromAgent = () => {
316
+ order.push("_disconnectFromAgent");
317
+ originalDisconnect();
318
+ };
319
+
320
+ const pendingSwitch = session.switchSession(previousSessionFile);
321
+ await Promise.resolve();
322
+ assert.deepEqual(order, ["waitForIdle"]);
323
+ assert.equal(order.includes("abort"), false);
324
+ assert.equal(session.sessionFile, activeSessionFile);
325
+ assert.deepEqual(session.messages, []);
326
+
327
+ releaseIdle();
328
+ const switched = await pendingSwitch;
329
+ assert.equal(switched, true);
330
+ assert.deepEqual(order, ["waitForIdle", "_disconnectFromAgent"]);
331
+ assert.equal(order.includes("abort"), false);
332
+ assert.equal(session.sessionFile, previousSessionFile);
333
+
334
+ const restoredText = session.messages
335
+ .flatMap((message: any) => message.content ?? [])
336
+ .filter((part: any) => part.type === "text")
337
+ .map((part: any) => part.text);
338
+ assert.deepEqual(restoredText, ["switch persisted prompt", "switch persisted response"]);
339
+ });
340
+
341
+ it("newSession() during agent_end skips stale post-handlers after the transition starts", async () => {
342
+ const session = await createSession();
343
+ const assistantMessage = makeAssistantMessage("old response");
344
+ let compactionChecks = 0;
345
+ let listenerAgentEnds = 0;
346
+
347
+ (session as any)._lastAssistantMessage = assistantMessage;
348
+ (session as any)._compactionOrchestrator.checkCompaction = async () => {
349
+ compactionChecks++;
350
+ };
351
+ session.subscribe((event: any) => {
352
+ if (event.type === "agent_end") listenerAgentEnds++;
353
+ });
354
+ installAgentEndSessionTransition(session, () => session.newSession());
355
+
356
+ await (session as any)._processAgentEvent({
357
+ type: "agent_end",
358
+ messages: [assistantMessage],
359
+ });
360
+
361
+ assert.equal(compactionChecks, 0);
362
+ assert.equal(listenerAgentEnds, 0);
363
+ assert.equal((session as any)._lastAssistantMessage, undefined);
364
+ assert.equal((session as any)._sessionSwitchPending, false);
365
+ assert.equal((session as any)._sessionTransitionStartedDuringAgentEnd, false);
366
+ });
367
+
368
+ it("switchSession() during agent_end skips stale post-handlers after the transition starts", async () => {
369
+ const session = await createSession({ persistSessions: true });
370
+ const previousSessionFile = session.sessionFile;
371
+ assert.ok(previousSessionFile, "need a persisted session file");
372
+
373
+ const ok = await session.newSession();
374
+ assert.equal(ok, true);
375
+ assert.notEqual(session.sessionFile, previousSessionFile);
376
+
377
+ const assistantMessage = makeAssistantMessage("old switch response");
378
+ let compactionChecks = 0;
379
+ let listenerAgentEnds = 0;
380
+
381
+ (session as any)._lastAssistantMessage = assistantMessage;
382
+ (session as any)._compactionOrchestrator.checkCompaction = async () => {
383
+ compactionChecks++;
384
+ };
385
+ session.subscribe((event: any) => {
386
+ if (event.type === "agent_end") listenerAgentEnds++;
387
+ });
388
+ installAgentEndSessionTransition(session, () => session.switchSession(previousSessionFile));
389
+
390
+ await (session as any)._processAgentEvent({
391
+ type: "agent_end",
392
+ messages: [assistantMessage],
393
+ });
394
+
395
+ assert.equal(session.sessionFile, previousSessionFile);
396
+ assert.equal(compactionChecks, 0);
397
+ assert.equal(listenerAgentEnds, 0);
398
+ assert.equal((session as any)._lastAssistantMessage, undefined);
399
+ assert.equal((session as any)._sessionSwitchPending, false);
400
+ assert.equal((session as any)._sessionTransitionStartedDuringAgentEnd, false);
401
+ });
402
+
403
+ it("agent_end post-handlers bail while a session switch is pending", async () => {
404
+ const session = await createSession();
405
+ const assistantMessage = makeAssistantMessage("old pending response");
406
+ let compactionChecks = 0;
407
+ let listenerAgentEnds = 0;
408
+
409
+ (session as any)._lastAssistantMessage = assistantMessage;
410
+ (session as any)._sessionSwitchPending = true;
411
+ (session as any)._compactionOrchestrator.checkCompaction = async () => {
412
+ compactionChecks++;
413
+ };
414
+ session.subscribe((event: any) => {
415
+ if (event.type === "agent_end") listenerAgentEnds++;
416
+ });
417
+
418
+ await (session as any)._processAgentEvent({
419
+ type: "agent_end",
420
+ messages: [assistantMessage],
421
+ });
422
+
423
+ assert.equal(compactionChecks, 0);
424
+ assert.equal(listenerAgentEnds, 1);
425
+ assert.equal((session as any)._lastAssistantMessage, undefined);
426
+ });
427
+
109
428
  it("switchSession() invokes abort() before _disconnectFromAgent()", async () => {
110
429
  const session = await createSession({ persistSessions: true });
111
430
  // Seed a session file to switch to (switchSession reads from the session manager).
@@ -272,6 +272,12 @@ export class AgentSession {
272
272
  // Extension system
273
273
  private _extensionRunner: ExtensionRunner | undefined = undefined;
274
274
  private _turnIndex = 0;
275
+ private _processingAgentEnd = false;
276
+ /** True while newSession()/switchSession() is in progress; signals agent_end
277
+ * post-handlers to bail rather than corrupt new-session state. */
278
+ private _sessionSwitchPending = false;
279
+ private _processingQueuedAgentEnd = false;
280
+ private _sessionTransitionStartedDuringAgentEnd = false;
275
281
 
276
282
  private _resourceLoader: ResourceLoader;
277
283
  private _customTools: ToolDefinition[];
@@ -433,7 +439,23 @@ export class AgentSession {
433
439
  }
434
440
 
435
441
  // Emit to extensions first
436
- await this._emitExtensionEvent(event);
442
+ let skipAgentEndPostHandlers = false;
443
+ if (event.type === "agent_end") {
444
+ this._processingQueuedAgentEnd = true;
445
+ try {
446
+ await this._emitExtensionEvent(event);
447
+ } finally {
448
+ this._processingQueuedAgentEnd = false;
449
+ skipAgentEndPostHandlers = this._sessionTransitionStartedDuringAgentEnd;
450
+ this._sessionTransitionStartedDuringAgentEnd = false;
451
+ }
452
+
453
+ if (skipAgentEndPostHandlers) {
454
+ return;
455
+ }
456
+ } else {
457
+ await this._emitExtensionEvent(event);
458
+ }
437
459
 
438
460
  // Notify all listeners
439
461
  this._emit(event);
@@ -491,6 +513,13 @@ export class AgentSession {
491
513
 
492
514
  // Check auto-retry and auto-compaction after agent completes
493
515
  if (event.type === "agent_end" && this._lastAssistantMessage) {
516
+ // A session transition started during agent_end handler execution -
517
+ // bail to avoid running retry/compaction against new-session state.
518
+ if (this._sessionSwitchPending) {
519
+ this._lastAssistantMessage = undefined;
520
+ return;
521
+ }
522
+
494
523
  const msg = this._lastAssistantMessage;
495
524
  this._lastAssistantMessage = undefined;
496
525
 
@@ -621,33 +650,39 @@ export class AgentSession {
621
650
 
622
651
  /** Emit extension events based on agent events */
623
652
  private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
624
- if (!this._extensionRunner) return;
653
+ const extensionRunner = this._extensionRunner;
654
+ if (!extensionRunner) return;
625
655
 
626
656
  if (event.type === "agent_start") {
627
657
  this._turnIndex = 0;
628
- await this._extensionRunner.emit({ type: "agent_start" });
658
+ await extensionRunner.emit({ type: "agent_start" });
629
659
  } else if (event.type === "agent_end") {
630
- await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
631
- // `stop` fires on true quiescence: the agent cleanly completed and is now
632
- // waiting for the user. Use the last assistant message's stopReason to
633
- // distinguish clean completion from error/cancellation.
634
- const last = event.messages[event.messages.length - 1];
635
- const stopReason: "completed" | "cancelled" | "error" | "blocked" =
636
- last?.role === "assistant"
637
- ? last.stopReason === "aborted"
638
- ? "cancelled"
639
- : last.stopReason === "error"
640
- ? "error"
641
- : "completed"
642
- : "completed";
643
- await this._extensionRunner.emitStop({ reason: stopReason, lastMessage: last });
660
+ this._processingAgentEnd = true;
661
+ try {
662
+ await extensionRunner.emit({ type: "agent_end", messages: event.messages });
663
+ // `stop` fires on true quiescence: the agent cleanly completed and is now
664
+ // waiting for the user. Use the last assistant message's stopReason to
665
+ // distinguish clean completion from error/cancellation.
666
+ const last = event.messages[event.messages.length - 1];
667
+ const stopReason: "completed" | "cancelled" | "error" | "blocked" =
668
+ last?.role === "assistant"
669
+ ? last.stopReason === "aborted"
670
+ ? "cancelled"
671
+ : last.stopReason === "error"
672
+ ? "error"
673
+ : "completed"
674
+ : "completed";
675
+ await extensionRunner.emitStop({ reason: stopReason, lastMessage: last });
676
+ } finally {
677
+ this._processingAgentEnd = false;
678
+ }
644
679
  } else if (event.type === "turn_start") {
645
680
  const extensionEvent: TurnStartEvent = {
646
681
  type: "turn_start",
647
682
  turnIndex: this._turnIndex,
648
683
  timestamp: Date.now(),
649
684
  };
650
- await this._extensionRunner.emit(extensionEvent);
685
+ await extensionRunner.emit(extensionEvent);
651
686
  } else if (event.type === "turn_end") {
652
687
  const extensionEvent: TurnEndEvent = {
653
688
  type: "turn_end",
@@ -655,27 +690,27 @@ export class AgentSession {
655
690
  message: event.message,
656
691
  toolResults: event.toolResults,
657
692
  };
658
- await this._extensionRunner.emit(extensionEvent);
693
+ await extensionRunner.emit(extensionEvent);
659
694
  this._turnIndex++;
660
695
  } else if (event.type === "message_start") {
661
696
  const extensionEvent: MessageStartEvent = {
662
697
  type: "message_start",
663
698
  message: event.message,
664
699
  };
665
- await this._extensionRunner.emit(extensionEvent);
700
+ await extensionRunner.emit(extensionEvent);
666
701
  } else if (event.type === "message_update") {
667
702
  const extensionEvent: MessageUpdateEvent = {
668
703
  type: "message_update",
669
704
  message: event.message,
670
705
  assistantMessageEvent: event.assistantMessageEvent,
671
706
  };
672
- await this._extensionRunner.emit(extensionEvent);
707
+ await extensionRunner.emit(extensionEvent);
673
708
  } else if (event.type === "message_end") {
674
709
  const extensionEvent: MessageEndEvent = {
675
710
  type: "message_end",
676
711
  message: event.message,
677
712
  };
678
- await this._extensionRunner.emit(extensionEvent);
713
+ await extensionRunner.emit(extensionEvent);
679
714
  } else if (event.type === "tool_execution_start") {
680
715
  const extensionEvent: ToolExecutionStartEvent = {
681
716
  type: "tool_execution_start",
@@ -683,7 +718,7 @@ export class AgentSession {
683
718
  toolName: event.toolName,
684
719
  args: event.args,
685
720
  };
686
- await this._extensionRunner.emit(extensionEvent);
721
+ await extensionRunner.emit(extensionEvent);
687
722
  } else if (event.type === "tool_execution_update") {
688
723
  const extensionEvent: ToolExecutionUpdateEvent = {
689
724
  type: "tool_execution_update",
@@ -692,7 +727,7 @@ export class AgentSession {
692
727
  args: event.args,
693
728
  partialResult: event.partialResult,
694
729
  };
695
- await this._extensionRunner.emit(extensionEvent);
730
+ await extensionRunner.emit(extensionEvent);
696
731
  } else if (event.type === "tool_execution_end") {
697
732
  const extensionEvent: ToolExecutionEndEvent = {
698
733
  type: "tool_execution_end",
@@ -701,7 +736,7 @@ export class AgentSession {
701
736
  result: event.result,
702
737
  isError: event.isError,
703
738
  };
704
- await this._extensionRunner.emit(extensionEvent);
739
+ await extensionRunner.emit(extensionEvent);
705
740
  }
706
741
  }
707
742
 
@@ -1546,24 +1581,54 @@ export class AgentSession {
1546
1581
  // between tool execution and response processing. Also fire Stop so
1547
1582
  // Layer 0 hooks see a consistent view of session quiescence.
1548
1583
  if (!this.isStreaming && this._extensionRunner) {
1549
- const messages = this.agent.state.messages;
1550
- await this._extensionRunner.emit({
1551
- type: "agent_end",
1552
- messages,
1553
- });
1554
- const last = messages[messages.length - 1];
1555
- const stopReason: "completed" | "cancelled" | "error" | "blocked" =
1556
- last?.role === "assistant"
1557
- ? last.stopReason === "aborted"
1558
- ? "cancelled"
1559
- : last.stopReason === "error"
1560
- ? "error"
1561
- : "completed"
1562
- : "cancelled";
1563
- await this._extensionRunner.emitStop({ reason: stopReason, lastMessage: last });
1584
+ const wasProcessingAgentEnd = this._processingAgentEnd;
1585
+ this._processingAgentEnd = true;
1586
+ try {
1587
+ const messages = this.agent.state.messages;
1588
+ await this._extensionRunner.emit({
1589
+ type: "agent_end",
1590
+ messages,
1591
+ });
1592
+ const last = messages[messages.length - 1];
1593
+ const stopReason: "completed" | "cancelled" | "error" | "blocked" =
1594
+ last?.role === "assistant"
1595
+ ? last.stopReason === "aborted"
1596
+ ? "cancelled"
1597
+ : last.stopReason === "error"
1598
+ ? "error"
1599
+ : "completed"
1600
+ : "cancelled";
1601
+ await this._extensionRunner.emitStop({ reason: stopReason, lastMessage: last });
1602
+ } finally {
1603
+ this._processingAgentEnd = wasProcessingAgentEnd;
1604
+ }
1564
1605
  }
1565
1606
  }
1566
1607
 
1608
+ private async _settleCurrentTurnForSessionTransition(): Promise<void> {
1609
+ if (this._processingAgentEnd) {
1610
+ // Wait for the agent to fully settle. When called from inside an
1611
+ // agent_end extension handler, the agent may already be idle - but
1612
+ // _processAgentEvent still has retry/compaction tail work to run after
1613
+ // _emitExtensionEvent returns. waitForIdle() is effectively a no-op when
1614
+ // already idle, so awaiting it unconditionally is safe and ensures we
1615
+ // don't proceed into the session reset while that tail is still on the stack.
1616
+ await this.agent.waitForIdle();
1617
+
1618
+ if (this._processingQueuedAgentEnd) {
1619
+ this._sessionTransitionStartedDuringAgentEnd = true;
1620
+ this._lastAssistantMessage = undefined;
1621
+ }
1622
+ return;
1623
+ }
1624
+
1625
+ // #4243: Normal session transitions must abort before disconnecting so
1626
+ // message_end/agent_end events fire while listeners are still connected.
1627
+ // During agent_end handling the turn is already ending; aborting there can
1628
+ // convert a successful auto-mode handoff into an aborted provider message.
1629
+ await this.abort();
1630
+ }
1631
+
1567
1632
  /**
1568
1633
  * Start a new session, optionally with initial messages and parent tracking.
1569
1634
  * Clears all messages and starts a new session.
@@ -1592,21 +1657,23 @@ export class AgentSession {
1592
1657
  }
1593
1658
  }
1594
1659
 
1595
- // #4243: Must call abort() BEFORE _disconnectFromAgent() so that
1596
- // message_end/agent_end events fire and the #4216 finalization code
1597
- // can run before we unsubscribe from the event bus.
1598
- await this.abort();
1660
+ this._sessionSwitchPending = true;
1661
+ try {
1662
+ await this._settleCurrentTurnForSessionTransition();
1663
+
1664
+ // #3731: If the caller aborted (e.g. runUnit() timed out and restored cwd to
1665
+ // project root), discard this session before capturing process.cwd() and
1666
+ // rebuilding the tool runtime. Without this check, the late newSession()
1667
+ // would rebuild tools with root cwd, breaking worktree isolation.
1668
+ if (options?.abortSignal?.aborted) {
1669
+ return false;
1670
+ }
1599
1671
 
1600
- // #3731: If the caller aborted (e.g. runUnit() timed out and restored cwd to
1601
- // project root), discard this session before capturing process.cwd() and
1602
- // rebuilding the tool runtime. Without this check, the late newSession()
1603
- // would rebuild tools with root cwd, breaking worktree isolation.
1604
- if (options?.abortSignal?.aborted) {
1605
- return false;
1672
+ this._disconnectFromAgent();
1673
+ this.agent.reset();
1674
+ } finally {
1675
+ this._sessionSwitchPending = false;
1606
1676
  }
1607
-
1608
- this._disconnectFromAgent();
1609
- this.agent.reset();
1610
1677
  // Update cwd to current process directory — auto-mode may have chdir'd
1611
1678
  // into a worktree since the original session was created.
1612
1679
  const previousCwd = this._cwd;
@@ -2457,12 +2524,14 @@ export class AgentSession {
2457
2524
  }
2458
2525
  }
2459
2526
 
2460
- // #4243: Must call abort() BEFORE _disconnectFromAgent() so that
2461
- // message_end/agent_end events fire and the #4216 finalization code
2462
- // can run before we unsubscribe from the event bus.
2463
- await this.abort();
2464
- this._disconnectFromAgent();
2465
- this._steeringMessages = [];
2527
+ this._sessionSwitchPending = true;
2528
+ try {
2529
+ await this._settleCurrentTurnForSessionTransition();
2530
+ this._disconnectFromAgent();
2531
+ } finally {
2532
+ this._sessionSwitchPending = false;
2533
+ }
2534
+ this._steeringMessages = [];
2466
2535
  this._followUpMessages = [];
2467
2536
  this._pendingNextTurnMessages = [];
2468
2537