@oh-my-pi/pi-coding-agent 17.2.7 → 17.2.9

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 (91) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/{CHANGELOG-1p8yvde7.md → CHANGELOG-8yy8h3bc.md} +57 -1
  3. package/dist/cli.js +4974 -4968
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/discovery/agents.d.ts +11 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  7. package/dist/types/hindsight/state.d.ts +0 -1
  8. package/dist/types/lsp/utils.d.ts +8 -0
  9. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  10. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  11. package/dist/types/session/agent-session-types.d.ts +7 -0
  12. package/dist/types/session/agent-session.d.ts +5 -3
  13. package/dist/types/session/turn-recovery.d.ts +6 -2
  14. package/dist/types/task/executor.d.ts +4 -0
  15. package/dist/types/tools/browser/launch.d.ts +2 -0
  16. package/dist/types/tools/path-utils.d.ts +7 -0
  17. package/dist/types/tools/read.d.ts +2 -0
  18. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  19. package/dist/types/utils/changelog.d.ts +3 -2
  20. package/dist/types/utils/late-cleanup.d.ts +2 -0
  21. package/dist/types/vibe/runtime.d.ts +2 -0
  22. package/package.json +13 -13
  23. package/scripts/omp +11 -2
  24. package/src/async/job-manager.ts +26 -0
  25. package/src/cli/stats-cli.ts +3 -3
  26. package/src/cli/update-cli.ts +2 -56
  27. package/src/config/config-file.ts +2 -2
  28. package/src/config/keybindings.ts +3 -3
  29. package/src/config/model-discovery.ts +36 -2
  30. package/src/config/model-registry.ts +4 -1
  31. package/src/discovery/agents.ts +71 -3
  32. package/src/discovery/claude.ts +8 -5
  33. package/src/discovery/cursor.ts +8 -5
  34. package/src/discovery/gemini.ts +11 -8
  35. package/src/discovery/vscode.ts +1 -0
  36. package/src/discovery/windsurf.ts +4 -2
  37. package/src/edit/hashline/filesystem.ts +7 -7
  38. package/src/eval/agent-bridge.ts +1 -3
  39. package/src/eval/jl/prelude.jl +4 -4
  40. package/src/eval/js/shared/prelude.txt +2 -2
  41. package/src/eval/py/prelude.py +0 -3
  42. package/src/eval/rb/prelude.rb +1 -2
  43. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  44. package/src/hindsight/state.ts +2 -22
  45. package/src/internal-urls/memory-protocol.ts +29 -0
  46. package/src/lsp/client.ts +2 -2
  47. package/src/lsp/utils.ts +29 -0
  48. package/src/modes/acp/acp-agent.ts +2 -1
  49. package/src/modes/components/agent-hub.ts +36 -20
  50. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  51. package/src/modes/components/bordered-loader.ts +1 -1
  52. package/src/modes/components/custom-editor.ts +2 -2
  53. package/src/modes/components/hook-input.ts +1 -1
  54. package/src/modes/components/hook-selector.ts +2 -2
  55. package/src/modes/components/session-selector.ts +2 -2
  56. package/src/modes/controllers/event-controller.ts +159 -2
  57. package/src/modes/controllers/extension-ui-controller.ts +2 -1
  58. package/src/modes/print-mode.ts +29 -12
  59. package/src/modes/session-observer-registry.ts +5 -0
  60. package/src/registry/agent-lifecycle.ts +21 -3
  61. package/src/sdk.ts +11 -5
  62. package/src/session/agent-session-types.ts +8 -0
  63. package/src/session/agent-session.ts +156 -186
  64. package/src/session/session-paths.ts +68 -121
  65. package/src/session/settings-stream-fn.ts +7 -3
  66. package/src/session/turn-recovery.ts +234 -16
  67. package/src/session/unexpected-stop-classifier.ts +12 -3
  68. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  69. package/src/task/agents.ts +1 -1
  70. package/src/task/executor.ts +105 -29
  71. package/src/task/isolation-runner.ts +19 -1
  72. package/src/task/structured-subagent.ts +17 -2
  73. package/src/tiny/worker.ts +1 -1
  74. package/src/tools/bash-interceptor.ts +8 -4
  75. package/src/tools/browser/launch.ts +37 -9
  76. package/src/tools/glob.ts +4 -2
  77. package/src/tools/path-utils.ts +13 -3
  78. package/src/tools/read.ts +6 -0
  79. package/src/tools/shell-tokenize.ts +38 -9
  80. package/src/utils/changelog.ts +5 -4
  81. package/src/utils/late-cleanup.ts +17 -0
  82. package/src/vibe/runtime.ts +197 -53
  83. package/src/web/scrapers/hackage.ts +1 -12
  84. package/src/web/search/providers/duckduckgo.ts +1 -1
  85. package/src/web/search/providers/ecosia.ts +1 -1
  86. package/src/web/search/providers/exa.ts +1 -1
  87. package/src/web/search/providers/firecrawl.ts +1 -1
  88. package/src/web/search/providers/mojeek.ts +1 -1
  89. package/src/web/search/providers/perplexity.ts +1 -1
  90. package/src/web/search/providers/public.ts +1 -1
  91. package/src/web/search/providers/startpage.ts +1 -1
@@ -183,6 +183,7 @@ export class AgentHubOverlayComponent extends Container {
183
183
 
184
184
  // Table state
185
185
  #rows: AgentRef[] = [];
186
+ #statusCounts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
186
187
  #selectedRow = 0;
187
188
  #notice: string | undefined;
188
189
  /** Captured row order from the first refresh; keeps the hub stable while open. */
@@ -258,7 +259,7 @@ export class AgentHubOverlayComponent extends Container {
258
259
  }
259
260
 
260
261
  /** Tear down every subscription and timer. Called by the overlay owner on close. */
261
- dispose(): void {
262
+ override dispose(): void {
262
263
  for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe();
263
264
  if (this.#ageTimer) {
264
265
  clearInterval(this.#ageTimer);
@@ -368,6 +369,9 @@ export class AgentHubOverlayComponent extends Container {
368
369
  #refreshRows(): void {
369
370
  const selectedId = this.#rows[this.#selectedRow]?.id;
370
371
  const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID);
372
+ const counts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
373
+ for (const ref of refs) counts[ref.status]++;
374
+ this.#statusCounts = counts;
371
375
 
372
376
  if (!this.#rowOrder) {
373
377
  // First refresh (usually the constructor): order by status, then recency.
@@ -398,7 +402,7 @@ export class AgentHubOverlayComponent extends Container {
398
402
  }
399
403
 
400
404
  #observableFor(id: string): ObservableSession | undefined {
401
- return this.#observers.getSessions().find(s => s.id === id);
405
+ return this.#observers.getSession(id);
402
406
  }
403
407
 
404
408
  // ========================================================================
@@ -418,30 +422,46 @@ export class AgentHubOverlayComponent extends Container {
418
422
  const termHeight = process.stdout.rows || 40;
419
423
  // Chrome: 2 borders + title + notice? + blank + hints + border
420
424
  const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0));
421
- const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width));
422
- // Entries are 1-2 lines tall; grow a window around the selection until
423
- // the line budget is spent, so the selected entry stays centered.
425
+ // Render outward from the selection and stop once the viewport is full.
426
+ // Cache rendered entries so a boundary probe is not paid twice when the
427
+ // same index is later accepted into the window.
428
+ const rendered = new Map<number, string[]>();
429
+ const entryAt = (index: number): string[] => {
430
+ const cached = rendered.get(index);
431
+ if (cached) return cached;
432
+ const ref = this.#rows[index];
433
+ if (!ref) return [];
434
+ const entry = this.#renderEntry(ref, index === this.#selectedRow, width);
435
+ rendered.set(index, entry);
436
+ return entry;
437
+ };
424
438
  let start = this.#selectedRow;
425
439
  let end = this.#selectedRow + 1;
426
- let used = entries[start]?.length ?? 0;
440
+ let used = entryAt(start).length;
427
441
  for (let grew = true; grew; ) {
428
442
  grew = false;
429
- if (end < entries.length && used + entries[end].length <= budget) {
430
- used += entries[end].length;
431
- end++;
432
- grew = true;
443
+ if (end < this.#rows.length) {
444
+ const next = entryAt(end);
445
+ if (used + next.length <= budget) {
446
+ used += next.length;
447
+ end++;
448
+ grew = true;
449
+ }
433
450
  }
434
- if (start > 0 && used + entries[start - 1].length <= budget) {
435
- start--;
436
- used += entries[start].length;
437
- grew = true;
451
+ if (start > 0) {
452
+ const previous = entryAt(start - 1);
453
+ if (used + previous.length <= budget) {
454
+ start--;
455
+ used += previous.length;
456
+ grew = true;
457
+ }
438
458
  }
439
459
  }
440
460
  if (start > 0) {
441
461
  lines.push(` ${theme.fg("dim", `… ${start} more`)}`);
442
462
  }
443
463
  for (let i = start; i < end; i++) {
444
- lines.push(...entries[i]);
464
+ lines.push(...entryAt(i));
445
465
  }
446
466
  if (end < this.#rows.length) {
447
467
  lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
@@ -458,13 +478,9 @@ export class AgentHubOverlayComponent extends Container {
458
478
  }
459
479
 
460
480
  #statusSummary(): string {
461
- const counts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
462
- for (const ref of this.#rows) {
463
- counts[ref.status]++;
464
- }
465
481
  const parts: string[] = [];
466
482
  for (const status of ["running", "idle", "parked", "aborted"] as const) {
467
- const count = counts[status];
483
+ const count = this.#statusCounts[status];
468
484
  if (count > 0) parts.push(`${count} ${status}`);
469
485
  }
470
486
  return parts.join(theme.sep.dot);
@@ -613,9 +613,7 @@ export class AgentTranscriptViewer implements Component {
613
613
  }
614
614
 
615
615
  #statsLine(): string {
616
- const observed: ObservableSession | undefined = this.deps.observers
617
- ?.getSessions()
618
- .find(s => s.id === this.deps.agentId);
616
+ const observed: ObservableSession | undefined = this.deps.observers?.getSession(this.deps.agentId);
619
617
  const progress = observed?.progress;
620
618
  if (!progress) return "";
621
619
  const stats: string[] = [];
@@ -35,7 +35,7 @@ export class BorderedLoader extends Container {
35
35
  this.#loader.handleInput(data);
36
36
  }
37
37
 
38
- dispose(): void {
38
+ override dispose(): void {
39
39
  this.#loader.dispose();
40
40
  }
41
41
  }
@@ -466,7 +466,7 @@ export class CustomEditor extends Editor {
466
466
  /** Decorate magic keywords, attachments, and the queue-composer header/list markers.
467
467
  * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
468
468
  * item markers use the accent color so separate follow-ups remain visible while composing. */
469
- decorateText = (text: string): string => {
469
+ override decorateText = (text: string): string => {
470
470
  const editorText = this.getText();
471
471
  const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
472
472
  const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
@@ -775,7 +775,7 @@ export class CustomEditor extends Editor {
775
775
  void promise.then(this.#onPasteSettled, this.#onPasteSettled);
776
776
  }
777
777
 
778
- handleInput(data: string): void {
778
+ override handleInput(data: string): void {
779
779
  // Serialize behind any in-flight async paste so a trailing Enter / follow-up key can't
780
780
  // submit before the clipboard image reaches `pendingImages` (Codex PR #3602 review).
781
781
  if (this.#pasteInFlight > 0) {
@@ -81,7 +81,7 @@ export class HookInputComponent extends Container {
81
81
  this.#input.pasteText(text);
82
82
  }
83
83
 
84
- dispose(): void {
84
+ override dispose(): void {
85
85
  this.#countdown?.dispose();
86
86
  }
87
87
  }
@@ -135,7 +135,7 @@ class OutlinedList extends Container {
135
135
  this.invalidate();
136
136
  }
137
137
 
138
- render(width: number): readonly string[] {
138
+ override render(width: number): readonly string[] {
139
139
  const borderColor = (text: string) => theme.fg("border", text);
140
140
  const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width)));
141
141
  const innerWidth = Math.max(1, width - 2);
@@ -685,7 +685,7 @@ export class HookSelectorComponent extends Container {
685
685
  return super.render(renderWidth);
686
686
  }
687
687
 
688
- dispose(): void {
688
+ override dispose(): void {
689
689
  this.#countdown?.dispose();
690
690
  }
691
691
  }
@@ -905,7 +905,7 @@ export class SessionSelectorComponent extends Container {
905
905
  * is mounted the list is detached from the child tree, so Container's
906
906
  * child-walking dispose would miss its pending history-merge timer.
907
907
  */
908
- dispose(): void {
908
+ override dispose(): void {
909
909
  this.#sessionList.dispose();
910
910
  super.dispose();
911
911
  }
@@ -971,7 +971,7 @@ export class SessionSelectorComponent extends Container {
971
971
  * footer is always visible and never drifts as the list window resizes. The
972
972
  * in-editor selector just appends the footer directly.
973
973
  */
974
- render(width: number): readonly string[] {
974
+ override render(width: number): readonly string[] {
975
975
  const lines: string[] = [];
976
976
  for (const child of this.children) {
977
977
  const childLines = child.render(width);
@@ -170,6 +170,26 @@ export class EventController {
170
170
  #prevHideThinking = false;
171
171
  #handlers: AgentSessionEventHandlers;
172
172
  #terminalProgressActive = false;
173
+ // Coalescing window for `message_update` events at the subscription boundary.
174
+ // `message_update` carries the CUMULATIVE assistant message (every update
175
+ // re-lists all content blocks), so when a burst of deltas arrives faster than
176
+ // this window only the latest snapshot needs to rebuild streaming state — the
177
+ // intermediate rebuilds are redundant work. The TUI already caps the paint
178
+ // rate via its own render cadence; this caps the per-token handler work that
179
+ // feeds it. Speech stays intact: `#vocalizeDelta` runs at ARRIVAL for every
180
+ // delta before the snapshot is coalesced away.
181
+ #pendingMessageUpdate: Extract<AgentSessionEvent, { type: "message_update" }> | undefined = undefined;
182
+ #messageUpdateTimer: NodeJS.Timeout | undefined = undefined;
183
+ /** Tail of the serialized dispatch chain; see #runSerialized. */
184
+ #dispatchTail: Promise<void> = Promise.resolve();
185
+ /** Whether a chained run is currently in flight (awaiting its own awaits). */
186
+ #dispatchInFlight = false;
187
+ // Deltas already fed to speech at arrival by the coalescer. `#handleMessageUpdate`
188
+ // also vocalizes so the direct `handleEvent` path (tests, session focus replay)
189
+ // keeps working — the WeakSet makes the coalesced path speak each delta exactly
190
+ // once instead of twice.
191
+ #vocalizedMessageUpdates = new WeakSet<object>();
192
+ static readonly #MESSAGE_UPDATE_COALESCE_MS = 33;
173
193
 
174
194
  constructor(private ctx: InteractiveModeContext) {
175
195
  // Enhanced speech (`speech.enhanced`) rewrites blocks through the
@@ -253,6 +273,11 @@ export class EventController {
253
273
  }
254
274
 
255
275
  dispose(): void {
276
+ if (this.#messageUpdateTimer) {
277
+ clearTimeout(this.#messageUpdateTimer);
278
+ this.#messageUpdateTimer = undefined;
279
+ }
280
+ this.#pendingMessageUpdate = undefined;
256
281
  this.#streamingReveal.stop();
257
282
  this.#toolArgsReveal.stop();
258
283
  this.#cancelIdleCompaction();
@@ -425,10 +450,135 @@ export class EventController {
425
450
  }
426
451
 
427
452
  subscribeToAgent(): void {
453
+ // Serialize non-update dispatch behind any in-flight handler run:
454
+ // AgentSession.#emit fires listeners fire-and-forget (it does not await
455
+ // listener promises), so without this a rapid stream tail
456
+ // (message_update → message_end → agent_end) could let a later callback
457
+ // overtake the coalesced flush's handler mid-await — agent_end removing
458
+ // `streamingComponent` before #handleMessageEnd finalizes and records
459
+ // the final message (issue #7443 follow-up). When the tail has settled,
460
+ // dispatch stays synchronous: the flush's streaming rebuild runs before
461
+ // the listener's first await, preserving the timing the coalescing
462
+ // tests assert on. `message_update` enqueue is itself synchronous and
463
+ // needs no serialization.
428
464
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
429
- await this.handleEvent(event);
465
+ // Coalesce the cumulative `message_update` deltas of a streaming turn
466
+ // into at most one handler run per window. `#handleMessageUpdate` is
467
+ // synchronous, so without this every token re-runs the whole
468
+ // streaming rebuild (splitAssistantMessageToolTimeline, reveal
469
+ // setTarget, per-block tool-call reconciliation) even though the TUI
470
+ // paints at most ~30fps — at 40-100 tps the handler work then
471
+ // dominates the CPU profile of an idle-looking streaming session
472
+ // (issue #7443). Only the latest snapshot is meaningful; non-update
473
+ // events flush the pending snapshot first so ordering is preserved.
474
+ if (event.type === "message_update") {
475
+ this.#enqueueMessageUpdate(event);
476
+ return;
477
+ }
478
+ await this.#runSerialized(async () => {
479
+ await this.#flushPendingMessageUpdate();
480
+ await this.handleEvent(event);
481
+ });
430
482
  });
431
483
  }
484
+
485
+ /**
486
+ * Run `run` in the serialized dispatch chain: every run is its own link on
487
+ * the tail, so a burst of events queued behind an in-flight run start one
488
+ * after the other, never concurrently. This closes two races (issue #7443
489
+ * follow-up): a rapid stream tail (message_update → message_end →
490
+ * agent_end) cannot overtake the coalesced flush mid-await — agent_end
491
+ * removing `streamingComponent` before #handleMessageEnd finalizes and
492
+ * records the final message — and two+ events landing in the same window
493
+ * cannot all resume from one shared await and dispatch in parallel. When
494
+ * the chain is drained, `run` starts synchronously (no intermediate
495
+ * microtask), preserving the synchronous-flush timing the coalescing
496
+ * tests assert on. A rejection propagates to the caller (the session's
497
+ * fire-and-forget emit) and the next event starts a fresh chain link
498
+ * instead of being dropped.
499
+ */
500
+ async #runSerialized(run: () => Promise<void>): Promise<void> {
501
+ if (this.#dispatchInFlight) {
502
+ // Queue behind the CURRENT tail: the next run starts only after
503
+ // the previous one settles. Each waiter gets its own link, so a
504
+ // burst cannot fan out from the same shared await.
505
+ const link = this.#dispatchTail.then(
506
+ () => run(),
507
+ () => run(),
508
+ );
509
+ this.#dispatchTail = link;
510
+ void link.then(
511
+ () => {
512
+ // Only the tail owner clears the flag: a later chained
513
+ // link clears it when it settles as the tail.
514
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
515
+ },
516
+ () => {
517
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
518
+ },
519
+ );
520
+ await link;
521
+ return;
522
+ }
523
+ this.#dispatchInFlight = true;
524
+ const link = run();
525
+ this.#dispatchTail = link;
526
+ void link.then(
527
+ () => {
528
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
529
+ },
530
+ () => {
531
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
532
+ },
533
+ );
534
+ await link;
535
+ }
536
+
537
+ /**
538
+ * Queue a streaming `message_update` for the next coalesced handler run.
539
+ * Speech is per-delta, so the delta is vocalized at arrival before the
540
+ * snapshot is (possibly) superseded by a newer one.
541
+ */
542
+ #enqueueMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): void {
543
+ // Speech is per-delta: every delta is spoken at arrival even when its
544
+ // cumulative snapshot is later superseded and never rebuilt.
545
+ this.#vocalizeDelta(event);
546
+ this.#vocalizedMessageUpdates.add(event);
547
+ this.#pendingMessageUpdate = event;
548
+ if (this.#messageUpdateTimer) return;
549
+ this.#messageUpdateTimer = setTimeout(() => {
550
+ this.#messageUpdateTimer = undefined;
551
+ // Mirror AgentSession.#emit: attach a catch so a streaming rebuild
552
+ // failure surfaces as a logged warning instead of a process-level
553
+ // unhandled rejection (the timer path has no listener to attach one).
554
+ // Runs inside the serialized dispatch chain so a message_end /
555
+ // agent_end landing mid-window cannot overtake this flush (issue
556
+ // #7443 follow-up).
557
+ void this.#runSerialized(async () => {
558
+ await this.#flushPendingMessageUpdate();
559
+ }).catch(err => {
560
+ logger.warn("Message update flush rejected", {
561
+ error: err instanceof Error ? err.message : String(err),
562
+ });
563
+ });
564
+ }, EventController.#MESSAGE_UPDATE_COALESCE_MS);
565
+ }
566
+
567
+ /**
568
+ * Run the coalesced `message_update` handler on the latest pending snapshot
569
+ * (dropping any superseded intermediates) and clear the queue. Safe to call
570
+ * more than once; no-ops when nothing is pending.
571
+ */
572
+ async #flushPendingMessageUpdate(): Promise<void> {
573
+ if (this.#messageUpdateTimer) {
574
+ clearTimeout(this.#messageUpdateTimer);
575
+ this.#messageUpdateTimer = undefined;
576
+ }
577
+ const event = this.#pendingMessageUpdate;
578
+ if (!event) return;
579
+ this.#pendingMessageUpdate = undefined;
580
+ await this.handleEvent(event);
581
+ }
432
582
  /**
433
583
  * Clear every transcript-anchored/turn-scoped piece of state. Used by the
434
584
  * session focus proxy when re-pointing the transcript at another session:
@@ -436,6 +586,11 @@ export class EventController {
436
586
  * session's transcript and must not bleed into the new one.
437
587
  */
438
588
  resetTranscriptAnchors(): void {
589
+ if (this.#messageUpdateTimer) {
590
+ clearTimeout(this.#messageUpdateTimer);
591
+ this.#messageUpdateTimer = undefined;
592
+ }
593
+ this.#pendingMessageUpdate = undefined;
439
594
  this.#resetReadGroup();
440
595
  this.#lastVisibleBlockCount = 0;
441
596
  this.#renderedCustomMessages.clear();
@@ -814,7 +969,9 @@ export class EventController {
814
969
 
815
970
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
816
971
  this.#ensureWorkingLoaderWhileStreaming();
817
- this.#vocalizeDelta(event);
972
+ if (!this.#vocalizedMessageUpdates.delete(event)) {
973
+ this.#vocalizeDelta(event);
974
+ }
818
975
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
819
976
  const unlockedThinkingVisibility = this.ctx.noteDisplayableThinkingContent(event.message);
820
977
  if (unlockedThinkingVisibility) {
@@ -128,7 +128,7 @@ export class ExtensionUiController {
128
128
  };
129
129
  this.ctx.setToolUIContext(uiContext, true);
130
130
  this.#toolUIContext = uiContext;
131
- this.ctx.session.setUsageFallbackConfirmer?.(confirmation => {
131
+ this.ctx.session.setUsageFallbackConfirmer?.((confirmation, signal) => {
132
132
  const reserve =
133
133
  confirmation.remainingPercent === undefined
134
134
  ? "inside the configured reserve margin"
@@ -136,6 +136,7 @@ export class ExtensionUiController {
136
136
  return this.showHookConfirm(
137
137
  "Coding-plan reserve reached",
138
138
  `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`,
139
+ { signal },
139
140
  );
140
141
  });
141
142
 
@@ -91,11 +91,29 @@ export function printableEvent(event: AgentSessionEvent): unknown {
91
91
  export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<void> {
92
92
  const { mode, messages = [], initialMessage, initialImages, printThoughts } = options;
93
93
 
94
+ // process.stdout.write is fire-and-forget: a large final record (e.g. a
95
+ // multi-MB agent_end) can be dropped when the process exits before the pipe
96
+ // drains, truncating the record mid-line while the process still exits 0.
97
+ // Serialize every stdout write on the previous write's completion callback so
98
+ // records stay ordered and honor backpressure, then block shutdown on the
99
+ // tail before dispose/exit. Same truncation class as issue #5309 (issue #7635).
100
+ let stdoutTail: Promise<void> = Promise.resolve();
101
+ const writeStdoutLine = (text: string): void => {
102
+ stdoutTail = stdoutTail.then(() => {
103
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
104
+ process.stdout.write(text, err => {
105
+ if (err) reject(err);
106
+ else resolve();
107
+ });
108
+ return promise;
109
+ });
110
+ };
111
+
94
112
  // Emit session header for JSON mode
95
113
  if (mode === "json") {
96
114
  const header = session.sessionManager.getHeader();
97
115
  if (header) {
98
- process.stdout.write(`${JSON.stringify(header)}\n`);
116
+ writeStdoutLine(`${JSON.stringify(header)}\n`);
99
117
  }
100
118
  }
101
119
  // Set up extensions for print mode (no UI, no command context)
@@ -172,7 +190,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
172
190
  }
173
191
  // In JSON mode, output all events
174
192
  if (mode === "json") {
175
- process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`);
193
+ writeStdoutLine(`${JSON.stringify(printableEvent(event))}\n`);
176
194
  }
177
195
  });
178
196
 
@@ -186,12 +204,14 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
186
204
  // Send initial message with attachments
187
205
  if (initialMessage !== undefined) {
188
206
  writeTextWorkingIndicator();
207
+ if (mode === "text") session.setTextOutputCommitted(false);
189
208
  await logger.time("print:prompt:initial", () => session.prompt(initialMessage, { images: initialImages }));
190
209
  }
191
210
 
192
211
  // Send remaining messages
193
212
  for (const message of messages) {
194
213
  writeTextWorkingIndicator();
214
+ if (mode === "text") session.setTextOutputCommitted(false);
195
215
  await logger.time("print:prompt:next", () => session.prompt(message));
196
216
  }
197
217
 
@@ -243,23 +263,20 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
243
263
  // Output text content
244
264
  for (const content of assistantMsg.content) {
245
265
  if (content.type === "text") {
246
- process.stdout.write(`${sanitizeText(content.text)}\n`);
266
+ writeStdoutLine(`${sanitizeText(content.text)}\n`);
247
267
  } else if (printThoughts && content.type === "thinking" && content.thinking.trim().length > 0) {
248
- process.stdout.write(`${sanitizeText(content.thinking)}\n`);
268
+ writeStdoutLine(`${sanitizeText(content.thinking)}\n`);
249
269
  }
250
270
  }
251
271
  }
272
+ session.setTextOutputCommitted(true);
252
273
  }
253
274
 
254
275
  await session.waitForAdvisorCatchup(PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS);
255
276
 
256
- // Ensure stdout, including late JSON advisor events, is fully flushed before returning.
257
- // This prevents race conditions where the process exits before all output is written.
258
- await new Promise<void>((resolve, reject) => {
259
- process.stdout.write("", err => {
260
- if (err) reject(err);
261
- else resolve();
262
- });
263
- });
277
+ // Block shutdown until every serialized stdout write (including the final
278
+ // agent_end and late JSON advisor events) has drained; process.exit would
279
+ // otherwise discard the buffered tail and truncate the last record.
280
+ await stdoutTail;
264
281
  await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
265
282
  }
@@ -91,6 +91,11 @@ export class SessionObserverRegistry {
91
91
  this.#notifyListeners("main");
92
92
  }
93
93
 
94
+ /** Return one tracked session without copying or sorting the registry. */
95
+ getSession(id: string): ObservableSession | undefined {
96
+ return this.#sessions.get(id);
97
+ }
98
+
94
99
  getSessions(): ObservableSession[] {
95
100
  const sessions = [...this.#sessions.values()];
96
101
  sessions.sort((a, b) => {
@@ -20,8 +20,9 @@
20
20
  * a superseded revive) can never clobber a newer same-id ref.
21
21
  */
22
22
 
23
- import { logger } from "@oh-my-pi/pi-utils";
23
+ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
24
24
  import type { AgentSession } from "../session/agent-session";
25
+ import { trackLateCleanup } from "../utils/late-cleanup";
25
26
  import {
26
27
  type AgentRef,
27
28
  type AgentRefExpectation,
@@ -32,6 +33,8 @@ import {
32
33
 
33
34
  export type AgentReviver = (expected: AgentRef) => Promise<AgentSession>;
34
35
 
36
+ const AGENT_RELEASE_GRACE_MS = 5000;
37
+
35
38
  /**
36
39
  * Builds a reviver for a `parked` ref restored from disk (Agent Hub scan,
37
40
  * collab mirror, resumed process) that carries a sessionFile but no in-memory
@@ -402,11 +405,26 @@ export class AgentLifecycleManager {
402
405
  }
403
406
 
404
407
  /** Teardown everything (process exit / main session dispose). */
405
- async dispose(): Promise<void> {
408
+ async dispose(deadlineAt: number = Date.now() + AGENT_RELEASE_GRACE_MS): Promise<void> {
406
409
  this.#unsubscribe?.();
407
410
  this.#unsubscribe = undefined;
408
411
  const ids = [...new Set([...this.#adopted.keys(), ...this.#parks.keys()])];
409
- await Promise.all(ids.map(id => this.release(id)));
412
+ await Promise.all(
413
+ ids.map(async id => {
414
+ const release = this.release(id).then(() => {});
415
+ try {
416
+ await untilAborted(AbortSignal.timeout(Math.max(0, deadlineAt - Date.now())), () => release);
417
+ } catch (error) {
418
+ if (Date.now() >= deadlineAt) {
419
+ trackLateCleanup(release, { id, resource: "adopted-agent" });
420
+ }
421
+ logger.warn("Agent cleanup exceeded its deadline", {
422
+ id,
423
+ error: error instanceof Error ? error.message : String(error),
424
+ });
425
+ }
426
+ }),
427
+ );
410
428
  this.#revivals.clear();
411
429
  this.#parks.clear();
412
430
  this.#persistedReviverFactory = undefined;
package/src/sdk.ts CHANGED
@@ -2283,9 +2283,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2283
2283
  }
2284
2284
  }
2285
2285
  const usageReservePolicy = settings.get("retry.usageReservePolicy");
2286
+ const modelFallbackEnabled = settings.get("retry.modelFallback");
2286
2287
  if (
2287
- (hasUsageFallbackCandidate || usageReservePolicy === "fail-closed") &&
2288
- settings.get("retry.modelFallback") &&
2288
+ ((modelFallbackEnabled && (hasUsageFallbackCandidate || usageFallbackTriggered)) ||
2289
+ usageReservePolicy === "fail-closed") &&
2289
2290
  settings.get("retry.usageAwareFallback")
2290
2291
  ) {
2291
2292
  let usageHealth: ModelUsageHealth | undefined;
@@ -2308,8 +2309,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2308
2309
  `Usage depleted for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`,
2309
2310
  );
2310
2311
  }
2311
- usageFallbackTriggered = true;
2312
- continue;
2312
+ if (modelFallbackEnabled) {
2313
+ usageFallbackTriggered = true;
2314
+ continue;
2315
+ }
2313
2316
  }
2314
2317
  if (usageHealth?.state === "reserve") {
2315
2318
  if (usageReservePolicy === "fail-closed") {
@@ -2317,7 +2320,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2317
2320
  `Usage reserve reached for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`,
2318
2321
  );
2319
2322
  }
2320
- if (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) {
2323
+ if (
2324
+ modelFallbackEnabled &&
2325
+ (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation))
2326
+ ) {
2321
2327
  usageFallbackTriggered = true;
2322
2328
  continue;
2323
2329
  }
@@ -92,6 +92,14 @@ export interface UsageFallbackConfirmation {
92
92
  remainingPercent: number | undefined;
93
93
  }
94
94
 
95
+ /**
96
+ * Confirms whether a reserve-triggered model fallback may proceed.
97
+ *
98
+ * Interactive callers use the confirmation details to present the pending
99
+ * route change; aborting `signal` cancels that pending confirmation.
100
+ */
101
+ export type UsageFallbackConfirmer = (confirmation: UsageFallbackConfirmation, signal: AbortSignal) => Promise<boolean>;
102
+
95
103
  /** Identifies a retry fallback chain already entered during startup model resolution. */
96
104
  export interface InitialRetryFallbackState {
97
105
  /** Role whose configured primary was unavailable. */