@oh-my-pi/pi-coding-agent 17.1.2 → 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 (110) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3806 -3798
  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/auto-generated-guard.d.ts +5 -2
  30. package/dist/types/tools/bash.d.ts +5 -4
  31. package/dist/types/tools/computer/exposure.d.ts +8 -0
  32. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  33. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  34. package/dist/types/tools/computer/worker.d.ts +1 -1
  35. package/dist/types/tools/computer.d.ts +6 -0
  36. package/dist/types/tools/memory-render.d.ts +1 -4
  37. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  38. package/dist/types/tools/todo.d.ts +7 -1
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +80 -10
  41. package/src/advisor/runtime.ts +27 -1
  42. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  43. package/src/cli/auth-gateway-cli.ts +63 -16
  44. package/src/cli/config-cli.ts +25 -7
  45. package/src/cli/update-cli.ts +229 -29
  46. package/src/cli/usage-cli.ts +144 -15
  47. package/src/cli.ts +21 -15
  48. package/src/commands/update.ts +6 -0
  49. package/src/config/__tests__/model-registry.test.ts +42 -7
  50. package/src/config/model-registry.ts +67 -4
  51. package/src/config/settings-schema.ts +39 -8
  52. package/src/config/settings.ts +24 -2
  53. package/src/cursor.ts +153 -0
  54. package/src/dap/session.ts +70 -11
  55. package/src/edit/hashline/filesystem.ts +1 -1
  56. package/src/edit/modes/patch.ts +1 -1
  57. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  58. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  59. package/src/eval/js/process-entry.ts +9 -5
  60. package/src/eval/js/worker-core.ts +6 -2
  61. package/src/exec/bash-executor.ts +4 -2
  62. package/src/extensibility/extensions/runner.ts +23 -8
  63. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  64. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  65. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  66. package/src/extensibility/shared-events.ts +2 -0
  67. package/src/main.ts +22 -1
  68. package/src/modes/acp/acp-agent.ts +6 -0
  69. package/src/modes/components/assistant-message.ts +88 -11
  70. package/src/modes/components/chat-transcript-builder.ts +2 -0
  71. package/src/modes/components/custom-editor.test.ts +170 -0
  72. package/src/modes/components/custom-editor.ts +79 -29
  73. package/src/modes/components/settings-defs.ts +5 -1
  74. package/src/modes/controllers/event-controller.ts +96 -4
  75. package/src/modes/controllers/selector-controller.ts +14 -0
  76. package/src/modes/interactive-mode.ts +34 -8
  77. package/src/modes/utils/context-usage.ts +19 -2
  78. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  79. package/src/plan-mode/approved-plan.ts +18 -10
  80. package/src/prompts/system/custom-system-prompt.md +1 -1
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/prompts/tools/ast-edit.md +1 -1
  83. package/src/sdk.ts +4 -0
  84. package/src/secrets/obfuscator.ts +36 -126
  85. package/src/session/agent-session.ts +69 -60
  86. package/src/session/prewalk.ts +8 -3
  87. package/src/session/session-advisors.ts +67 -3
  88. package/src/session/session-metadata.ts +53 -0
  89. package/src/session/session-provider-boundary.ts +0 -1
  90. package/src/session/session-tools.ts +35 -1
  91. package/src/session/stream-guards.ts +1 -1
  92. package/src/session/turn-recovery.ts +36 -19
  93. package/src/slash-commands/builtin-registry.ts +49 -7
  94. package/src/stt/asr-worker.ts +2 -37
  95. package/src/stt/sherpa-runtime.ts +71 -0
  96. package/src/task/executor.ts +6 -5
  97. package/src/thinking.ts +39 -0
  98. package/src/tools/auto-generated-guard.ts +18 -5
  99. package/src/tools/bash.ts +43 -15
  100. package/src/tools/computer/exposure.ts +38 -0
  101. package/src/tools/computer/supervisor.ts +61 -13
  102. package/src/tools/computer/worker-entry.ts +28 -19
  103. package/src/tools/computer/worker.ts +3 -7
  104. package/src/tools/computer.ts +65 -10
  105. package/src/tools/gh-cache-invalidation.ts +2 -82
  106. package/src/tools/memory-render.ts +11 -2
  107. package/src/tools/render-utils.ts +8 -3
  108. package/src/tools/shell-tokenize.ts +83 -0
  109. package/src/tools/todo.ts +44 -17
  110. package/src/tools/write.ts +1 -1
@@ -56,6 +56,22 @@ const EMPTY_STRING_PARTS: string[] = [];
56
56
  const EMPTY_TOOLS: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">> = [];
57
57
  const EMPTY_SKILLS: readonly Skill[] = [];
58
58
 
59
+ /**
60
+ * Skills actually rendered into the system prompt, mirroring the filter in
61
+ * `buildSystemPrompt` (`system-prompt.ts`): the `read` tool must be present so
62
+ * the model can fetch skill content, and skills with frontmatter `hide: true`
63
+ * (or `disable-model-invocation`, normalized onto `hide`) are excluded.
64
+ * Accounting must count only these so the Skills category and the System-prompt
65
+ * subtraction stay aligned with the provider-facing prompt.
66
+ */
67
+ function renderedSkills(
68
+ skills: readonly Skill[],
69
+ tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>,
70
+ ): readonly Skill[] {
71
+ if (!tools.some(tool => tool.name === "read")) return EMPTY_SKILLS;
72
+ return skills.filter(skill => skill.hide !== true);
73
+ }
74
+
59
75
  export function estimateSkillsTokens(skills: readonly Skill[]): number {
60
76
  const fragments: string[] = [];
61
77
  for (const skill of skills) {
@@ -171,8 +187,9 @@ export function computeNonMessageBreakdown(session: NonMessageTokenSource): {
171
187
  } {
172
188
  const entry = nonMessageTokenCacheEntry(session);
173
189
  if (entry.breakdown) return entry.breakdown;
174
- const skillsTokens = estimateSkillsTokens(session.skills ?? EMPTY_SKILLS);
175
- const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? EMPTY_TOOLS);
190
+ const tools = session.agent?.state?.tools ?? EMPTY_TOOLS;
191
+ const skillsTokens = estimateSkillsTokens(renderedSkills(session.skills ?? EMPTY_SKILLS, tools));
192
+ const toolsTokens = estimateToolSchemaTokens(tools);
176
193
  const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
177
194
  const systemContextTokens = countTokens(systemPromptParts.slice(1));
178
195
  const systemPromptTokens = Math.max(0, countTokens(systemPromptParts[0] ?? "") - skillsTokens);
@@ -25,5 +25,6 @@ export function createAssistantMessageComponent(
25
25
  ctx.proseOnlyThinking,
26
26
  );
27
27
  component.setImagesVisible(ctx.settings.get("terminal.showImages"));
28
+ component.setExpanded(ctx.toolOutputExpanded);
28
29
  return component;
29
30
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeLocalScheme } from "../tools/path-utils";
1
2
  import { ToolError } from "../tools/tool-errors";
2
3
 
3
4
  /** Shape forwarded from the plan-proposal handler to InteractiveMode's
@@ -149,8 +150,9 @@ export interface ResolvedApprovedPlan {
149
150
 
150
151
  /** Locate the plan file the agent wrote and finalize its title — without
151
152
  * renaming anything. Tries, in order: the slug derived from `extra.title`
152
- * (`local://<slug>-plan.md`), the plan path from plan-mode state, then a scan
153
- * of recent plan files. Throws a `ToolError` guiding the agent when none exist. */
153
+ * (`local://<slug>-plan.md`), a state plan that the artifact scan can't see,
154
+ * scanned plan files newest-to-oldest, then the state plan path as a final
155
+ * fallback. Throws a `ToolError` guiding the agent when none exist. */
154
156
  export async function resolveApprovedPlan(input: ResolveApprovedPlanInput): Promise<ResolvedApprovedPlan> {
155
157
  const ordered: string[] = [];
156
158
  const consider = (url: string | undefined): void => {
@@ -159,6 +161,20 @@ export async function resolveApprovedPlan(input: ResolveApprovedPlanInput): Prom
159
161
 
160
162
  const slug = planSlugFromSupplied(input.suppliedTitle);
161
163
  consider(slug ? planFileUrlForSlug(slug) : undefined);
164
+
165
+ const listed = input.listPlanFiles ? await input.listPlanFiles() : [];
166
+ // A state plan the scan cannot surface (cwd-relative, or a local file whose
167
+ // name does not end in `plan.md`) has no mtime in `listed` to compete on, so
168
+ // it keeps precedence over scanned artifacts — otherwise a stale older draft
169
+ // could shadow the deliberately-set current plan. A state plan already inside
170
+ // the scan competes purely on the newest-first ordering below (issue #6569).
171
+ // Compare canonical `local://` spellings so a resumed `local:/…` state path
172
+ // still matches the scanner's `local://…` entry (normalizeLocalScheme).
173
+ const canonicalListed = new Set(listed.map(normalizeLocalScheme));
174
+ if (input.statePlanFilePath && !canonicalListed.has(normalizeLocalScheme(input.statePlanFilePath))) {
175
+ consider(input.statePlanFilePath);
176
+ }
177
+ for (const url of listed) consider(url);
162
178
  consider(input.statePlanFilePath);
163
179
 
164
180
  for (const url of ordered) {
@@ -166,14 +182,6 @@ export async function resolveApprovedPlan(input: ResolveApprovedPlanInput): Prom
166
182
  if (content !== null) return finalizeApprovedPlan(url, content, input.suppliedTitle);
167
183
  }
168
184
 
169
- if (input.listPlanFiles) {
170
- for (const url of await input.listPlanFiles()) {
171
- if (ordered.includes(url)) continue;
172
- const content = await input.readPlan(url);
173
- if (content !== null) return finalizeApprovedPlan(url, content, input.suppliedTitle);
174
- }
175
- }
176
-
177
185
  const target = ordered[0] ?? input.statePlanFilePath;
178
186
  throw new ToolError(
179
187
  `Plan file not found at ${target}. Write the finalized plan to ${target} before requesting approval.`,
@@ -59,6 +59,6 @@ Rules are local constraints. You MUST read `rule://<name>` when working in that
59
59
  {{/if}}
60
60
  {{#if secretsEnabled}}
61
61
  <redacted-content>
62
- Some values in tool output are redacted for security. They appear as placeholder tokens such as `#HASH#`, `#HASH:CASE#`, or `#NAME_HASH:CASE#` (uppercase-alphanumeric digest, optional case hint, optional friendly-name prefix). These are **not errors** — they are intentional placeholders for sensitive values (API keys, passwords, tokens). Treat them as opaque strings. NEVER attempt to decode, fix, or report them as problems.
62
+ Some values in tool output are redacted for security. They appear as placeholder tokens such as `$$HASH$$`, `$$HASH:CASE$$`, or `$$NAME_HASH:CASE$$` (uppercase-alphanumeric digest, optional case hint, optional friendly-name prefix). These are **not errors** — they are intentional placeholders for sensitive values (API keys, passwords, tokens). Treat them as opaque strings. NEVER attempt to decode, fix, or report them as problems.
63
63
  </redacted-content>
64
64
  {{/if}}
@@ -79,6 +79,15 @@ Special URLs for internal resources; with most FS/bash tools they auto-resolve t
79
79
  {{/if}}
80
80
  {{/if}}
81
81
 
82
+ {{#has tools "computer"}}
83
+ # Computer Use
84
+ The `{{toolRefs.computer}}` tool is explicitly enabled and available in this session.
85
+ - MUST use `{{toolRefs.computer}}` for requests to view or control host desktop applications.
86
+ - NEVER claim Computer Use is unavailable while `{{toolRefs.computer}}` appears in the tool inventory.
87
+ - While fulfilling host-desktop requests, NEVER substitute Browser, Bash, Eval, AppleScript, accessibility commands, or `screencapture` unless the user explicitly requests that mechanism or `{{toolRefs.computer}}` returns an error.
88
+ - Inspect the fresh screenshot returned by every successful `{{toolRefs.computer}}` call before choosing the next action.
89
+ {{/has}}
90
+
82
91
  {{#if xdevTools.length}}
83
92
  # xd:// Tool Devices
84
93
  Additional tools are mounted as virtual devices, executed by writing a JSON args object as `content` to `xd://<tool>` via `{{toolRefs.write}}`.
@@ -101,7 +110,7 @@ Use tools whenever they improve correctness, completeness, or grounding.
101
110
  # Tool I/O
102
111
  - Prefer relative paths for `path`-like fields.
103
112
  {{#if intentTracing}}- Most tools take `{{intentField}}`: a concise intent, present participle, 2–6 words, no period, capitalized.{{/if}}
104
- {{#if secretsEnabled}}- Redacted `#HASH#`, `#HASH:CASE#`, or `#NAME_HASH:CASE#` tokens in output are opaque strings.{{/if}}
113
+ {{#if secretsEnabled}}- Redacted `$$HASH$$`, `$$HASH:CASE$$`, or `$$NAME_HASH:CASE$$` tokens in output are opaque strings.{{/if}}
105
114
  {{#has tools "inspect_image"}}- Image tasks: prefer `{{toolRefs.inspect_image}}` over `{{toolRefs.read}}` to spare session context.{{/has}}
106
115
 
107
116
  # Specialized Tools
@@ -1,4 +1,4 @@
1
- Structural AST-aware rewrites via ast-grep. Use for codemods where text replace is unsafe. Narrow each call to one language.
1
+ Structural AST-aware rewrites via ast-grep. Use for codemods where text replace is unsafe. Mixed-language paths are fine: each file is parsed in its own language, and a pattern only rewrites files it parses in.
2
2
 
3
3
  - Metavariables in `pat` (`$A`, `$$$ARGS`) substitute into `out`.
4
4
  - **Patterns match AST structure, not text.** `$NAME` = one node; `$_` = unbound; `$$$NAME` = zero-or-more.
package/src/sdk.ts CHANGED
@@ -201,6 +201,7 @@ import { getImageGenTools } from "./tools/image-gen";
201
201
  import { wrapToolWithMetaNotice } from "./tools/output-meta";
202
202
  import { isAutoQaEnabled } from "./tools/report-tool-issue";
203
203
  import { queueResolveHandler } from "./tools/resolve";
204
+ import { USER_TODO_EDIT_CUSTOM_TYPE } from "./tools/todo";
204
205
  import { ttsTool } from "./tools/tts";
205
206
  import { resolveActiveRepoContext } from "./utils/active-repo-context";
206
207
  import { EventBus } from "./utils/event-bus";
@@ -2609,6 +2610,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2609
2610
  getTool: resolveDeviceTool,
2610
2611
  getToolContext: () => toolContextStore.getContext(),
2611
2612
  emitEvent: event => cursorEventEmitter?.(event),
2613
+ getTodoPhases: () => session.getTodoPhases(),
2614
+ setTodoPhases: phases => session.setTodoPhases(phases),
2615
+ persistTodoPhases: phases => sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases }),
2612
2616
  });
2613
2617
 
2614
2618
  // Resolve the inline-descriptors setting against the session-start model.
@@ -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 };