@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.4

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 (94) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.js +3553 -3540
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  10. package/dist/types/edit/hashline/execute.d.ts +1 -1
  11. package/dist/types/edit/hashline/params.d.ts +3 -6
  12. package/dist/types/edit/renderer.d.ts +1 -0
  13. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  14. package/dist/types/hindsight/client.d.ts +15 -11
  15. package/dist/types/hindsight/client.test.d.ts +1 -0
  16. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  17. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  18. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  19. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  20. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  23. package/dist/types/session/session-storage.d.ts +31 -3
  24. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  25. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  26. package/dist/types/ssh/connection-manager.d.ts +19 -0
  27. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  28. package/dist/types/subprocess/worker-client.d.ts +20 -6
  29. package/dist/types/tools/bash.d.ts +27 -0
  30. package/dist/types/tools/renderers.d.ts +13 -0
  31. package/dist/types/tools/ssh.d.ts +2 -0
  32. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  33. package/dist/types/web/search/providers/base.d.ts +1 -0
  34. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  35. package/package.json +12 -12
  36. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  37. package/src/cli/models-cli.ts +19 -0
  38. package/src/cli/update-cli.test.ts +28 -0
  39. package/src/cli/update-cli.ts +35 -8
  40. package/src/cli/usage-cli.ts +34 -5
  41. package/src/commands/update.ts +8 -2
  42. package/src/config/config-file.ts +6 -6
  43. package/src/config/settings-schema.ts +10 -0
  44. package/src/dap/client.ts +134 -36
  45. package/src/edit/file-snapshot-store.ts +12 -1
  46. package/src/edit/hashline/diff.ts +4 -13
  47. package/src/edit/hashline/execute.ts +1 -1
  48. package/src/edit/hashline/params.ts +5 -12
  49. package/src/edit/renderer.ts +4 -2
  50. package/src/edit/streaming.ts +15 -5
  51. package/src/export/html/tool-views.generated.js +2 -2
  52. package/src/extensibility/plugins/installer.ts +12 -3
  53. package/src/extensibility/plugins/manager.ts +32 -8
  54. package/src/extensibility/plugins/parser.ts +7 -5
  55. package/src/extensibility/tool-event-input.ts +1 -1
  56. package/src/hindsight/client.test.ts +33 -0
  57. package/src/hindsight/client.ts +42 -22
  58. package/src/internal-urls/docs-index.generated.txt +1 -1
  59. package/src/main.ts +7 -1
  60. package/src/mcp/oauth-flow.ts +93 -4
  61. package/src/mcp/smithery-auth.ts +3 -0
  62. package/src/mcp/smithery-connect.ts +9 -0
  63. package/src/mcp/smithery-registry.test.ts +51 -0
  64. package/src/mcp/smithery-registry.ts +27 -4
  65. package/src/modes/components/advisor-message.ts +13 -10
  66. package/src/modes/components/status-line/component.ts +11 -4
  67. package/src/modes/components/tool-execution.ts +74 -8
  68. package/src/modes/components/transcript-container.ts +26 -0
  69. package/src/modes/controllers/event-controller.ts +7 -3
  70. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  71. package/src/modes/interactive-mode.ts +1 -0
  72. package/src/modes/rpc/rpc-client.ts +29 -16
  73. package/src/modes/theme/shimmer.ts +49 -15
  74. package/src/modes/theme/theme.ts +7 -0
  75. package/src/session/agent-session.ts +166 -30
  76. package/src/session/indexed-session-storage.ts +40 -3
  77. package/src/session/session-manager.ts +83 -14
  78. package/src/session/session-storage.ts +112 -26
  79. package/src/slash-commands/helpers/usage-report.ts +6 -1
  80. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  81. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  82. package/src/ssh/connection-manager.ts +44 -11
  83. package/src/ssh/sshfs-mount.ts +27 -4
  84. package/src/subprocess/worker-client.ts +161 -10
  85. package/src/tools/bash.ts +30 -1
  86. package/src/tools/grep.ts +21 -1
  87. package/src/tools/read.ts +14 -4
  88. package/src/tools/renderers.ts +13 -0
  89. package/src/tools/ssh.ts +8 -0
  90. package/src/utils/clipboard.ts +49 -12
  91. package/src/utils/fetch-timeout.ts +10 -0
  92. package/src/web/search/index.ts +8 -0
  93. package/src/web/search/providers/base.ts +1 -0
  94. package/src/web/search/providers/gemini.ts +23 -6
@@ -443,6 +443,10 @@ export class TranscriptContainer
443
443
  // drift after commit; the engine commits them audit-exempt. Provisional
444
444
  // (commit-unstable) blocks never extend it.
445
445
  #nativeScrollbackSnapshotSafeEnd: number | undefined;
446
+ // Local line index through which lower finalized siblings are safe to OFFER to
447
+ // native scrollback while still audited. Unlike snapshotSafeEnd, rows below a
448
+ // live block are not durable: growth above them must repair stale history.
449
+ #nativeScrollbackOfferSafeEnd: number | undefined;
446
450
  // Persistent assembled transcript rows. Rows before the stable floor are
447
451
  // byte-identical to the previous render; rows at/after it were re-pushed.
448
452
  #lines: string[] = [];
@@ -491,6 +495,10 @@ export class TranscriptContainer
491
495
  return this.#nativeScrollbackSnapshotSafeEnd;
492
496
  }
493
497
 
498
+ getNativeScrollbackOfferSafeEnd(): number | undefined {
499
+ return this.#nativeScrollbackOfferSafeEnd;
500
+ }
501
+
494
502
  /**
495
503
  * Whether `component` sits below a still-mutating block — i.e. inside the
496
504
  * live region, where its rows cannot have been committed to native
@@ -583,6 +591,7 @@ export class TranscriptContainer
583
591
  this.#nativeScrollbackLiveRegionStart = undefined;
584
592
  this.#nativeScrollbackCommitSafeEnd = undefined;
585
593
  this.#nativeScrollbackSnapshotSafeEnd = undefined;
594
+ this.#nativeScrollbackOfferSafeEnd = undefined;
586
595
 
587
596
  const count = this.children.length;
588
597
 
@@ -629,6 +638,14 @@ export class TranscriptContainer
629
638
  // liveStartIndex; empty leading blocks (or a separator) must not claim it
630
639
  // early.
631
640
  let liveRecorded = false;
641
+ // Prefix boundary for finalized siblings rendered below the first live
642
+ // block. These rows may be offered to native scrollback, but they cannot
643
+ // extend snapshotSafeEnd because a live block above can still move them.
644
+ let offerSafeEnd: number | undefined;
645
+ // Offer rows must be a contiguous finalized run below the first live
646
+ // block. A later live/provisional block can still push rows below it, so
647
+ // finalized siblings after that barrier must stay forced-overflow.
648
+ let offerSafeOpen = true;
632
649
  // Frame row cursor: rows emitted (reused or pushed) so far.
633
650
  let row = 0;
634
651
  let stableRows = 0;
@@ -701,6 +718,7 @@ export class TranscriptContainer
701
718
  // everything below it.
702
719
  if (contribution.length === 0) {
703
720
  if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false;
721
+ if (i > liveStartIndex && !finalized) offerSafeOpen = false;
704
722
  if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) {
705
723
  chainStable = false;
706
724
  lines.length = row;
@@ -770,6 +788,13 @@ export class TranscriptContainer
770
788
  // rows around as it grows, so the run closes there.
771
789
  if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
772
790
  }
791
+ if (i > liveStartIndex) {
792
+ if (offerSafeOpen && finalized) {
793
+ offerSafeEnd = blockStart + contribution.length;
794
+ } else if (!finalized) {
795
+ offerSafeOpen = false;
796
+ }
797
+ }
773
798
 
774
799
  segments[i] = {
775
800
  component: child,
@@ -788,6 +813,7 @@ export class TranscriptContainer
788
813
  // Trailing shrink: blocks removed from the tail leave stale rows behind
789
814
  // when every surviving segment was reused.
790
815
  if (lines.length !== row) lines.length = row;
816
+ this.#nativeScrollbackOfferSafeEnd = offerSafeEnd;
791
817
  this.#segments = segments;
792
818
  this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
793
819
  return lines;
@@ -302,9 +302,13 @@ export class EventController {
302
302
  await this.ctx.init();
303
303
  }
304
304
 
305
- this.ctx.statusLine.invalidate();
306
- this.ctx.ui.requestRender();
307
-
305
+ // Each handler explicitly requests a render (or leaves it out, when it
306
+ // changed nothing visible). A blanket pre-render fired on every event —
307
+ // including the ~hundreds of `message_update` deltas per streaming turn —
308
+ // doubled the paint rate: the pre-render's frame fires while the handler
309
+ // is awaiting, then the handler's own final requestRender schedules a
310
+ // second identical frame. Removing it lets the render cadence follow real
311
+ // state changes rather than event volume (issue #4353).
308
312
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
309
313
  await run(event);
310
314
  }
@@ -13,7 +13,7 @@ type ToolArgsRevealComponent = {
13
13
  // patch/replace `edits[].diff`) still fall through to the throttled parse.
14
14
  const STREAMING_STRING_KEYS_BY_TOOL: Record<string, readonly string[]> = {
15
15
  write: ["content"],
16
- edit: ["input"],
16
+ edit: ["input", "_input"],
17
17
  eval: ["code"],
18
18
  };
19
19
 
@@ -3584,6 +3584,7 @@ export class InteractiveMode implements InteractiveModeContext {
3584
3584
  } else if (!this.statusContainer.children.includes(this.loadingAnimation)) {
3585
3585
  this.statusContainer.clear();
3586
3586
  this.statusContainer.addChild(this.loadingAnimation);
3587
+ this.ui.requestRender();
3587
3588
  }
3588
3589
  this.applyPendingWorkingMessage();
3589
3590
  }
@@ -256,18 +256,19 @@ export class RpcClient {
256
256
  args.push(...this.options.args);
257
257
  }
258
258
 
259
- this.#process = ptree.spawn(["bun", cliPath, ...args], {
259
+ const child = ptree.spawn(["bun", cliPath, ...args], {
260
260
  cwd: this.options.cwd,
261
261
  env: { ...Bun.env, ...this.options.env },
262
262
  stdin: "pipe",
263
263
  });
264
+ this.#process = child;
264
265
 
265
266
  // Wait for the "ready" signal or process exit
266
267
  const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
267
268
  let readySettled = false;
268
269
 
269
270
  // Process lines in background, intercepting the ready signal
270
- const lines = readJsonl(this.#process.stdout, this.#abortController.signal);
271
+ const lines = readJsonl(child.stdout, this.#abortController.signal);
271
272
  void (async () => {
272
273
  for await (const line of lines) {
273
274
  if (!readySettled && isRecord(line) && line.type === "ready") {
@@ -277,10 +278,16 @@ export class RpcClient {
277
278
  }
278
279
  this.#handleLine(line);
279
280
  }
280
- // Stream ended without ready signal — process exited
281
+ // Stream ended without the ready signal — the child exited or is
282
+ // exiting. Defer to the exit handler below: ptree resolves
283
+ // `exited` only after stderr is fully drained (nonzero exits), so
284
+ // rejecting here would snapshot a partial stderr tail and lose
285
+ // the actual startup error.
286
+ if (readySettled) return;
287
+ await child.exited.catch(() => {});
281
288
  if (!readySettled) {
282
289
  readySettled = true;
283
- readyReject(new Error(`Agent process exited before ready. Stderr: ${this.#process?.peekStderr() ?? ""}`));
290
+ readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`));
284
291
  }
285
292
  })().catch((err: Error) => {
286
293
  if (!readySettled) {
@@ -290,22 +297,26 @@ export class RpcClient {
290
297
  });
291
298
 
292
299
  // Also race against process exit (in case stdout closes before we read it)
293
- void this.#process.exited.then((exitCode: number) => {
294
- if (!readySettled) {
300
+ void child.exited.then(
301
+ (exitCode: number) => {
302
+ if (readySettled) return;
295
303
  readySettled = true;
296
- readyReject(
297
- new Error(`Agent process exited with code ${exitCode}. Stderr: ${this.#process?.peekStderr() ?? ""}`),
298
- );
299
- }
300
- });
304
+ readyReject(new Error(`Agent process exited with code ${exitCode}. Stderr: ${child.peekStderr()}`));
305
+ },
306
+ (err: Error) => {
307
+ // Killed or reaped without an exit code (e.g. stop() during
308
+ // startup); surface it instead of leaking an unhandled rejection.
309
+ if (readySettled) return;
310
+ readySettled = true;
311
+ readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`, { cause: err }));
312
+ },
313
+ );
301
314
 
302
315
  // Timeout to prevent hanging forever
303
316
  const readyTimeout = this.#startTimeout(30000, () => {
304
317
  if (readySettled) return;
305
318
  readySettled = true;
306
- readyReject(
307
- new Error(`Timeout waiting for agent to become ready. Stderr: ${this.#process?.peekStderr() ?? ""}`),
308
- );
319
+ readyReject(new Error(`Timeout waiting for agent to become ready. Stderr: ${child.peekStderr()}`));
309
320
  });
310
321
 
311
322
  try {
@@ -318,12 +329,14 @@ export class RpcClient {
318
329
  // state so the caller (or a retry via start() again) does not
319
330
  // leak the abandoned process (issue #4079).
320
331
  try {
321
- this.#process?.kill();
332
+ child.kill();
322
333
  } catch {
323
334
  // best-effort cleanup
324
335
  }
325
336
  this.#abortController.abort();
326
- this.#process = null;
337
+ if (this.#process === child) {
338
+ this.#process = null;
339
+ }
327
340
  throw err;
328
341
  } finally {
329
342
  clearTimeout(readyTimeout);
@@ -180,12 +180,16 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
180
180
  const mode = resolveMode();
181
181
 
182
182
  // Pre-scan: total code-point count (positions the band) and resolved palette.
183
+ // The per-segment string is kept verbatim — iterating UTF-16 units with a
184
+ // surrogate-pair guard produces the same code points as `Array.from(text)`
185
+ // at zero per-frame allocation (previously the #1 hotspot at ~10% of profiled
186
+ // CPU during streaming — the working message is shimmered every animation
187
+ // frame at 30fps and `Array.from` reallocated the code-point array each tick).
183
188
  let total = 0;
184
- const perSeg: { chars: string[]; palette: ShimmerPalette }[] = [];
189
+ const perSeg: { text: string; palette: ShimmerPalette }[] = [];
185
190
  for (const seg of segments) {
186
- const chars = Array.from(seg.text);
187
- total += chars.length;
188
- perSeg.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
191
+ total += countCodePoints(seg.text);
192
+ perSeg.push({ text: seg.text, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
189
193
  }
190
194
  if (total === 0) return "";
191
195
 
@@ -193,9 +197,9 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
193
197
  // tier so the working line stays legible without movement.
194
198
  if (mode === "disabled") {
195
199
  let out = "";
196
- for (const { chars, palette } of perSeg) {
200
+ for (const { text, palette } of perSeg) {
197
201
  const seq = compile(theme, palette).mid;
198
- out += `${seq.open}${chars.join("")}${seq.close}`;
202
+ out += `${seq.open}${text}${seq.close}`;
199
203
  }
200
204
  return out;
201
205
  }
@@ -205,31 +209,61 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
205
209
 
206
210
  let out = "";
207
211
  let index = 0;
208
- for (const { chars, palette } of perSeg) {
212
+ for (const { text, palette } of perSeg) {
209
213
  const compiled = compile(theme, palette);
210
214
  let runTier: Tier | null = null;
211
- let runBuf = "";
212
- for (let i = 0; i < chars.length; i++) {
215
+ let runStart = 0;
216
+ let runEnd = 0;
217
+ let i = 0;
218
+ while (i < text.length) {
219
+ // Detect a surrogate pair so a single code point (e.g. an emoji) stays
220
+ // atomic; the band position is measured in code points, not UTF-16 units.
221
+ const c = text.charCodeAt(i);
222
+ let step = 1;
223
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
224
+ const c2 = text.charCodeAt(i + 1);
225
+ if (c2 >= 0xdc00 && c2 <= 0xdfff) step = 2;
226
+ }
213
227
  const tier = tierFor(intensityFn(time, index, total));
214
228
  if (tier !== runTier) {
215
- if (runTier !== null) {
229
+ if (runTier !== null && runEnd > runStart) {
216
230
  const seq = compiled[runTier];
217
- out += `${seq.open}${runBuf}${seq.close}`;
218
- runBuf = "";
231
+ out += `${seq.open}${text.slice(runStart, runEnd)}${seq.close}`;
219
232
  }
220
233
  runTier = tier;
234
+ runStart = i;
221
235
  }
222
- runBuf += chars[i];
236
+ runEnd = i + step;
223
237
  index++;
238
+ i += step;
224
239
  }
225
- if (runTier !== null && runBuf.length > 0) {
240
+ if (runTier !== null && runEnd > runStart) {
226
241
  const seq = compiled[runTier];
227
- out += `${seq.open}${runBuf}${seq.close}`;
242
+ out += `${seq.open}${text.slice(runStart, runEnd)}${seq.close}`;
228
243
  }
229
244
  }
230
245
  return out;
231
246
  }
232
247
 
248
+ function countCodePoints(text: string): number {
249
+ let n = 0;
250
+ let i = 0;
251
+ while (i < text.length) {
252
+ const c = text.charCodeAt(i);
253
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
254
+ const c2 = text.charCodeAt(i + 1);
255
+ if (c2 >= 0xdc00 && c2 <= 0xdfff) {
256
+ i += 2;
257
+ n++;
258
+ continue;
259
+ }
260
+ }
261
+ i++;
262
+ n++;
263
+ }
264
+ return n;
265
+ }
266
+
233
267
  export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string {
234
268
  return shimmerSegments([{ text, palette }], theme);
235
269
  }
@@ -159,6 +159,8 @@ export type SymbolKey =
159
159
  | "md.hrChar"
160
160
  | "md.bullet"
161
161
  | "md.colorSwatch"
162
+ // Advisor note rail
163
+ | "advisor.rail"
162
164
  // Language/file type icons
163
165
  | "lang.default"
164
166
  | "lang.typescript"
@@ -364,6 +366,8 @@ const UNICODE_SYMBOLS: SymbolMap = {
364
366
  "md.hrChar": "─",
365
367
  "md.bullet": "•",
366
368
  "md.colorSwatch": "■",
369
+ // Advisor note rail (heavier than md.quoteBorder so notes read as a distinct voice)
370
+ "advisor.rail": "▎",
367
371
  // Language/file icons (emoji-centric, no Nerd Font required)
368
372
  "lang.default": "⌘",
369
373
  "lang.typescript": "🟦",
@@ -675,6 +679,8 @@ const NERD_SYMBOLS: SymbolMap = {
675
679
  "md.bullet": "\uf111",
676
680
  // pick: ■ | alt: (U+F096)
677
681
  "md.colorSwatch": "■",
682
+ // pick: ▎ | alt: ┃ │
683
+ "advisor.rail": "▎",
678
684
  // Language icons (nerd font devicons)
679
685
  "lang.default": "",
680
686
  "lang.typescript": "\u{E628}",
@@ -877,6 +883,7 @@ const ASCII_SYMBOLS: SymbolMap = {
877
883
  "md.hrChar": "-",
878
884
  "md.bullet": "*",
879
885
  "md.colorSwatch": "[]",
886
+ "advisor.rail": "|",
880
887
  // Language icons (ASCII uses abbreviations)
881
888
  "lang.default": "code",
882
889
  "lang.typescript": "ts",
@@ -969,6 +969,18 @@ interface ActiveAdvisor {
969
969
  agentUnsubscribe?: () => void;
970
970
  model: Model;
971
971
  thinkingLevel: ThinkingLevel;
972
+ /** Stable key for the resolved runtime inputs that require a rebuild to change. */
973
+ signature: string;
974
+ }
975
+
976
+ /** Resolved advisor config ready to instantiate as an {@link ActiveAdvisor}. */
977
+ interface AdvisorRuntimeDescriptor {
978
+ config: AdvisorConfig;
979
+ name: string;
980
+ slug: string;
981
+ model: Model;
982
+ thinkingLevel: ThinkingLevel;
983
+ signature: string;
972
984
  }
973
985
 
974
986
  export interface FreshSessionResult {
@@ -2268,32 +2280,10 @@ export class AgentSession {
2268
2280
  }
2269
2281
  }
2270
2282
 
2271
- #buildAdvisorRuntime(seedToCurrent = false): boolean {
2272
- if (this.#isDisposed) return false;
2273
- if (this.#advisors.length > 0) return true;
2274
- if (!this.#advisorEnabled) return false;
2275
- if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
2276
-
2277
- // The lone implicit "default" advisor (slug "") reproduces the legacy
2278
- // single-advisor path: the `advisor` role model + `__advisor.jsonl`.
2283
+ #resolveAdvisorRuntimeDescriptors(emitWarnings: boolean): AdvisorRuntimeDescriptor[] {
2279
2284
  const legacy = !this.#advisorConfigs?.length;
2280
2285
  const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
2281
-
2282
- // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
2283
- // on standard processing; "inherit" tracks the session's live per-family
2284
- // tiers per request (like the main agent, including /fast toggles); a
2285
- // concrete value is broadcast across families and applied to the advisor
2286
- // model's family. One value for all advisors.
2287
- const advisorTierSetting = this.settings.get("tier.advisor");
2288
- const advisorTierMap =
2289
- advisorTierSetting === "inherit"
2290
- ? undefined
2291
- : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
2292
- const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
2293
- advisorTierSetting === "inherit"
2294
- ? this.#effectiveServiceTier(model)
2295
- : resolveModelServiceTier(advisorTierMap, model);
2296
-
2286
+ const descriptors: AdvisorRuntimeDescriptor[] = [];
2297
2287
  const usedSlugs = new Set<string>();
2298
2288
  for (const config of roster) {
2299
2289
  let slug = legacy ? "" : slugifyAdvisorName(config.name);
@@ -2314,23 +2304,84 @@ export class AgentSession {
2314
2304
  model = resolved.model;
2315
2305
  thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
2316
2306
  if (!model) {
2317
- this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2307
+ if (emitWarnings) {
2308
+ this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2309
+ }
2318
2310
  continue;
2319
2311
  }
2320
2312
  } else {
2321
2313
  const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
2322
2314
  if (!sel) {
2323
- logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2324
- advisor: config.name,
2325
- });
2315
+ if (emitWarnings) {
2316
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2317
+ advisor: config.name,
2318
+ });
2319
+ }
2326
2320
  continue;
2327
2321
  }
2328
2322
  model = sel.model;
2329
2323
  thinkingLevel = concreteThinkingLevel(sel.thinkingLevel);
2330
2324
  }
2331
- const advisorModel = model;
2332
- const advisorName = config.name;
2333
2325
  const advisorThinkingLevel = thinkingLevel ?? ThinkingLevel.Medium;
2326
+ descriptors.push({
2327
+ config,
2328
+ name: config.name,
2329
+ slug,
2330
+ model,
2331
+ thinkingLevel: advisorThinkingLevel,
2332
+ signature: this.#advisorRuntimeSignature(config, slug, model, advisorThinkingLevel),
2333
+ });
2334
+ }
2335
+ return descriptors;
2336
+ }
2337
+
2338
+ #advisorRuntimeSignature(config: AdvisorConfig, slug: string, model: Model, thinkingLevel: ThinkingLevel): string {
2339
+ const tools = config.tools?.length ? config.tools.join("\u001e") : "";
2340
+ const instructions = config.instructions?.trim() ?? "";
2341
+ return [config.name, slug, model.provider, model.id, thinkingLevel, tools, instructions].join("\u001f");
2342
+ }
2343
+
2344
+ #advisorRuntimeMatchesCurrentConfig(): boolean {
2345
+ const descriptors = this.#resolveAdvisorRuntimeDescriptors(false);
2346
+ if (descriptors.length !== this.#advisors.length) return false;
2347
+ for (let i = 0; i < descriptors.length; i++) {
2348
+ if (descriptors[i].signature !== this.#advisors[i].signature) return false;
2349
+ }
2350
+ return true;
2351
+ }
2352
+
2353
+ #buildAdvisorRuntime(seedToCurrent = false): boolean {
2354
+ if (this.#isDisposed) return false;
2355
+ if (this.#advisors.length > 0) return true;
2356
+ if (!this.#advisorEnabled) return false;
2357
+ if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
2358
+
2359
+ const descriptors = this.#resolveAdvisorRuntimeDescriptors(true);
2360
+
2361
+ // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
2362
+ // on standard processing; "inherit" tracks the session's live per-family
2363
+ // tiers per request (like the main agent, including /fast toggles); a
2364
+ // concrete value is broadcast across families and applied to the advisor
2365
+ // model's family. One value for all advisors.
2366
+ const advisorTierSetting = this.settings.get("tier.advisor");
2367
+ const advisorTierMap =
2368
+ advisorTierSetting === "inherit"
2369
+ ? undefined
2370
+ : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
2371
+ const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
2372
+ advisorTierSetting === "inherit"
2373
+ ? this.#effectiveServiceTier(model)
2374
+ : resolveModelServiceTier(advisorTierMap, model);
2375
+
2376
+ for (const descriptor of descriptors) {
2377
+ const {
2378
+ config,
2379
+ slug,
2380
+ model: advisorModel,
2381
+ name: advisorName,
2382
+ thinkingLevel: advisorThinkingLevel,
2383
+ signature,
2384
+ } = descriptor;
2334
2385
 
2335
2386
  const emissionGuard = new AdvisorEmissionGuard();
2336
2387
  const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity));
@@ -2456,6 +2507,7 @@ export class AgentSession {
2456
2507
  recorderClosed: Promise.resolve(),
2457
2508
  model: advisorModel,
2458
2509
  thinkingLevel: advisorThinkingLevel,
2510
+ signature,
2459
2511
  };
2460
2512
  this.#attachAdvisorRecorderFeed(advisorRef);
2461
2513
  if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
@@ -2980,6 +3032,26 @@ export class AgentSession {
2980
3032
  manager?.cancelAll({ ownerId: this.#agentId });
2981
3033
  }
2982
3034
 
3035
+ /**
3036
+ * True when a background async job owned by this agent is still running with
3037
+ * an unsuppressed delivery, or a finished job's delivery is still queued or
3038
+ * in flight. Either way the async-result follow-up will re-wake the loop, so
3039
+ * a settle observed now is a scheduling pause rather than a terminal stop:
3040
+ * stop-time passes (todo reminder, session_stop hooks) defer to the settle
3041
+ * reached once the session is fully idle. Suppressed deliveries
3042
+ * (acknowledged, or watched by an in-flight `job` poll) never wake the loop,
3043
+ * so they don't count.
3044
+ */
3045
+ #hasPendingAsyncWake(): boolean {
3046
+ const manager = this.#asyncJobManager;
3047
+ if (!manager) return false;
3048
+ const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
3049
+ return (
3050
+ manager.getRunningJobs(ownerFilter).some(job => !manager.isDeliverySuppressed(job.id)) ||
3051
+ manager.hasPendingDeliveries(ownerFilter)
3052
+ );
3053
+ }
3054
+
2983
3055
  // =========================================================================
2984
3056
  // Event Subscription
2985
3057
  // =========================================================================
@@ -3894,6 +3966,15 @@ export class AgentSession {
3894
3966
  return;
3895
3967
  }
3896
3968
  }
3969
+ // A pending async wake means this settle is a scheduling pause, not
3970
+ // the terminal stop: the async-result delivery continues the loop and
3971
+ // the real stop settles later. Defer the session_stop hook pass until
3972
+ // the session is fully idle (the todo reminder above defers the same
3973
+ // way inside #checkTodoCompletion).
3974
+ if (this.#hasPendingAsyncWake()) {
3975
+ await emitAgentEndNotification();
3976
+ return;
3977
+ }
3897
3978
  await this.#emitSessionStopEvent(settledMessages, msg);
3898
3979
  await emitAgentEndNotification();
3899
3980
  }
@@ -10117,6 +10198,48 @@ export class AgentSession {
10117
10198
  }
10118
10199
  return COMPACTION_CHECK_NONE;
10119
10200
  }
10201
+ // A context promotion can land while the failing call is already in
10202
+ // flight (or on a run whose loop predates the switch): the overflow
10203
+ // error then arrives stamped with the pre-promotion model while
10204
+ // `this.model` is already the promoted target. The sameModel guard
10205
+ // above deliberately ignores stale foreign-model errors, but this
10206
+ // state is not stale — recover exactly like the promotion path:
10207
+ // drop the dead turn and retry on the already-promoted model. Gated
10208
+ // narrowly on "current model IS the failed model's promotion target
10209
+ // with a strictly larger window" so genuinely stale errors from
10210
+ // old user-switched models keep surfacing untouched.
10211
+ if (
10212
+ !sameModel &&
10213
+ autoContinue &&
10214
+ !errorIsFromBeforeCompaction &&
10215
+ assistantMessage.stopReason === "error" &&
10216
+ this.model &&
10217
+ contextWindow > 0 &&
10218
+ this.settings.getGroup("contextPromotion").enabled
10219
+ ) {
10220
+ const failedModel = this.#modelRegistry.find(assistantMessage.provider, assistantMessage.model);
10221
+ const failedWindow = failedModel?.contextWindow ?? 0;
10222
+ const promotionTarget = failedModel
10223
+ ? this.#resolveContextPromotionConfiguredTarget(failedModel, this.#modelRegistry.getAvailable())
10224
+ : undefined;
10225
+ if (
10226
+ failedModel &&
10227
+ failedWindow > 0 &&
10228
+ contextWindow > failedWindow &&
10229
+ promotionTarget &&
10230
+ modelsAreEqual(promotionTarget, this.model) &&
10231
+ AIError.isContextOverflow(assistantMessage, failedWindow)
10232
+ ) {
10233
+ this.#removeAssistantMessageFromActiveContext(assistantMessage);
10234
+ await this.#dropPersistedAssistantTurn(assistantMessage);
10235
+ logger.debug("Overflow on pre-promotion model; retrying on promoted model", {
10236
+ failed: `${assistantMessage.provider}/${assistantMessage.model}`,
10237
+ current: `${this.model.provider}/${this.model.id}`,
10238
+ });
10239
+ this.#scheduleAgentContinue({ delayMs: 100, generation });
10240
+ return COMPACTION_CHECK_CONTINUATION;
10241
+ }
10242
+ }
10120
10243
 
10121
10244
  // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
10122
10245
  // (and Codex) maps to stopReason === "length". The model burned its
@@ -10874,6 +10997,18 @@ export class AgentSession {
10874
10997
  return false;
10875
10998
  }
10876
10999
 
11000
+ // Background async jobs (bash/task) owned by this agent re-wake the loop
11001
+ // when they complete: the result delivery enqueues an async-result
11002
+ // follow-up that continues the run, and todos are re-evaluated at that
11003
+ // settle. A stop with such a job in flight is a scheduling pause, not
11004
+ // abandonment — stay silent instead of nagging.
11005
+ if (this.#hasPendingAsyncWake()) {
11006
+ logger.debug("Todo completion: async jobs in flight will re-wake the loop; skipping reminder", {
11007
+ incomplete: incomplete.length,
11008
+ });
11009
+ return false;
11010
+ }
11011
+
10877
11012
  // Build reminder message
10878
11013
  this.#todoReminderCount++;
10879
11014
  const todoList = incompleteByPhase
@@ -15301,6 +15436,7 @@ export class AgentSession {
15301
15436
  setAdvisorEnabled(enabled: boolean): boolean {
15302
15437
  this.#advisorEnabled = enabled;
15303
15438
  if (enabled) {
15439
+ if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime();
15304
15440
  return this.#buildAdvisorRuntime(true);
15305
15441
  }
15306
15442
  this.#stopAdvisorRuntime();
@@ -1,5 +1,10 @@
1
1
  import { toError } from "@oh-my-pi/pi-utils";
2
- import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
2
+ import type {
3
+ SessionStorage,
4
+ SessionStorageStat,
5
+ SessionStorageWriter,
6
+ WriteTextAtomicOptions,
7
+ } from "./session-storage";
3
8
  import {
4
9
  overlayTitleSlotContent,
5
10
  overlayTitleSlotPrefix,
@@ -234,8 +239,40 @@ export class IndexedSessionStorage implements SessionStorage {
234
239
  }
235
240
  }
236
241
 
237
- writeTextAtomic(path: string, content: string): Promise<void> {
238
- return this.writeText(path, content);
242
+ async writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
243
+ const commitGuard = options?.commitGuard;
244
+ if (commitGuard && !commitGuard()) return;
245
+ await this.#awaitPath(path);
246
+ // A concurrent flushSync (writeTextSync) may have taken over during the
247
+ // awaitPath yield and bumped the epoch. Re-check before touching the
248
+ // index or enqueueing the backend publish.
249
+ if (commitGuard && !commitGuard()) return;
250
+ const previous = this.#index.get(path);
251
+ const mtimeMs = this.#allocMtimeMs();
252
+ const title = titleUpdateFromSlot(parseTitleSlotFromContent(content));
253
+ this.#setIndex(path, byteLength(content), mtimeMs, title ?? null);
254
+ try {
255
+ await this.#enqueuePath(
256
+ path,
257
+ async () => {
258
+ // Final guard immediately before the backend actually publishes.
259
+ // If a concurrent writer has advanced the index past our
260
+ // optimistic entry, leave that newer state alone; otherwise
261
+ // restore the pre-write snapshot so readers do not observe a
262
+ // body we never wrote.
263
+ if (commitGuard && !commitGuard()) {
264
+ const current = this.#index.get(path);
265
+ if (current?.mtimeMs === mtimeMs) this.#restoreIndex(path, previous);
266
+ return;
267
+ }
268
+ await this.#backend.writeFull(path, content, mtimeMs, title);
269
+ },
270
+ { trackDrain: false },
271
+ );
272
+ } catch (err) {
273
+ this.#restoreIndex(path, previous);
274
+ throw toError(err);
275
+ }
239
276
  }
240
277
 
241
278
  async rename(src: string, dst: string): Promise<void> {