@oh-my-pi/pi-coding-agent 17.1.3 → 17.1.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 (100) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3759 -3751
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/bash.d.ts +5 -4
  30. package/dist/types/tools/computer/exposure.d.ts +8 -0
  31. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  32. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  33. package/dist/types/tools/computer/worker.d.ts +1 -1
  34. package/dist/types/tools/computer.d.ts +6 -0
  35. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  36. package/dist/types/tools/todo.d.ts +7 -1
  37. package/package.json +12 -12
  38. package/src/advisor/__tests__/advisor.test.ts +80 -10
  39. package/src/advisor/runtime.ts +27 -1
  40. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  41. package/src/cli/auth-gateway-cli.ts +63 -16
  42. package/src/cli/config-cli.ts +25 -7
  43. package/src/cli/update-cli.ts +229 -29
  44. package/src/cli/usage-cli.ts +144 -15
  45. package/src/cli.ts +21 -15
  46. package/src/commands/update.ts +6 -0
  47. package/src/config/__tests__/model-registry.test.ts +42 -7
  48. package/src/config/model-registry.ts +67 -4
  49. package/src/config/settings-schema.ts +39 -8
  50. package/src/config/settings.ts +24 -2
  51. package/src/cursor.ts +153 -0
  52. package/src/dap/session.ts +70 -11
  53. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  54. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  55. package/src/eval/js/process-entry.ts +9 -5
  56. package/src/eval/js/worker-core.ts +6 -2
  57. package/src/exec/bash-executor.ts +4 -2
  58. package/src/extensibility/extensions/runner.ts +23 -8
  59. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  60. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  61. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  62. package/src/extensibility/shared-events.ts +2 -0
  63. package/src/main.ts +22 -1
  64. package/src/modes/acp/acp-agent.ts +6 -0
  65. package/src/modes/components/assistant-message.ts +88 -11
  66. package/src/modes/components/chat-transcript-builder.ts +2 -0
  67. package/src/modes/components/custom-editor.test.ts +170 -0
  68. package/src/modes/components/custom-editor.ts +79 -29
  69. package/src/modes/components/settings-defs.ts +5 -1
  70. package/src/modes/controllers/event-controller.ts +96 -4
  71. package/src/modes/controllers/selector-controller.ts +14 -0
  72. package/src/modes/interactive-mode.ts +34 -8
  73. package/src/modes/utils/context-usage.ts +19 -2
  74. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  75. package/src/plan-mode/approved-plan.ts +18 -10
  76. package/src/prompts/system/custom-system-prompt.md +1 -1
  77. package/src/prompts/system/system-prompt.md +10 -1
  78. package/src/sdk.ts +4 -0
  79. package/src/secrets/obfuscator.ts +36 -126
  80. package/src/session/agent-session.ts +69 -60
  81. package/src/session/prewalk.ts +8 -3
  82. package/src/session/session-advisors.ts +67 -3
  83. package/src/session/session-metadata.ts +53 -0
  84. package/src/session/session-provider-boundary.ts +0 -1
  85. package/src/session/session-tools.ts +35 -1
  86. package/src/session/turn-recovery.ts +36 -19
  87. package/src/slash-commands/builtin-registry.ts +49 -7
  88. package/src/stt/asr-worker.ts +2 -37
  89. package/src/stt/sherpa-runtime.ts +71 -0
  90. package/src/task/executor.ts +6 -5
  91. package/src/thinking.ts +39 -0
  92. package/src/tools/bash.ts +43 -15
  93. package/src/tools/computer/exposure.ts +38 -0
  94. package/src/tools/computer/supervisor.ts +61 -13
  95. package/src/tools/computer/worker-entry.ts +28 -19
  96. package/src/tools/computer/worker.ts +3 -7
  97. package/src/tools/computer.ts +65 -10
  98. package/src/tools/gh-cache-invalidation.ts +2 -82
  99. package/src/tools/shell-tokenize.ts +83 -0
  100. package/src/tools/todo.ts +44 -17
@@ -243,11 +243,6 @@ const HASH_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
243
243
  // base. A collision would let a persisted placeholder deobfuscate to the wrong
244
244
  // secret when the configured secret set or its ordering changes across sessions.
245
245
  const HASH_LEN = 12;
246
- // Pre-friendly-name sessions persisted a 4-char, index-derived token; reproduce
247
- // that exact legacy format so old session text still deobfuscates. The legacy
248
- // token is keyed on the entry index, not the secret value, so it leaks nothing.
249
- const LEGACY_HASH_LEN = 4;
250
- const LEGACY_HASH_SEED = 0x5345_4352;
251
246
  const MAX_FRIENDLY_NAME_LEN = 32;
252
247
  // Plain/regex obfuscate matches shorter than this are toned down (never placed
253
248
  // behind a reversible placeholder) to avoid redacting small words/fragments.
@@ -458,17 +453,6 @@ function buildKeyedReplacementRun(key: string, length: number): string {
458
453
  return out;
459
454
  }
460
455
 
461
- /** Build the pre-friendly-name index-derived placeholder for session resume compatibility. */
462
- function buildLegacyPlaceholder(index: number): string {
463
- let v = Bun.hash.xxHash32(String(index), LEGACY_HASH_SEED);
464
- let tag = "#";
465
- for (let i = 0; i < LEGACY_HASH_LEN; i++) {
466
- tag += HASH_CHARS[v % HASH_CHARS.length];
467
- v = Math.floor(v / HASH_CHARS.length);
468
- }
469
- return `${tag}#`;
470
- }
471
-
472
456
  function inferCaseHint(secret: string): PlaceholderCaseHint | undefined {
473
457
  let hasCased = false;
474
458
  let hasUpper = false;
@@ -502,21 +486,21 @@ function inferCaseHint(secret: string): PlaceholderCaseHint | undefined {
502
486
 
503
487
  function buildPlaceholder(hint: PlaceholderCaseHint | undefined, base: string, friendlyName?: string): string {
504
488
  const prefix = friendlyName ? `${friendlyName}_` : "";
505
- return hint ? `#${prefix}${base}:${hint}#` : `#${prefix}${base}#`;
489
+ return hint ? `$$${prefix}${base}:${hint}$$` : `$$${prefix}${base}$$`;
506
490
  }
507
491
 
508
- /** Regex to match #HASH#, #HASH:U#, and #FRIENDLY_HASH(:hint)# placeholders. */
509
- const PLACEHOLDER_RE = /#(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?#/g;
492
+ /** Regex matching `$$HASH$$`, `$$HASH:U$$`, and `$$FRIENDLY_HASH(:hint)$$` placeholders. */
493
+ const PLACEHOLDER_RE = /\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$/g;
510
494
 
511
495
  function resumePlaceholderScanAfterRejectedCandidate(match: RegExpExecArray): void {
512
496
  // RegExp#exec does not find overlapping matches. Restart at the rejected
513
- // candidate's closing `#`, which can open an immediately adjacent placeholder.
514
- PLACEHOLDER_RE.lastIndex = match.index + match[0].length - 1;
497
+ // candidate's closing delimiter, which can open an immediately adjacent placeholder.
498
+ PLACEHOLDER_RE.lastIndex = match.index + match[0].length - 2;
515
499
  }
516
500
 
517
501
  function placeholderWithoutFriendlyName(placeholder: string): string | undefined {
518
- const match = /^#[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder);
519
- return match ? `#${match[1]}#` : undefined;
502
+ const match = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder);
503
+ return match ? `$$${match[1]}$$` : undefined;
520
504
  }
521
505
 
522
506
  function lookupFriendlyPlaceholderAlias(
@@ -529,14 +513,12 @@ function lookupFriendlyPlaceholderAlias(
529
513
  return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined;
530
514
  }
531
515
 
532
- const PENDING_PLACEHOLDER_SUFFIX_RE = /#(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?$/;
516
+ const PENDING_PLACEHOLDER_SUFFIX_RE = /(?:\$\$(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?|\$)$/;
533
517
 
534
518
  // Withhold a trailing run that could be the start of a placeholder from streamed
535
519
  // deltas, so a partial token is never emitted before deobfuscation can replace
536
- // it. A lone trailing `#` is always buffered, even right after an alnum/`:`
537
- // (e.g. `ID#`), because that `#` can open a placeholder; emitting it would
538
- // corrupt the length-sliced live draft once the token completes. The final
539
- // non-streamed flush re-emits any buffered tail, so nothing is lost.
520
+ // it. A lone trailing delimiter character is always buffered because it can open
521
+ // a placeholder; the final non-streamed flush re-emits it when no token follows.
540
522
  export function stripPendingSecretPlaceholderSuffix(text: string): string {
541
523
  const pendingPlaceholderStart = text.match(PENDING_PLACEHOLDER_SUFFIX_RE);
542
524
  if (pendingPlaceholderStart?.index === undefined) return text;
@@ -582,13 +564,6 @@ export class SecretObfuscator {
582
564
  * one without the key. */
583
565
  #deobfuscateMap = new Map<string, { secret: string; recursive: boolean }>();
584
566
 
585
- /** Legacy index-derived aliases (unkeyed `#XRRS#`), honored ONLY when replaying
586
- * stored session content. They are deterministic and trivially guessable, so
587
- * accepting them on live provider/tool-call paths would let a prompt-injected
588
- * model synthesize one to exfiltrate a secret; they exist solely so sessions
589
- * persisted before keyed placeholders still deobfuscate on resume/display. */
590
- #legacyDeobfuscateMap = new Map<string, { secret: string; recursive: boolean }>();
591
-
592
567
  /** Exact placeholder tokens generated by this obfuscator revision (no aliases). */
593
568
  #generatedPlaceholders = new Set<string>();
594
569
 
@@ -669,7 +644,6 @@ export class SecretObfuscator {
669
644
  }
670
645
  }
671
646
  let index = 0;
672
- let legacyIndex = 0;
673
647
  let hasRealSec = this.#regexEntries.length > 0;
674
648
  for (const entry of entries) {
675
649
  if (entry.type !== "plain") continue;
@@ -680,11 +654,6 @@ export class SecretObfuscator {
680
654
  continue;
681
655
  }
682
656
  const placeholder = this.#createPlaceholder(entry.content, entry.friendlyName);
683
- this.#legacyDeobfuscateMap.set(buildLegacyPlaceholder(legacyIndex), {
684
- secret: entry.content,
685
- recursive: false,
686
- });
687
- legacyIndex++;
688
657
  this.#plainMappings.set(entry.content, index);
689
658
  this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
690
659
  this.#generatedPlaceholders.add(placeholder);
@@ -706,11 +675,6 @@ export class SecretObfuscator {
706
675
  return this.#hasAny;
707
676
  }
708
677
 
709
- /** Whether stored-session restoration can resolve keyed or legacy placeholders. */
710
- hasStoredSecrets(): boolean {
711
- return this.#hasAny || this.#legacyDeobfuscateMap.size > 0;
712
- }
713
-
714
678
  /** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */
715
679
  obfuscate(text: string, sharedRegexSecretValues?: ReadonlySet<string>): string {
716
680
  if (!this.#hasAny) return text;
@@ -904,24 +868,9 @@ export class SecretObfuscator {
904
868
  return result;
905
869
  }
906
870
 
907
- /**
908
- * Deobfuscate keyed placeholders back to original secrets for LIVE paths
909
- * (provider output, tool-call arguments). Replace-mode is NOT reversed, and
910
- * legacy index-derived aliases are intentionally ignored so a prompt-injected
911
- * model cannot synthesize one to recover a secret.
912
- */
871
+ /** Deobfuscate keyed placeholders for provider output, tool-call arguments, replay, and display. */
913
872
  deobfuscate(text: string): string {
914
- return this.#deobfuscate(text, false);
915
- }
916
-
917
- /**
918
- * Deobfuscate stored session content for replay/display. Identical to
919
- * {@link deobfuscate} but additionally honors legacy index-derived aliases so
920
- * sessions persisted before keyed placeholders still resume correctly. Use
921
- * only for trusted on-disk session content, never for live model output.
922
- */
923
- deobfuscateStored(text: string): string {
924
- return this.#deobfuscate(text, true);
873
+ return this.#deobfuscate(text);
925
874
  }
926
875
 
927
876
  // Reverse-direction counterpart to `#isGeneratedPlaceholder`'s guard: the
@@ -929,7 +878,7 @@ export class SecretObfuscator {
929
878
  // placeholder minted under a renamed friendly name still deobfuscates
930
879
  // (see `#prefixIsSecretShaped`'s docstring for why), but unconditionally
931
880
  // stripping and ignoring an attacker-authored prefix would let a forged
932
- // token like `#GITHUBPATABC123_<suffix-copied-from-any-real-placeholder>#`
881
+ // token like `$$GITHUBPATABC123_<suffix-copied-from-any-real-placeholder>$$`
933
882
  // restore to that OTHER secret's raw value with no check at all — worse
934
883
  // than the obfuscate-direction leak, since deobfuscation is what feeds
935
884
  // tool-call arguments and provider-output restoration. Refuse the
@@ -939,14 +888,14 @@ export class SecretObfuscator {
939
888
  #lookupLiveAlias(placeholder: string): { secret: string; recursive: boolean } | undefined {
940
889
  const direct = this.#deobfuscateMap.get(placeholder);
941
890
  if (direct !== undefined) return direct;
942
- const match = /^#([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder);
891
+ const body = placeholder.slice(2, -2);
892
+ const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(body);
943
893
  if (match === null || this.#prefixIsSecretShaped(match[1]!)) return undefined;
944
- return this.#deobfuscateMap.get(`#${match[2]}#`);
894
+ return this.#deobfuscateMap.get(`$$${match[2]}$$`);
945
895
  }
946
896
 
947
- #deobfuscate(text: string, allowLegacy: boolean): string {
948
- if ((!this.#hasAny && (!allowLegacy || this.#legacyDeobfuscateMap.size === 0)) || !text.includes("#"))
949
- return text;
897
+ #deobfuscate(text: string): string {
898
+ if (!this.#hasAny || !text.includes("$$")) return text;
950
899
  let result = text;
951
900
  for (;;) {
952
901
  let shouldContinue = false;
@@ -956,16 +905,9 @@ export class SecretObfuscator {
956
905
  shouldContinue ||= mapped.recursive;
957
906
  return mapped.secret;
958
907
  }
959
- if (allowLegacy) {
960
- const legacy = this.#legacyDeobfuscateMap.get(match);
961
- if (legacy !== undefined) {
962
- shouldContinue ||= legacy.recursive;
963
- return legacy.secret;
964
- }
965
- }
966
908
  return match;
967
909
  });
968
- if (next === result || !shouldContinue || !next.includes("#")) return next;
910
+ if (next === result || !shouldContinue || !next.includes("$$")) return next;
969
911
  result = next;
970
912
  }
971
913
  }
@@ -976,12 +918,6 @@ export class SecretObfuscator {
976
918
  return deepWalkStrings(obj, s => this.deobfuscate(s));
977
919
  }
978
920
 
979
- /** Deep-walk stored session content, deobfuscating string values incl. legacy aliases. */
980
- deobfuscateStoredObject<T>(obj: T): T {
981
- if (!this.hasStoredSecrets()) return obj;
982
- return deepWalkStrings(obj, s => this.deobfuscateStored(s));
983
- }
984
-
985
921
  /** Deep-walk an object, obfuscating all string values. */
986
922
  obfuscateObject<T>(obj: T): T {
987
923
  if (!this.#hasAny) return obj;
@@ -1390,7 +1326,7 @@ export class SecretObfuscator {
1390
1326
  #placeholderForCurrentInput(placeholder: string): string {
1391
1327
  const unprefixed = placeholderWithoutFriendlyName(placeholder);
1392
1328
  if (unprefixed === undefined) return placeholder;
1393
- const match = /^#([A-Z0-9]+)_/.exec(placeholder);
1329
+ const match = /^([A-Z0-9]+)_/.exec(placeholder.slice(2, -2));
1394
1330
  if (match === null || !this.#prefixIsSecretShaped(match[1]!)) return placeholder;
1395
1331
  return unprefixed;
1396
1332
  }
@@ -1492,10 +1428,10 @@ export class SecretObfuscator {
1492
1428
  // deobfuscate-direction check in `#deobfuscate`.
1493
1429
  #isGeneratedPlaceholder(placeholder: string): boolean {
1494
1430
  if (this.#deobfuscateMap.has(placeholder)) return true;
1495
- const match = /^#([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder);
1431
+ const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(placeholder.slice(2, -2));
1496
1432
  if (match === null) return false;
1497
1433
  if (this.#prefixIsSecretShaped(match[1]!)) return false;
1498
- return this.#deobfuscateMap.has(`#${match[2]}#`);
1434
+ return this.#deobfuscateMap.has(`$$${match[2]}$$`);
1499
1435
  }
1500
1436
 
1501
1437
  // Replace `search` with `replacement` outside known generated placeholders while
@@ -1865,40 +1801,25 @@ export class SecretObfuscator {
1865
1801
  * Restore secret placeholders for local display. Only message kinds the model
1866
1802
  * itself authored from obfuscated context carry placeholders — assistant
1867
1803
  * content and the LLM-written branch/compaction summaries. User, developer, and
1868
- * tool-result messages are persisted with their literal text, so a literal
1869
- * `#ABCD#` the operator typed must survive untouched; those roles are never
1870
- * walked.
1871
- *
1872
- * Legacy index-derived aliases (`#XXXX#`) are unkeyed and trivially guessable,
1873
- * so a prompt-injected model can plant one in any record it influences. Every
1874
- * agent-feeding path (resume, history rewrite, branch switch) therefore restores
1875
- * keyed placeholders ONLY (`allowLegacyAliases` false), leaving legacy tokens
1876
- * inert; display-only transcripts that are never re-obfuscated opt in via
1877
- * `allowLegacyAliases`.
1804
+ * tool-result messages are persisted with their literal text, so operator-authored
1805
+ * placeholder-shaped text must survive untouched; those roles are never walked.
1878
1806
  */
1879
1807
  export function deobfuscateSessionContext(
1880
1808
  sessionContext: SessionContext,
1881
1809
  obfuscator: SecretObfuscator | undefined,
1882
- allowLegacyAliases = false,
1883
1810
  ): SessionContext {
1884
- if (!obfuscator || !(allowLegacyAliases ? obfuscator.hasStoredSecrets() : obfuscator.hasSecrets()))
1885
- return sessionContext;
1886
- const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages, allowLegacyAliases);
1811
+ if (!obfuscator?.hasSecrets()) return sessionContext;
1812
+ const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages);
1887
1813
  return messages === sessionContext.messages ? sessionContext : { ...sessionContext, messages };
1888
1814
  }
1889
1815
 
1890
- export function deobfuscateAgentMessages(
1891
- obfuscator: SecretObfuscator,
1892
- messages: AgentMessage[],
1893
- allowLegacyAliases = false,
1894
- ): AgentMessage[] {
1895
- const deob = (text: string): string =>
1896
- allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
1816
+ export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] {
1817
+ const deob = (text: string): string => obfuscator.deobfuscate(text);
1897
1818
  let changed = false;
1898
1819
  const result = messages.map((message): AgentMessage => {
1899
1820
  switch (message.role) {
1900
1821
  case "assistant": {
1901
- const content = deobfuscateAssistantContent(obfuscator, message.content, allowLegacyAliases);
1822
+ const content = deobfuscateAssistantContent(obfuscator, message.content);
1902
1823
  if (content === message.content) return message;
1903
1824
  changed = true;
1904
1825
  return { ...message, content };
@@ -1912,10 +1833,7 @@ export function deobfuscateAgentMessages(
1912
1833
  case "compactionSummary": {
1913
1834
  const summary = deob(message.summary);
1914
1835
  const shortSummary = message.shortSummary === undefined ? undefined : deob(message.shortSummary);
1915
- const blocks =
1916
- message.blocks === undefined
1917
- ? undefined
1918
- : deobfuscateTextBlocks(obfuscator, message.blocks, allowLegacyAliases);
1836
+ const blocks = message.blocks === undefined ? undefined : deobfuscateTextBlocks(obfuscator, message.blocks);
1919
1837
  if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) {
1920
1838
  return message;
1921
1839
  }
@@ -1937,11 +1855,9 @@ export function deobfuscateAgentMessages(
1937
1855
  export function deobfuscateAssistantContent(
1938
1856
  obfuscator: SecretObfuscator,
1939
1857
  content: AssistantMessage["content"],
1940
- allowLegacyAliases = false,
1941
1858
  ): AssistantMessage["content"] {
1942
- if (!(allowLegacyAliases ? obfuscator.hasStoredSecrets() : obfuscator.hasSecrets())) return content;
1943
- const deob = (text: string): string =>
1944
- allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
1859
+ if (!obfuscator.hasSecrets()) return content;
1860
+ const deob = (text: string): string => obfuscator.deobfuscate(text);
1945
1861
  let changed = false;
1946
1862
  const result = content.map((block): AssistantMessage["content"][number] => {
1947
1863
  if (block.type === "text") {
@@ -1952,7 +1868,7 @@ export function deobfuscateAssistantContent(
1952
1868
  }
1953
1869
 
1954
1870
  if (block.type === "toolCall") {
1955
- const args = deobfuscateToolArguments(obfuscator, block.arguments, allowLegacyAliases);
1871
+ const args = deobfuscateToolArguments(obfuscator, block.arguments);
1956
1872
  const intent = block.intent === undefined ? undefined : deob(block.intent);
1957
1873
  const rawBlock = block.rawBlock === undefined ? undefined : deob(block.rawBlock);
1958
1874
  if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block;
@@ -1972,12 +1888,9 @@ export function deobfuscateAssistantContent(
1972
1888
  export function deobfuscateToolArguments(
1973
1889
  obfuscator: SecretObfuscator,
1974
1890
  args: Record<string, unknown>,
1975
- allowLegacyAliases = false,
1976
1891
  ): Record<string, unknown> {
1977
- if (!(allowLegacyAliases ? obfuscator.hasStoredSecrets() : obfuscator.hasSecrets())) return args;
1978
- const deob = (text: string): string =>
1979
- allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
1980
- return mapJsonStrings(args as JsonValue, deob) as Record<string, unknown>;
1892
+ if (!obfuscator.hasSecrets()) return args;
1893
+ return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record<string, unknown>;
1981
1894
  }
1982
1895
 
1983
1896
  /** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */
@@ -2018,14 +1931,11 @@ function obfuscateTextBlocks(
2018
1931
  function deobfuscateTextBlocks(
2019
1932
  obfuscator: SecretObfuscator,
2020
1933
  content: (TextContent | ImageContent)[],
2021
- allowLegacyAliases = false,
2022
1934
  ): (TextContent | ImageContent)[] {
2023
- const deob = (text: string): string =>
2024
- allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
2025
1935
  let changed = false;
2026
1936
  const result = content.map((block): TextContent | ImageContent => {
2027
1937
  if (block.type !== "text") return block;
2028
- const text = deob(block.text);
1938
+ const text = obfuscator.deobfuscate(block.text);
2029
1939
  if (text === block.text) return block;
2030
1940
  changed = true;
2031
1941
  return { ...block, text };
@@ -71,7 +71,7 @@ import type {
71
71
  ToolResultMessage,
72
72
  UsageReport,
73
73
  } from "@oh-my-pi/pi-ai";
74
- import { deriveClaudeDeviceId, type Effort, streamSimple } from "@oh-my-pi/pi-ai";
74
+ import { type Effort, streamSimple } from "@oh-my-pi/pi-ai";
75
75
  import * as AIError from "@oh-my-pi/pi-ai/error";
76
76
  import { resetOpenAICodexHistoryAfterCompaction } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
77
77
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
@@ -81,7 +81,6 @@ import {
81
81
  escapeXmlText,
82
82
  formatDuration,
83
83
  getAgentDbPath,
84
- getInstallId,
85
84
  isBunTestRuntime,
86
85
  isEnoent,
87
86
  isInteractiveHost,
@@ -229,7 +228,6 @@ import {
229
228
  type AsyncResultEntry,
230
229
  buildAsyncResultBatchMessage,
231
230
  } from "./async-job-delivery";
232
- import type { AuthStorage } from "./auth-storage";
233
231
  import { BashRunner, type BashRunnerHost } from "./bash-runner";
234
232
  import {
235
233
  checkpointStartedAtFromEntry,
@@ -304,6 +302,7 @@ import {
304
302
  } from "./session-maintenance";
305
303
  import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
306
304
  import { SessionMemory, type SessionMemoryHost } from "./session-memory";
305
+ import { buildSessionMetadata } from "./session-metadata";
307
306
  import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./session-provider-boundary";
308
307
  import { SessionStatsTracker, type SessionStatsTrackerHost } from "./session-stats";
309
308
  import { SessionTools, type SessionToolsHost } from "./session-tools";
@@ -330,53 +329,6 @@ const PLAN_MODE_REMINDER_MAX = 3;
330
329
  // Constants
331
330
  // ============================================================================
332
331
 
333
- /** Standard thinking levels */
334
-
335
- /**
336
- * Build the per-request `metadata` payload for the Anthropic provider, shaped
337
- * like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
338
- * device_id }`) so the backend buckets requests under one session and attributes
339
- * them to the authenticated OAuth account when available. Resolved at request
340
- * time so token refreshes and login/logout transitions don't strand a stale
341
- * account UUID in memory. `account_uuid` and `device_id` are omitted for
342
- * non-Anthropic providers to avoid leaking the user's Claude identity to
343
- * third-party APIs (including Anthropic-format-compatible proxies such as
344
- * cloudflare-ai-gateway or gitlab-duo).
345
- *
346
- * `provider` is the target provider string (e.g. `"anthropic"`) and gates the
347
- * `account_uuid` and `device_id` lookups — only `"anthropic"` requests carry them.
348
- *
349
- * `sessionId` is forwarded to the auth-storage session-sticky lookup so that
350
- * multi-credential setups attribute to the same OAuth account used for the
351
- * actual API request rather than always picking the first credential.
352
- *
353
- * `authStorage` is treated as optional so test fixtures that stub `modelRegistry`
354
- * without a real storage layer still work; the resolver simply skips the lookup
355
- * and emits `{ session_id }` alone, matching the no-OAuth-credential path.
356
- */
357
- function buildSessionMetadata(
358
- sessionId: string,
359
- provider: string,
360
- authStorage: AuthStorage | undefined,
361
- ): Record<string, unknown> {
362
- const userId: Record<string, string> = { session_id: sessionId };
363
- // Only look up account_uuid when the request is going to Anthropic. Injecting
364
- // a Claude OAuth account_uuid into requests bound for other providers (including
365
- // Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo)
366
- // would leak the user's Anthropic identity to unrelated third-party APIs.
367
- if (provider === "anthropic") {
368
- const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
369
- if (typeof accountUuid === "string" && accountUuid.length > 0) {
370
- userId.account_uuid = accountUuid;
371
- // Claude Code's `device_id` is a stable 64-hex account-scoped install
372
- // identifier. Include both omp's persistent install id and the Claude
373
- // account UUID so two accounts on the same install do not share a device.
374
- userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid);
375
- }
376
- }
377
- return { user_id: JSON.stringify(userId) };
378
- }
379
-
380
332
  const noOpUIContext: ExtensionUIContext = {
381
333
  select: async (_title, _options, _dialogOptions) => undefined,
382
334
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -901,6 +853,7 @@ export class AgentSession {
901
853
  agent: this.agent,
902
854
  sessionManager: this.sessionManager,
903
855
  model: () => this.model,
856
+ configuredThinkingLevel: () => this.configuredThinkingLevel(),
904
857
  emitNotice: (level, message, source) => this.emitNotice(level, message, source),
905
858
  setModelTemporary: (model, thinkingLevel, options) => this.setModelTemporary(model, thinkingLevel, options),
906
859
  setActiveToolsByName: names => this.setActiveToolsByName(names),
@@ -2249,7 +2202,7 @@ export class AgentSession {
2249
2202
  // Deobfuscate assistant message content for display emission — the LLM echoes back
2250
2203
  // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
2251
2204
  // values. The original event.message stays obfuscated so the persistence path below
2252
- // writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound
2205
+ // writes `$$HASH$$` tokens to the session file; convertToLlm re-obfuscates outbound
2253
2206
  // traffic on the next turn. Walks text, thinking, and toolCall arguments/intent.
2254
2207
  let displayEvent: AgentEvent = event;
2255
2208
  const obfuscator = this.#obfuscator;
@@ -2632,8 +2585,14 @@ export class AgentSession {
2632
2585
  return;
2633
2586
  }
2634
2587
 
2635
- if (this.#recovery.isRetryableReasonlessAbort(msg)) {
2636
- const didRetry = await this.#recovery.handleRetryableError(msg, { allowModelFallback: false });
2588
+ const resolvedInterruptedToolTurn = this.#recovery.classifyResolvedInterruptedToolTurn(msg);
2589
+ if (this.#recovery.isRetryableReasonlessAbort(msg) || resolvedInterruptedToolTurn === "reasonless-abort") {
2590
+ const didRetry = await this.#recovery.handleRetryableError(
2591
+ msg,
2592
+ resolvedInterruptedToolTurn === "reasonless-abort"
2593
+ ? { allowModelFallback: false, preserveFailedTurn: true }
2594
+ : { allowModelFallback: false },
2595
+ );
2637
2596
  if (didRetry) {
2638
2597
  await emitAgentEndNotification({ willContinue: true });
2639
2598
  return;
@@ -2659,7 +2618,7 @@ export class AgentSession {
2659
2618
  return;
2660
2619
  }
2661
2620
  }
2662
- const resumeResolvedStreamStall = this.#recovery.canResumeResolvedStreamStall(msg);
2621
+ const resumeResolvedStreamStall = resolvedInterruptedToolTurn === "stream-stall";
2663
2622
  if (resumeResolvedStreamStall || this.#recovery.isRetryableError(msg)) {
2664
2623
  const didRetry = await this.#recovery.handleRetryableError(
2665
2624
  msg,
@@ -3078,6 +3037,7 @@ export class AgentSession {
3078
3037
  session_id: this.sessionId,
3079
3038
  session_file: this.sessionFile,
3080
3039
  stop_hook_active: this.#sessionStopHookActive,
3040
+ signal: this.#postPromptTasksAbortController.signal,
3081
3041
  });
3082
3042
  if (this.#promptGeneration !== generation || this.#abortInProgress || this.#isDisposed) {
3083
3043
  this.#resetSessionStopContinuationState();
@@ -3336,6 +3296,12 @@ export class AgentSession {
3336
3296
  this.agent.setMetadataResolver((provider: string) =>
3337
3297
  buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage),
3338
3298
  );
3299
+ // Keep every live advisor's provider identity in lockstep with the primary's
3300
+ // across every session-boundary transition — including branch paths that
3301
+ // skip conversation restore — so advisors never emit the previous
3302
+ // conversation's session id/metadata (issue #6625). Guarded because this
3303
+ // runs once during construction before the advisor controller exists.
3304
+ if (this.#advisors) this.#advisors.refreshProviderIdentity();
3339
3305
  }
3340
3306
 
3341
3307
  /** Run one abortable auto-learn capture outside the primary agent loop. */
@@ -4097,10 +4063,7 @@ export class AgentSession {
4097
4063
  * Transcript for TUI display. Full history is kept for export/resume-style
4098
4064
  * callers; live chat can collapse compacted history to keep the hot render
4099
4065
  * surface bounded. Display-only — NEVER feed the result to
4100
- * `agent.replaceMessages` or a provider. Because it is never re-obfuscated,
4101
- * it opts into legacy index-derived alias restoration so pre-keyed sessions
4102
- * still render their secrets; the agent-feeding paths
4103
- * (`buildDisplaySessionContext`) keep the keyed-only default.
4066
+ * `agent.replaceMessages` or a provider.
4104
4067
  */
4105
4068
  buildTranscriptSessionContext(
4106
4069
  options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory" | "keepDanglingToolCalls">,
@@ -7373,6 +7336,14 @@ export class AgentSession {
7373
7336
  * (issue #5642).
7374
7337
  */
7375
7338
  reopenAsk?: { toolCallId: string; questions: AskToolInput["questions"] };
7339
+ /**
7340
+ * `true` when this call committed a new sibling answer for an `ask`
7341
+ * re-answer (`reanswerAskResult` was applied). The interactive caller
7342
+ * resumes the agent via {@link resumeAfterAskReanswer} *after* rebuilding
7343
+ * its transcript, so the resumed turn never renders against the stale
7344
+ * pre-rebuild UI (issue #6483).
7345
+ */
7346
+ askReanswerCommitted?: boolean;
7376
7347
  }> {
7377
7348
  await this.#bash.flushPending();
7378
7349
  const oldLeafId = this.sessionManager.getLeafId();
@@ -7523,6 +7494,10 @@ export class AgentSession {
7523
7494
  // Determine the new leaf position based on target type
7524
7495
  let newLeafId: string | null;
7525
7496
  let editorText: string | undefined;
7497
+ // Set when the second-pass `ask` re-answer branch below actually commits a
7498
+ // new sibling answer — the trigger for resuming the agent afterwards so the
7499
+ // model consumes it, mirroring a live `ask` completion (issue #6483).
7500
+ let isAskReanswerCompletion = false;
7526
7501
 
7527
7502
  if (targetEntry.type === "message" && targetEntry.message.role === "user") {
7528
7503
  // User message: leaf = parent (null if root), text goes to editor
@@ -7559,6 +7534,7 @@ export class AgentSession {
7559
7534
  timestamp: Date.now(),
7560
7535
  };
7561
7536
  newLeafId = this.sessionManager.appendMessageToBranch(toolResultMessage, targetEntry.parentId);
7537
+ isAskReanswerCompletion = true;
7562
7538
  } else {
7563
7539
  // Non-user message (or a user-invoked skill-prompt injection): land the
7564
7540
  // leaf on the selected node so it stays on the active branch. Skill
@@ -7604,6 +7580,14 @@ export class AgentSession {
7604
7580
 
7605
7581
  this.#branchSummaryAbortController = undefined;
7606
7582
 
7583
+ // Report a committed `ask` re-answer so the interactive caller can resume
7584
+ // the agent via `resumeAfterAskReanswer()` *after* rebuilding its
7585
+ // transcript. Scheduling the continue here instead would start a fresh
7586
+ // streaming turn whose `agent_start`/`turn_start` events could render
7587
+ // against the stale pre-rebuild UI and then be clobbered by the caller's
7588
+ // `renderInitialMessages(...)` (issue #6483). Plain leaf moves and the
7589
+ // read-only `reopenAsk` probe leave the flag unset.
7590
+
7607
7591
  // Emit session_tree event; only handlers can mutate session entries, so skip
7608
7592
  // the emit and the context rebuild when no handlers are registered (mirrors
7609
7593
  // the session_before_tree guard above).
@@ -7616,9 +7600,34 @@ export class AgentSession {
7616
7600
  fromExtension: summaryText ? fromExtension : undefined,
7617
7601
  });
7618
7602
  const rawContext = this.sessionManager.buildSessionContext();
7619
- return { editorText, cancelled: false, summaryEntry, sessionContext: rawContext };
7603
+ return {
7604
+ editorText,
7605
+ cancelled: false,
7606
+ summaryEntry,
7607
+ sessionContext: rawContext,
7608
+ askReanswerCommitted: isAskReanswerCompletion,
7609
+ };
7620
7610
  }
7621
- return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
7611
+ return {
7612
+ editorText,
7613
+ cancelled: false,
7614
+ summaryEntry,
7615
+ sessionContext: stateContext,
7616
+ askReanswerCommitted: isAskReanswerCompletion,
7617
+ };
7618
+ }
7619
+
7620
+ /**
7621
+ * Resume the agent after the interactive `/tree` caller has committed an
7622
+ * `ask` re-answer (`navigateTree` returned `askReanswerCommitted`) and
7623
+ * rebuilt its transcript. Mirrors how a live `ask` completion drives a
7624
+ * follow-up turn, but is deferred to the caller so the resumed turn renders
7625
+ * against the rebuilt UI rather than the stale pre-navigation transcript
7626
+ * (issue #6483). The scheduled continue honors the same disposed/compacting
7627
+ * guards as every other post-prompt continuation.
7628
+ */
7629
+ resumeAfterAskReanswer(): void {
7630
+ this.#scheduleAgentContinue();
7622
7631
  }
7623
7632
 
7624
7633
  /**
@@ -1,7 +1,6 @@
1
1
  import type { Agent, AgentMessage, AgentToolResult, AgentTurnEndContext } from "@oh-my-pi/pi-agent-core";
2
2
  import { invalidateMessageCache } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { Model } from "@oh-my-pi/pi-ai";
4
- import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
5
4
  import { prompt } from "@oh-my-pi/pi-utils";
6
5
  import type { LocalProtocolOptions } from "../internal-urls";
7
6
  import { resolveApprovedPlan } from "../plan-mode/approved-plan";
@@ -11,7 +10,7 @@ import planYoloHandoffPrompt from "../prompts/system/plan-yolo-handoff.md" with
11
10
  import prewalkChecklistPrompt from "../prompts/system/prewalk-checklist.md" with { type: "text" };
12
11
  import prewalkContinuePrompt from "../prompts/system/prewalk-continue.md" with { type: "text" };
13
12
  import prewalkPlanPrompt from "../prompts/system/prewalk-plan.md" with { type: "text" };
14
- import type { ConfiguredThinkingLevel } from "../thinking";
13
+ import { type ConfiguredThinkingLevel, prewalkWouldBeNoop } from "../thinking";
15
14
  import type { PlanProposalHandler } from "../tools/resolve";
16
15
  import { ToolError } from "../tools/tool-errors";
17
16
  import type { PlanYolo, Prewalk } from "./agent-session-types";
@@ -31,6 +30,7 @@ export interface PrewalkCoordinatorHost {
31
30
  agent: Agent;
32
31
  sessionManager: SessionManager;
33
32
  model(): Model | undefined;
33
+ configuredThinkingLevel(): ConfiguredThinkingLevel | undefined;
34
34
  emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
35
35
  setModelTemporary(
36
36
  model: Model,
@@ -126,8 +126,13 @@ export class PrewalkCoordinator {
126
126
  this.#scrubPlanNudge(liveMessages);
127
127
  const target = prewalk.target;
128
128
  const currentModel = this.#host.model();
129
- if (currentModel && modelsAreEqual(currentModel, target)) {
129
+ if (prewalkWouldBeNoop(currentModel, this.#host.configuredThinkingLevel(), target, prewalk.thinkingLevel)) {
130
130
  this.#prewalk = undefined;
131
+ this.#host.emitNotice(
132
+ "info",
133
+ `Prewalk: target ${target.provider}/${target.id} already matches the active model and thinking level; nothing to switch.`,
134
+ "prewalk",
135
+ );
131
136
  return;
132
137
  }
133
138
  await this.#host.setModelTemporary(target, prewalk.thinkingLevel, { ephemeral: true });