@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -13,6 +13,7 @@ import {
13
13
  formatToolResultErrorPreview,
14
14
  PRIMARY_CONTEXT_CUSTOM_TYPES,
15
15
  } from "../session/session-history-format";
16
+ import { ADVISOR_RENDER_OPTIONS, renderAdvisorDeltaChunks } from "./delta-split";
16
17
 
17
18
  /**
18
19
  * Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core
@@ -21,7 +22,7 @@ import {
21
22
  * this field after every prompt to detect a failed turn.
22
23
  */
23
24
  export interface AdvisorAgent {
24
- prompt(input: string): Promise<void>;
25
+ prompt(input: string | AgentMessage[]): Promise<void>;
25
26
  abort(reason?: unknown): void;
26
27
  reset(): void;
27
28
  /**
@@ -232,13 +233,6 @@ const MAX_COALESCE_ROUNDS = 3;
232
233
  */
233
234
  const MAX_QUARANTINE_RETRIES = 2;
234
235
 
235
- const ADVISOR_RENDER_OPTIONS = {
236
- includeToolIntent: true,
237
- watchedRoles: true,
238
- expandPrimaryContext: true,
239
- expandEditDiffs: true,
240
- } as const;
241
-
242
236
  interface PendingDelta {
243
237
  text: string;
244
238
  rawMessages: AgentMessage[];
@@ -262,9 +256,38 @@ interface DeliveredMessage {
262
256
 
263
257
  function fingerprintMessage(message: AgentMessage): bigint | undefined {
264
258
  try {
265
- const serialized = JSON.stringify(message);
266
- if (serialized === undefined) return undefined;
267
- return Bun.hash.wyhash(serialized);
259
+ // Field-selective fingerprint: hash every top-level field the advisor
260
+ // renderer actually reads (mirrors AppendOnlyContextManager.#messageDigest,
261
+ // issue #3406). Unrendered metadata (timestamp, usage, provider internals)
262
+ // churns on provider round-trips and would otherwise trigger a full
263
+ // transcript replay for a no-op change. Rendered fields (from
264
+ // session-history-format.ts): role, content, customType, display, isError,
265
+ // toolResult: cancelled/exitCode/output, custom: details, plus the
266
+ // execution/branch/compaction/file-mention fields the formatter reads:
267
+ // excludeFromContext, command (bashExecution), code (pythonExecution),
268
+ // summary + fromId (branch/compaction), files (fileMention).
269
+ const m = message as unknown as Record<string, unknown>;
270
+ const payload = JSON.stringify({
271
+ r: m.role ?? null,
272
+ c: m.content ?? null,
273
+ toolCallId: m.toolCallId ?? null,
274
+ toolName: m.toolName ?? null,
275
+ err: m.isError ?? null,
276
+ ct: m.customType ?? null,
277
+ disp: m.display ?? null,
278
+ cancel: m.cancelled ?? null,
279
+ exit: m.exitCode ?? null,
280
+ out: m.output ?? null,
281
+ det: m.details ?? null,
282
+ xfc: m.excludeFromContext ?? null,
283
+ cmd: m.command ?? null,
284
+ code: m.code ?? null,
285
+ sum: m.summary ?? null,
286
+ from: m.fromId ?? null,
287
+ files: m.files ?? null,
288
+ });
289
+ if (payload === undefined) return undefined;
290
+ return Bun.hash.wyhash(payload);
268
291
  } catch {
269
292
  return undefined;
270
293
  }
@@ -281,8 +304,17 @@ export class AdvisorRuntime {
281
304
  * approved plan). These prompts are re-injected verbatim every primary turn;
282
305
  * this lets {@link #renderDelta} collapse an unchanged copy to a one-line
283
306
  * marker so the advisor isn't re-fed the full ~1k-token rules each turn.
284
- * Cleared on every re-prime/seed and when a failed batch is dropped. */
307
+ /** Cleared on every re-prime/seed and when a failed batch is dropped. */
285
308
  #seenContext = new Map<string, string>();
309
+ /**
310
+ * Snapshot of {@link #seenContext} taken by #prepareBatch before the
311
+ * in-flight batch's first dedup mutation. Restored by
312
+ * {@link #rollbackFailedTurn} when the turn fails and its rawMessages are
313
+ * requeued, so first-time primary-context is re-delivered in full instead
314
+ * of collapsing to "(unchanged — still in effect)" against an advisor
315
+ * history that no longer contains it. Cleared on turn success.
316
+ */
317
+ #seenContextInFlight: [string, string][] | undefined;
286
318
  /** Incremented whenever the advisor loses context so queued raw deltas are re-rendered against fresh dedupe state. */
287
319
  #renderRevision = 0;
288
320
  /** Regex secret values observed in primary deltas and retained until advisor context resets. */
@@ -462,6 +494,7 @@ export class AdvisorRuntime {
462
494
 
463
495
  #clearSeenContext(): void {
464
496
  this.#seenContext.clear();
497
+ this.#seenContextInFlight = undefined;
465
498
  this.#advisorRegexSecretValues.clear();
466
499
  this.#renderRevision++;
467
500
  }
@@ -477,7 +510,15 @@ export class AdvisorRuntime {
477
510
  } catch {}
478
511
  }
479
512
 
480
- #resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void {
513
+ #resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean, reason?: string): void {
514
+ if (reason) {
515
+ logger.debug("advisor context reset", {
516
+ reason,
517
+ lastCount: this.#lastCount,
518
+ pending: this.#pending.length,
519
+ backlog: this.#backlog,
520
+ });
521
+ }
481
522
  this.#lastCount = 0;
482
523
  this.#deliveredPrefix = [];
483
524
  this.#pending = [];
@@ -541,7 +582,11 @@ export class AdvisorRuntime {
541
582
  * post-compaction — transcript, giving the advisor fresh context instead of
542
583
  * leaving it blind to everything before the rewrite.
543
584
  */
544
- reset(): void {
585
+ reset(reason = "external"): void {
586
+ // Step-1 observability (issue #7226): every re-prime logs its trigger so
587
+ // live investigations can attribute full-transcript replays (cached_tokens
588
+ // pinned at the instructions/tools boundary) to a concrete path instead of
589
+ // inferring it from payload markers after the fact.
545
590
  this.#iterationAbort?.abort("advisor reset");
546
591
  this.#epoch++;
547
592
  this.#sessionTransitionPaused = false;
@@ -552,7 +597,7 @@ export class AdvisorRuntime {
552
597
  this.#consecutiveQuarantines = 0;
553
598
  this.#refusalModelsTried.clear();
554
599
  this.#failureNotified = false;
555
- this.#resetAdvisorContext(true, true);
600
+ this.#resetAdvisorContext(true, true, reason);
556
601
  }
557
602
 
558
603
  /**
@@ -584,10 +629,145 @@ export class AdvisorRuntime {
584
629
  this.#includeThinking = true;
585
630
  }
586
631
 
587
- #formatRawDelta(rawMessages: AgentMessage[], wip = false): string | null {
632
+ // Candidate 4 (multi-message split): render the Session update as MULTIPLE
633
+ // user messages — one per source message — instead of one ever-growing user
634
+ // message. Provider prompt caches are prefix-based: a single user message
635
+ // whose text keeps growing invalidates the whole message on every turn, so
636
+ // cache_read stays pinned at the instructions/tools boundary (observed
637
+ // 14491 in production, 11066 in tests). Splitting into per-source user
638
+ // messages lets the provider cache each appended message (verified
639
+ // experimentally: cache_read 11066 → 11091 → 11112 vs pinned 11066).
640
+ //
641
+ /**
642
+ * Shared obfuscation side effects for BOTH render paths (single-block
643
+ * {@link #renderPreparedDelta} and multi-message
644
+ * {@link #formatRawDeltaMessageChunks}): collect regex secret values from
645
+ * primary-context custom messages and the rendered markdown, scrub the
646
+ * advisor's own history, and refresh pending placeholder prefixes when new
647
+ * secrets appear. Returns whether new secret values were discovered.
648
+ * Idempotent across the two calls one drain makes for the same prepared
649
+ * list: the second call discovers nothing new and skips the strip.
650
+ */
651
+ #collectAdvisorSecrets(obfuscator: SecretObfuscator, delta: AgentMessage[], renderedMd: string): boolean {
652
+ let discoveredNewRegexSecretValue = false;
653
+ const addRegexValues = (text: string): void => {
654
+ for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(text) ?? []) {
655
+ if (this.#advisorRegexSecretValues.has(secretValue)) continue;
656
+ this.#advisorRegexSecretValues.add(secretValue);
657
+ discoveredNewRegexSecretValue = true;
658
+ }
659
+ };
660
+ for (const message of delta) {
661
+ if (
662
+ message.role === "custom" &&
663
+ PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) &&
664
+ typeof message.content === "string"
665
+ ) {
666
+ addRegexValues(message.content);
667
+ }
668
+ }
669
+ addRegexValues(renderedMd);
670
+ scrubAdvisorHistory(obfuscator, this.agent.state.messages, this.#advisorRegexSecretValues);
671
+ if (discoveredNewRegexSecretValue) {
672
+ this.#pending = this.#pending.map(delta => ({
673
+ ...delta,
674
+ text: obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(delta.text, this.#advisorRegexSecretValues),
675
+ }));
676
+ }
677
+ return discoveredNewRegexSecretValue;
678
+ }
679
+
680
+ /**
681
+ * Map primary-context custom messages through the obfuscator. Shared by
682
+ * both render paths so the byte-equivalence contract lives in one place.
683
+ */
684
+ #obfuscatePrimaryContextMessages(obfuscator: SecretObfuscator, delta: AgentMessage[]): AgentMessage[] {
685
+ return delta.map(message =>
686
+ message.role === "custom" && PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType)
687
+ ? obfuscateAdvisorMessage(obfuscator, message, this.#advisorRegexSecretValues)
688
+ : message,
689
+ );
690
+ }
691
+
692
+ // Each source message is rendered INDEPENDENTLY via
693
+ // formatSessionHistoryMarkdown in chunked mode (shared toolResultIndex +
694
+ // consumedToolCallIds over the WHOLE delta), so a toolCall finds its
695
+ // toolResult across chunk boundaries and consecutive same-role collapsing
696
+ // is preserved. Concatenating the chunk texts with the same separator the
697
+ // old single-block render used yields byte-identical advisor context.
698
+ // Each chunk is delivered as its own user AgentMessage via a SINGLE
699
+ // Agent.prompt(AgentMessage[]) call, so the advisor model still runs ONCE
700
+ // per update (no per-message assistant turns).
701
+ #formatRawDeltaMessageChunks(preparedMessages: AgentMessage[], wip = false): AgentMessage[] | null {
702
+ // Consumes the ALREADY-prepared view from #prepareBatch: advisor custom
703
+ // messages are filtered and primary-context dedup is applied there, so
704
+ // splitting here never double-folds or leaks hidden messages.
705
+ const delta = preparedMessages;
706
+ if (delta.length === 0) return null;
707
+
708
+ const obfuscator = this.host.obfuscator;
709
+ // Side effects the pure renderer cannot own: collect secrets, scrub the
710
+ // advisor's own history and refresh pending placeholder prefixes (shared
711
+ // helper — see #collectAdvisorSecrets; idempotent for this drain's
712
+ // single-block pass over the same prepared list).
713
+ const probeMd = formatSessionHistoryMarkdown(delta, {
714
+ ...ADVISOR_RENDER_OPTIONS,
715
+ includeThinking: this.#includeThinking,
716
+ });
717
+ if (obfuscator?.hasSecrets()) {
718
+ this.#collectAdvisorSecrets(obfuscator, delta, probeMd);
719
+ }
720
+
721
+ // Message-level obfuscation mirrors the old #formatRawDelta path EXACTLY:
722
+ // only primary-context custom messages are mapped (tool args, details.diff,
723
+ // structured fields), because the old path's contract is whole-delta text
724
+ // obfuscation as the final pass. Expanding to every role would mint
725
+ // different placeholders and break byte-equivalence with the old render.
726
+ const renderDelta = obfuscator?.hasSecrets() ? this.#obfuscatePrimaryContextMessages(obfuscator, delta) : delta;
727
+
728
+ const chunks = renderAdvisorDeltaChunks(renderDelta, {
729
+ wip,
730
+ includeThinking: this.#includeThinking,
731
+ obfuscator: obfuscator?.hasSecrets() ? obfuscator : undefined,
732
+ advisorRegexSecretValues: this.#advisorRegexSecretValues,
733
+ });
734
+ return chunks;
735
+ }
736
+
737
+ #formatRawDelta(rawMessages: AgentMessage[], wip = false, updateSeenContext = true): string | null {
588
738
  const delta = rawMessages
589
739
  .filter(message => !(message.role === "custom" && message.customType === "advisor"))
590
- .map(message => this.#dedupContextMessage(message));
740
+ .map(message =>
741
+ updateSeenContext ? this.#dedupContextMessage(message) : this.#dedupContextMessageReadOnly(message),
742
+ );
743
+ return this.#renderPreparedDelta(delta, wip);
744
+ }
745
+
746
+ /**
747
+ * Preview variant of #dedupContextMessage: returns the collapse decision
748
+ * WITHOUT advancing the live #seenContext map. Used by #renderDelta so the
749
+ * preview text does not make the batch's first real delivery look like a
750
+ * re-injection.
751
+ */
752
+ #dedupContextMessageReadOnly(msg: AgentMessage): AgentMessage {
753
+ if (msg.role !== "custom") return msg;
754
+ if (!PRIMARY_CONTEXT_CUSTOM_TYPES.has(msg.customType)) return msg;
755
+ if (typeof msg.content !== "string") return msg;
756
+ if (this.#seenContext.get(msg.customType) === msg.content) {
757
+ return { ...msg, content: "(unchanged — still in effect)" };
758
+ }
759
+ return msg;
760
+ }
761
+
762
+ /**
763
+ * Render already-prepared (deduped + advisor-filtered) messages to the
764
+ * single-block Session update text. Does NOT dedup again — callers that
765
+ * prepared the list must pass it here directly, and callers that prepared
766
+ * via #prepareBatch get byte-identical batch text to what the multi-message
767
+ * split consumes.
768
+ */
769
+ #renderPreparedDelta(preparedMessages: AgentMessage[], wip = false): string | null {
770
+ const delta = preparedMessages;
591
771
  if (delta.length === 0) return null;
592
772
  const obfuscator = this.host.obfuscator;
593
773
  let md = formatSessionHistoryMarkdown(delta, {
@@ -596,43 +776,22 @@ export class AdvisorRuntime {
596
776
  });
597
777
  if (!md.trim()) return null;
598
778
  if (obfuscator?.hasSecrets()) {
599
- let discoveredNewRegexSecretValue = false;
600
- const addRegexValues = (text: string): void => {
601
- for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(text)) {
602
- if (this.#advisorRegexSecretValues.has(secretValue)) continue;
603
- this.#advisorRegexSecretValues.add(secretValue);
604
- discoveredNewRegexSecretValue = true;
605
- }
606
- };
607
- for (const message of delta) {
608
- if (
609
- message.role === "custom" &&
610
- PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) &&
611
- typeof message.content === "string"
612
- ) {
613
- addRegexValues(message.content);
614
- }
615
- }
616
- addRegexValues(md);
617
- scrubAdvisorHistory(obfuscator, this.agent.state.messages, this.#advisorRegexSecretValues);
618
- if (discoveredNewRegexSecretValue) {
619
- this.#pending = this.#pending.map(delta => ({
620
- ...delta,
621
- text: obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(delta.text, this.#advisorRegexSecretValues),
622
- }));
623
- }
624
- md = formatSessionHistoryMarkdown(
625
- delta.map(message =>
626
- message.role === "custom" && PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType)
627
- ? obfuscateAdvisorMessage(obfuscator, message, this.#advisorRegexSecretValues)
628
- : message,
629
- ),
630
- { ...ADVISOR_RENDER_OPTIONS, includeThinking: this.#includeThinking },
631
- );
779
+ this.#collectAdvisorSecrets(obfuscator, delta, md);
780
+ md = formatSessionHistoryMarkdown(this.#obfuscatePrimaryContextMessages(obfuscator, delta), {
781
+ ...ADVISOR_RENDER_OPTIONS,
782
+ includeThinking: this.#includeThinking,
783
+ });
632
784
  md = obfuscator.obfuscate(md, this.#advisorRegexSecretValues);
633
785
  }
634
- const heading = wip ? "### Session update [in progress more steps follow]" : "### Session update";
635
- return `${heading}\n\n${md}`;
786
+ // Candidate 3: keep the heading byte-identical between wip and final turns
787
+ // and put the WIP marker at the END of the batch, so a wip/final flip
788
+ // never changes the batch prefix. The provider prompt cache is
789
+ // prefix-based; a heading that flips between turns re-prefills the whole
790
+ // user message on every in-progress turn.
791
+ const heading = "### Session update";
792
+ const mdHead = `${heading}\n\n${md}`;
793
+ if (!wip) return mdHead;
794
+ return `${mdHead}\n\n---\n\n[in progress — more steps follow]`;
636
795
  }
637
796
 
638
797
  #renderDelta(messages?: AgentMessage[], wip = false): Omit<PendingDelta, "turns" | "overflowRecovery"> | null {
@@ -653,12 +812,26 @@ export class AdvisorRuntime {
653
812
  delivered.fingerprint !== fingerprint
654
813
  ) {
655
814
  prefixChanged = true;
815
+ // Full replays are expensive (the whole transcript is re-sent and
816
+ // the provider prompt cache re-prefills from the system prompt), so
817
+ // record exactly which delivered message diverged and which
818
+ // top-level fields changed — without this the trigger is invisible.
819
+ try {
820
+ const oldMsg: Record<string, unknown> = delivered.message as unknown as Record<string, unknown>;
821
+ const newMsg: Record<string, unknown> = current as unknown as Record<string, unknown>;
822
+ const differingFields: string[] = [];
823
+ for (const key of new Set([...Object.keys(oldMsg), ...Object.keys(newMsg)])) {
824
+ if (JSON.stringify(oldMsg[key]) !== JSON.stringify(newMsg[key])) differingFields.push(key);
825
+ }
826
+ logger.debug("advisor delivered prefix changed", { index: i, role: newMsg.role, differingFields });
827
+ } catch {}
656
828
  break;
657
829
  }
658
830
  delivered.message = current;
659
831
  }
660
832
  if (prefixChanged) {
661
833
  this.#epoch++;
834
+ logger.debug("advisor context reset", { reason: "delivered-prefix-changed", lastCount: this.#lastCount });
662
835
  this.#resetAdvisorContext(true, true);
663
836
  }
664
837
  const rawMessages = all.slice(this.#lastCount);
@@ -668,7 +841,11 @@ export class AdvisorRuntime {
668
841
  this.#deliveredPrefix.push({ message, fingerprint: fingerprintMessage(message) });
669
842
  }
670
843
  this.#lastCount = all.length;
671
- const text = this.#formatRawDelta(rawMessages, wip);
844
+ // Preview render: do NOT advance #seenContext — the batch's real dedup
845
+ // happens once in #prepareBatch. Advancing here would make the first
846
+ // real delivery of a re-injected primary-context message collapse to
847
+ // "(unchanged…)" (double-fold).
848
+ const text = this.#formatRawDelta(rawMessages, wip, false);
672
849
  return text ? { text, rawMessages, renderRevision: this.#renderRevision, wip } : null;
673
850
  }
674
851
 
@@ -715,7 +892,19 @@ export class AdvisorRuntime {
715
892
  * append-only context); falls back to truncating `state.messages` for tests
716
893
  * that hand-roll a minimal facade.
717
894
  */
895
+ #restoreSeenContextInFlight(): void {
896
+ if (!this.#seenContextInFlight) return;
897
+ this.#seenContext.clear();
898
+ for (const [key, value] of this.#seenContextInFlight) this.#seenContext.set(key, value);
899
+ this.#seenContextInFlight = undefined;
900
+ }
901
+
718
902
  #rollbackFailedTurn(snapshot: number): void {
903
+ // Restore the primary-context dedup map to its pre-batch state: the
904
+ // failed turn never reached the advisor, so first-time context collapsed
905
+ // to "(unchanged…)" by this batch's #prepareBatch must expand again on
906
+ // the retry/requeue pass.
907
+ this.#restoreSeenContextInFlight();
719
908
  const messages = this.agent.state.messages;
720
909
  if (messages.length <= snapshot) return;
721
910
  try {
@@ -757,6 +946,7 @@ export class AdvisorRuntime {
757
946
  ): Promise<{
758
947
  batch: string | null;
759
948
  rawMessages: AgentMessage[];
949
+ preparedMessages: AgentMessage[];
760
950
  finalTurns: number;
761
951
  wip: boolean;
762
952
  resetContext: boolean;
@@ -803,11 +993,18 @@ export class AdvisorRuntime {
803
993
  // waiters, latest snapshot, and epoch stay untouched. Re-render only
804
994
  // this already-popped raw batch so active plan/reference bodies are
805
995
  // restored without replaying any older primary transcript.
996
+ logger.debug("advisor context reset", {
997
+ reason: "context-maintenance",
998
+ lastCount: this.#lastCount,
999
+ pending: this.#pending.length,
1000
+ backlog: this.#backlog,
1001
+ });
806
1002
  this.#clearAdvisorContextAtCurrentCursor();
807
- const rerendered = this.#formatRawDelta(rawMessages, wip);
1003
+ const { batch: rerendered, preparedMessages } = this.#prepareBatch(rawMessages, wip, batchText);
808
1004
  return {
809
1005
  batch: rerendered ?? (batchText || null),
810
1006
  rawMessages,
1007
+ preparedMessages,
811
1008
  finalTurns: turns,
812
1009
  wip,
813
1010
  resetContext: true,
@@ -836,11 +1033,47 @@ export class AdvisorRuntime {
836
1033
  wip = late.at(-1)!.wip;
837
1034
  }
838
1035
 
839
- const batchObfuscator = this.host.obfuscator;
840
- if (batchObfuscator?.hasSecrets()) {
841
- batchText = batchObfuscator.stripUnsafeFriendlyPlaceholderPrefixes(batchText, this.#advisorRegexSecretValues);
842
- }
843
- return { batch: batchText || null, rawMessages, finalTurns: turns, wip, resetContext: false };
1036
+ // Prepare the deduped view AFTER coalescing (rawMessages is complete by
1037
+ // now): filters advisor custom messages and collapses re-injected
1038
+ // primary-context to "(unchanged…)". BOTH the single-block text and the
1039
+ // multi-message split derive from this exact list so they never diverge.
1040
+ const { batch: preparedBatch, preparedMessages } = this.#prepareBatch(rawMessages, wip, batchText);
1041
+ return {
1042
+ batch: preparedBatch ?? (batchText || null),
1043
+ rawMessages,
1044
+ preparedMessages,
1045
+ finalTurns: turns,
1046
+ wip,
1047
+ resetContext: false,
1048
+ };
1049
+ }
1050
+
1051
+ /**
1052
+ * Single dedup+render pass shared by every batch finalization path (normal
1053
+ * and context-reset). Filters advisor custom messages, collapses re-injected
1054
+ * primary-context to "(unchanged…)" via #dedupContextMessage, renders the
1055
+ * single-block batch text from the SAME prepared list the multi-message
1056
+ * split consumes, so the two views can never diverge.
1057
+ */
1058
+ #prepareBatch(
1059
+ rawMessages: AgentMessage[],
1060
+ wip: boolean,
1061
+ fallback: string | null,
1062
+ ): { batch: string | null; preparedMessages: AgentMessage[] } {
1063
+ // Dedup against the LIVE #seenContext (populated by previous turns via
1064
+ // #renderDelta -> #formatRawDelta) so re-injected primary context that
1065
+ // was ALREADY shown collapses to "(unchanged…)", while a FIRST delivery
1066
+ // in this batch stays expanded. This pass advances the live map exactly
1067
+ // once per batch — #renderDelta's text is a preview and must not set it.
1068
+ // Snapshot the dedup map BEFORE this batch's first mutation so a failed
1069
+ // turn can restore it (see #rollbackFailedTurn). `??=` keeps the first
1070
+ // snapshot across coalescing re-prepares within one in-flight batch.
1071
+ this.#seenContextInFlight ??= [...this.#seenContext];
1072
+ const preparedMessages = rawMessages
1073
+ .filter(message => !(message.role === "custom" && message.customType === "advisor"))
1074
+ .map(message => this.#dedupContextMessage(message));
1075
+ const batch = this.#renderPreparedDelta(preparedMessages, wip);
1076
+ return { batch: batch ?? fallback, preparedMessages };
844
1077
  }
845
1078
 
846
1079
  #terminalAssistantFailure(snapshot: number): AssistantMessage | undefined {
@@ -882,8 +1115,10 @@ export class AdvisorRuntime {
882
1115
  const epoch = this.#epoch;
883
1116
  for (const delta of popped) {
884
1117
  if (delta.renderRevision === this.#renderRevision) continue;
885
- const refreshed = this.#formatRawDelta(delta.rawMessages, delta.wip);
886
- if (refreshed) delta.text = refreshed;
1118
+ // Context maintenance estimates this preview before #prepareBatch makes
1119
+ // its final deduped render. Rebuild stale text against the new context
1120
+ // so the maintenance budget cannot undercount an expanded re-injection.
1121
+ delta.text = this.#formatRawDelta(delta.rawMessages, delta.wip, false) ?? delta.text;
887
1122
  delta.renderRevision = this.#renderRevision;
888
1123
  }
889
1124
  const recoveringOverflow = popped.some(delta => delta.overflowRecovery === true);
@@ -897,11 +1132,12 @@ export class AdvisorRuntime {
897
1132
  // Epoch was invalidated during batch collection; restart the loop.
898
1133
  if (result === null) continue;
899
1134
  if (this.#sessionTransitionPaused) {
1135
+ this.#restoreSeenContextInFlight();
900
1136
  this.#pending.unshift(...popped);
901
1137
  continue;
902
1138
  }
903
1139
 
904
- const { batch, rawMessages, finalTurns, wip, resetContext } = result;
1140
+ const { batch, rawMessages, preparedMessages, finalTurns, wip, resetContext } = result;
905
1141
 
906
1142
  if (this.disposed || batch === null) {
907
1143
  this.#backlog = Math.max(0, this.#backlog - finalTurns);
@@ -918,10 +1154,18 @@ export class AdvisorRuntime {
918
1154
  const messageSnapshot = this.agent.state.messages.length;
919
1155
  const contextWasFresh = resetContext || recoveringOverflow || messageSnapshot === 0;
920
1156
  try {
921
- // Reset the host's per-update advisor state (one-advise-per-update
922
- // gate) and pass through whether this batch reviews partial work.
923
1157
  this.host.beginAdvisorUpdate?.(wip);
924
- const prompt = this.agent.prompt(batch);
1158
+ // Candidate 4 (multi-message split): deliver the Session update as
1159
+ // multiple user messages so the provider prompt cache can
1160
+ // incrementally hit each appended message (cache_read grows with
1161
+ // the session instead of staying pinned at the instructions/tools
1162
+ // boundary). Falls back to the single-block string when the chunk
1163
+ // renderer cannot split (e.g. empty delta). The split is
1164
+ // byte-equivalent to the old single-block render (equivalence
1165
+ // tested), so the advisor sees identical context.
1166
+ const splitMessages = this.#formatRawDeltaMessageChunks(preparedMessages, wip);
1167
+ const promptInput: string | AgentMessage[] = splitMessages ?? batch;
1168
+ const prompt = this.agent.prompt(promptInput);
925
1169
  this.#promptInFlight = prompt;
926
1170
  try {
927
1171
  await prompt;
@@ -941,6 +1185,7 @@ export class AdvisorRuntime {
941
1185
  const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot));
942
1186
  if (turnError) throw turnError;
943
1187
  success = true;
1188
+ this.#seenContextInFlight = undefined;
944
1189
  this.#failing = false;
945
1190
  this.#consecutiveFailures = 0;
946
1191
  this.#failureNotified = false;
@@ -994,7 +1239,11 @@ export class AdvisorRuntime {
994
1239
  if (classifierRefusal) {
995
1240
  if (this.#includeThinking) {
996
1241
  this.#includeThinking = false;
997
- const strippedBatch = this.#formatRawDelta(rawMessages, wip);
1242
+ // Do NOT advance #seenContext here: the requeued batch is
1243
+ // re-deduped by #prepareBatch on the next drain, so a mutation
1244
+ // now would double-fold first-time primary context into
1245
+ // "(unchanged — still in effect)" on the retry.
1246
+ const strippedBatch = this.#formatRawDelta(rawMessages, wip, false);
998
1247
  if (strippedBatch) {
999
1248
  this.#pending.unshift({
1000
1249
  text: strippedBatch,
@@ -1083,13 +1332,13 @@ export class AdvisorRuntime {
1083
1332
  if (this.#consecutiveQuarantines >= MAX_QUARANTINE_RETRIES) {
1084
1333
  this.#notifyFailureOnce(err);
1085
1334
  this.#consecutiveQuarantines = 0;
1086
- this.#resetAdvisorContext(true, true);
1335
+ this.#resetAdvisorContext(true, true, "quarantine-retry-exhausted");
1087
1336
  continue;
1088
1337
  }
1089
1338
  const rePrime = this.#pending.length > 0 ? this.#latestMessages : undefined;
1090
1339
  // Wake catchup waiters only when nothing is re-primed; otherwise the
1091
1340
  // re-primed turn restores the backlog and waiters resolve on its completion.
1092
- this.#resetAdvisorContext(true, !rePrime);
1341
+ this.#resetAdvisorContext(true, !rePrime, "quarantine-recovery");
1093
1342
  if (rePrime) this.onTurnEnd(rePrime);
1094
1343
  continue;
1095
1344
  }
@@ -1155,7 +1404,10 @@ export class AdvisorRuntime {
1155
1404
  } else {
1156
1405
  // Retry once against the fresh advisor context, using only the same
1157
1406
  // bounded raw batch. Pending updates remain queued behind it.
1158
- const recoveryBatch = this.#formatRawDelta(rawMessages, wip) ?? batch;
1407
+ // Same double-fold guard as the refusal branch: #prepareBatch
1408
+ // re-dedups on retry, so this preview render must not mutate
1409
+ // #seenContext.
1410
+ const recoveryBatch = this.#formatRawDelta(rawMessages, wip, false) ?? batch;
1159
1411
  this.#pending.unshift({
1160
1412
  text: recoveryBatch,
1161
1413
  rawMessages,
@@ -5,6 +5,8 @@ const DELIVERY_RETRY_MAX_MS = 30_000;
5
5
  const DELIVERY_RETRY_JITTER_MS = 200;
6
6
  const DEFAULT_RETENTION_MS = 5 * 60 * 1000;
7
7
  const DEFAULT_MAX_RUNNING_JOBS = 15;
8
+ /** Abort reason used only when the owning session shuts down the entire manager. */
9
+ export const ASYNC_JOB_MANAGER_SHUTDOWN_REASON = Symbol("AsyncJobManager shutdown");
8
10
 
9
11
  /**
10
12
  * Adaptive ("smart") `hub` poll-wait ladder (ms). A tight poll loop climbs
@@ -414,11 +416,20 @@ export class AsyncJobManager {
414
416
  * Cancel running jobs. With `filter.ownerId` set, cancels only jobs the
415
417
  * matching agent registered; with no filter, cancels every running job
416
418
  * (used by `dispose()` to nuke the manager's state).
419
+ *
420
+ * `reason` is forwarded to each job's `AbortController.abort`, so a session
421
+ * teardown can tag its owned jobs with {@link ASYNC_JOB_MANAGER_SHUTDOWN_REASON}
422
+ * before `dispose()` runs — the task executor reads it to park (not
423
+ * tombstone) a subagent interrupted purely by process shutdown.
417
424
  */
418
- cancelAll(filter?: AsyncJobFilter): void {
425
+ cancelAll(filter?: AsyncJobFilter, reason?: unknown): void {
426
+ this.#cancelJobs(filter, reason);
427
+ }
428
+
429
+ #cancelJobs(filter?: AsyncJobFilter, reason?: unknown): void {
419
430
  for (const job of this.getRunningJobs(filter)) {
420
431
  job.status = "cancelled";
421
- job.abortController.abort();
432
+ job.abortController.abort(reason);
422
433
  this.#scheduleEviction(job.id);
423
434
  }
424
435
  }
@@ -584,7 +595,7 @@ export class AsyncJobManager {
584
595
  async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
585
596
  this.#disposed = true;
586
597
  this.#clearEvictionTimers();
587
- this.cancelAll();
598
+ this.#cancelJobs(undefined, ASYNC_JOB_MANAGER_SHUTDOWN_REASON);
588
599
  const timeoutMs = Math.max(options?.timeoutMs ?? 3_000, 0);
589
600
  const deadline = Date.now() + timeoutMs;
590
601
  const jobsSettled = await this.#waitForAllUntil(deadline);
@@ -464,7 +464,7 @@ async function handleInstall(
464
464
  async function handleUninstall(
465
465
  manager: PluginManager,
466
466
  packages: string[],
467
- flags: { json?: boolean; scope?: "user" | "project" },
467
+ flags: { json?: boolean; dryRun?: boolean; scope?: "user" | "project" },
468
468
  ): Promise<void> {
469
469
  if (packages.length === 0) {
470
470
  console.error(chalk.red(`Usage: ${APP_NAME} plugin uninstall <package> ...`));
@@ -477,7 +477,35 @@ async function handleUninstall(
477
477
  const installedPlugins = new Set((await mktMgr.listInstalledPlugins()).map(p => p.id));
478
478
 
479
479
  for (const name of packages) {
480
- if (installedPlugins.has(name)) {
480
+ const viaMarketplace = installedPlugins.has(name);
481
+
482
+ if (flags.dryRun) {
483
+ if (viaMarketplace) {
484
+ try {
485
+ await mktMgr.uninstallPlugin(name, flags.scope, { dryRun: true });
486
+ } catch (err) {
487
+ console.error(chalk.red(`${theme.status.error} Failed to uninstall ${name}: ${err}`));
488
+ process.exit(1);
489
+ }
490
+ }
491
+
492
+ // Marketplace dry-runs validate the requested scope before reporting.
493
+ if (flags.json) {
494
+ console.log(
495
+ JSON.stringify({
496
+ dryRun: true,
497
+ action: "uninstall",
498
+ plugin: name,
499
+ source: viaMarketplace ? "marketplace" : "npm",
500
+ }),
501
+ );
502
+ } else {
503
+ console.log(chalk.dim(`[dry-run] Would uninstall ${name}`));
504
+ }
505
+ continue;
506
+ }
507
+
508
+ if (viaMarketplace) {
481
509
  // Exact match against installed marketplace plugin IDs (name@marketplace)
482
510
  try {
483
511
  await mktMgr.uninstallPlugin(name, flags.scope);