@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.
- package/CHANGELOG.md +57 -1
- package/dist/{CHANGELOG-1p8yvde7.md → CHANGELOG-8yy8h3bc.md} +57 -1
- package/dist/cli.js +4974 -4968
- package/dist/types/async/job-manager.d.ts +12 -0
- package/dist/types/discovery/agents.d.ts +11 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
- package/dist/types/hindsight/state.d.ts +0 -1
- package/dist/types/lsp/utils.d.ts +8 -0
- package/dist/types/modes/session-observer-registry.d.ts +2 -0
- package/dist/types/registry/agent-lifecycle.d.ts +1 -1
- package/dist/types/session/agent-session-types.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +5 -3
- package/dist/types/session/turn-recovery.d.ts +6 -2
- package/dist/types/task/executor.d.ts +4 -0
- package/dist/types/tools/browser/launch.d.ts +2 -0
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/read.d.ts +2 -0
- package/dist/types/tools/shell-tokenize.d.ts +21 -3
- package/dist/types/utils/changelog.d.ts +3 -2
- package/dist/types/utils/late-cleanup.d.ts +2 -0
- package/dist/types/vibe/runtime.d.ts +2 -0
- package/package.json +13 -13
- package/scripts/omp +11 -2
- package/src/async/job-manager.ts +26 -0
- package/src/cli/stats-cli.ts +3 -3
- package/src/cli/update-cli.ts +2 -56
- package/src/config/config-file.ts +2 -2
- package/src/config/keybindings.ts +3 -3
- package/src/config/model-discovery.ts +36 -2
- package/src/config/model-registry.ts +4 -1
- package/src/discovery/agents.ts +71 -3
- package/src/discovery/claude.ts +8 -5
- package/src/discovery/cursor.ts +8 -5
- package/src/discovery/gemini.ts +11 -8
- package/src/discovery/vscode.ts +1 -0
- package/src/discovery/windsurf.ts +4 -2
- package/src/edit/hashline/filesystem.ts +7 -7
- package/src/eval/agent-bridge.ts +1 -3
- package/src/eval/jl/prelude.jl +4 -4
- package/src/eval/js/shared/prelude.txt +2 -2
- package/src/eval/py/prelude.py +0 -3
- package/src/eval/rb/prelude.rb +1 -2
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
- package/src/hindsight/state.ts +2 -22
- package/src/internal-urls/memory-protocol.ts +29 -0
- package/src/lsp/client.ts +2 -2
- package/src/lsp/utils.ts +29 -0
- package/src/modes/acp/acp-agent.ts +2 -1
- package/src/modes/components/agent-hub.ts +36 -20
- package/src/modes/components/agent-transcript-viewer.ts +1 -3
- package/src/modes/components/bordered-loader.ts +1 -1
- package/src/modes/components/custom-editor.ts +2 -2
- package/src/modes/components/hook-input.ts +1 -1
- package/src/modes/components/hook-selector.ts +2 -2
- package/src/modes/components/session-selector.ts +2 -2
- package/src/modes/controllers/event-controller.ts +159 -2
- package/src/modes/controllers/extension-ui-controller.ts +2 -1
- package/src/modes/print-mode.ts +29 -12
- package/src/modes/session-observer-registry.ts +5 -0
- package/src/registry/agent-lifecycle.ts +21 -3
- package/src/sdk.ts +11 -5
- package/src/session/agent-session-types.ts +8 -0
- package/src/session/agent-session.ts +156 -186
- package/src/session/session-paths.ts +68 -121
- package/src/session/settings-stream-fn.ts +7 -3
- package/src/session/turn-recovery.ts +234 -16
- package/src/session/unexpected-stop-classifier.ts +12 -3
- package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
- package/src/task/agents.ts +1 -1
- package/src/task/executor.ts +105 -29
- package/src/task/isolation-runner.ts +19 -1
- package/src/task/structured-subagent.ts +17 -2
- package/src/tiny/worker.ts +1 -1
- package/src/tools/bash-interceptor.ts +8 -4
- package/src/tools/browser/launch.ts +37 -9
- package/src/tools/glob.ts +4 -2
- package/src/tools/path-utils.ts +13 -3
- package/src/tools/read.ts +6 -0
- package/src/tools/shell-tokenize.ts +38 -9
- package/src/utils/changelog.ts +5 -4
- package/src/utils/late-cleanup.ts +17 -0
- package/src/vibe/runtime.ts +197 -53
- package/src/web/scrapers/hackage.ts +1 -12
- package/src/web/search/providers/duckduckgo.ts +1 -1
- package/src/web/search/providers/ecosia.ts +1 -1
- package/src/web/search/providers/exa.ts +1 -1
- package/src/web/search/providers/firecrawl.ts +1 -1
- package/src/web/search/providers/mojeek.ts +1 -1
- package/src/web/search/providers/perplexity.ts +1 -1
- package/src/web/search/providers/public.ts +1 -1
- 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.
|
|
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
|
-
|
|
422
|
-
//
|
|
423
|
-
//
|
|
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 =
|
|
440
|
+
let used = entryAt(start).length;
|
|
427
441
|
for (let grew = true; grew; ) {
|
|
428
442
|
grew = false;
|
|
429
|
-
if (end <
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
435
|
-
start
|
|
436
|
-
used
|
|
437
|
-
|
|
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(...
|
|
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 =
|
|
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[] = [];
|
|
@@ -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) {
|
|
@@ -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
|
-
|
|
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.#
|
|
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
|
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
+
writeStdoutLine(`${sanitizeText(content.text)}\n`);
|
|
247
267
|
} else if (printThoughts && content.type === "thinking" && content.thinking.trim().length > 0) {
|
|
248
|
-
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
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 ||
|
|
2288
|
-
|
|
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
|
-
|
|
2312
|
-
|
|
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 (
|
|
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. */
|