@oh-my-pi/pi-coding-agent 16.1.10 → 16.1.12

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 (63) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli.js +2480 -2481
  3. package/dist/types/export/html/index.d.ts +31 -2
  4. package/dist/types/export/html/web-palette.d.ts +117 -0
  5. package/dist/types/export/share.d.ts +10 -5
  6. package/dist/types/hindsight/content.d.ts +7 -0
  7. package/dist/types/hindsight/transcript.d.ts +1 -1
  8. package/dist/types/modes/components/hook-editor.d.ts +2 -1
  9. package/dist/types/modes/interactive-mode.d.ts +5 -0
  10. package/dist/types/modes/types.d.ts +17 -0
  11. package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
  12. package/dist/types/secrets/index.d.ts +1 -1
  13. package/dist/types/secrets/obfuscator.d.ts +43 -9
  14. package/dist/types/session/agent-session.d.ts +4 -0
  15. package/dist/types/session/session-context.d.ts +2 -0
  16. package/dist/types/session/session-entries.d.ts +6 -0
  17. package/dist/types/session/session-manager.d.ts +2 -1
  18. package/dist/types/tools/acp-bridge.d.ts +29 -0
  19. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +7 -0
  20. package/dist/types/tools/browser/tab-worker.d.ts +20 -0
  21. package/dist/types/utils/jj.d.ts +25 -0
  22. package/dist/types/utils/title-generator.d.ts +0 -2
  23. package/package.json +12 -12
  24. package/scripts/generate-share-viewer.ts +4 -2
  25. package/src/autoresearch/git.ts +12 -0
  26. package/src/discovery/opencode.ts +47 -4
  27. package/src/edit/hashline/filesystem.ts +8 -0
  28. package/src/edit/modes/patch.ts +18 -2
  29. package/src/edit/modes/replace.ts +13 -10
  30. package/src/export/html/index.ts +50 -8
  31. package/src/export/html/web-palette.ts +142 -0
  32. package/src/export/share.ts +198 -8
  33. package/src/hindsight/backend.ts +4 -4
  34. package/src/hindsight/content.ts +17 -1
  35. package/src/hindsight/transcript.ts +2 -2
  36. package/src/internal-urls/docs-index.generated.txt +1 -1
  37. package/src/main.ts +8 -0
  38. package/src/modes/components/agent-dashboard.ts +8 -8
  39. package/src/modes/components/hook-editor.ts +13 -10
  40. package/src/modes/components/session-selector.ts +3 -0
  41. package/src/modes/controllers/event-controller.ts +9 -5
  42. package/src/modes/controllers/extension-ui-controller.ts +6 -2
  43. package/src/modes/interactive-mode.ts +69 -29
  44. package/src/modes/theme/dark.json +1 -1
  45. package/src/modes/types.ts +18 -0
  46. package/src/modes/utils/keybinding-matchers.ts +36 -1
  47. package/src/modes/utils/ui-helpers.ts +1 -0
  48. package/src/prompts/tools/browser.md +3 -2
  49. package/src/sdk.ts +14 -2
  50. package/src/secrets/index.ts +1 -1
  51. package/src/secrets/obfuscator.ts +220 -71
  52. package/src/session/agent-session.ts +57 -43
  53. package/src/session/session-context.ts +5 -0
  54. package/src/session/session-entries.ts +6 -0
  55. package/src/session/session-manager.ts +3 -1
  56. package/src/task/worktree.ts +12 -4
  57. package/src/thinking.ts +1 -1
  58. package/src/tools/acp-bridge.ts +66 -0
  59. package/src/tools/browser/cmux/cmux-tab.ts +37 -0
  60. package/src/tools/browser/tab-worker.ts +160 -37
  61. package/src/tools/write.ts +2 -25
  62. package/src/utils/jj.ts +47 -0
  63. package/src/utils/title-generator.ts +31 -99
@@ -1,5 +1,5 @@
1
- import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
2
- import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { AssistantMessage, Context, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
3
3
  import type { SessionContext } from "../session/session-context";
4
4
  import { compileSecretRegex } from "./regex";
5
5
 
@@ -15,6 +15,9 @@ export interface SecretEntry {
15
15
  flags?: string;
16
16
  }
17
17
 
18
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue | undefined };
19
+ export type JsonRecord = { [key: string]: JsonValue | undefined };
20
+
18
21
  // ═══════════════════════════════════════════════════════════════════════════
19
22
  // Deterministic replacement generation
20
23
  // ═══════════════════════════════════════════════════════════════════════════
@@ -85,26 +88,34 @@ export class SecretObfuscator {
85
88
 
86
89
  constructor(entries: SecretEntry[]) {
87
90
  let index = 0;
91
+ let hasRealSec = false;
88
92
  for (const entry of entries) {
89
93
  const mode = entry.mode ?? "obfuscate";
90
94
 
91
95
  if (entry.type === "plain") {
92
96
  if (mode === "obfuscate") {
97
+ if (entry.content.length < 8) {
98
+ // Tone down short plain secret obfuscation to avoid false matches on small words like "esp"
99
+ continue;
100
+ }
93
101
  const placeholder = buildPlaceholder(index);
94
102
  this.#plainMappings.set(entry.content, index);
95
103
  this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
96
104
  this.#deobfuscateMap.set(placeholder, entry.content);
97
105
  index++;
106
+ hasRealSec = true;
98
107
  } else {
99
108
  // replace mode
100
109
  const replacement = entry.replacement ?? generateDeterministicReplacement(entry.content);
101
110
  this.#replaceMappings.set(entry.content, replacement);
111
+ hasRealSec = true;
102
112
  }
103
113
  } else {
104
114
  // regex type — compiled here, matches discovered during obfuscate()
105
115
  try {
106
116
  const regex = compileSecretRegex(entry.content, entry.flags);
107
117
  this.#regexEntries.push({ regex, mode, replacement: entry.replacement });
118
+ hasRealSec = true;
108
119
  } catch {
109
120
  // Invalid regex — skip silently (validation happens at load time)
110
121
  }
@@ -112,7 +123,7 @@ export class SecretObfuscator {
112
123
  }
113
124
 
114
125
  this.#nextIndex = index;
115
- this.#hasAny = entries.length > 0;
126
+ this.#hasAny = hasRealSec;
116
127
  }
117
128
 
118
129
  hasSecrets(): boolean {
@@ -154,6 +165,10 @@ export class SecretObfuscator {
154
165
  const replacement = entry.replacement ?? generateDeterministicReplacement(matchValue);
155
166
  result = replaceAll(result, matchValue, replacement);
156
167
  } else {
168
+ if (matchValue.length < 8) {
169
+ // Tone down short regex match obfuscation to avoid false matches on small words/fragments
170
+ continue;
171
+ }
157
172
  // obfuscate mode — get or create stable index
158
173
  let index = this.#findObfuscateIndex(matchValue);
159
174
  if (index === undefined) {
@@ -174,30 +189,13 @@ export class SecretObfuscator {
174
189
  /** Deobfuscate obfuscate-mode placeholders back to original secrets. Replace-mode is NOT reversed. */
175
190
  deobfuscate(text: string): string {
176
191
  if (!this.#hasAny || !text.includes("#")) return text;
177
- return text.replace(PLACEHOLDER_RE, match => {
178
- return this.#deobfuscateMap.get(match) ?? match;
179
- });
180
- }
181
-
182
- /** Deep-walk an object, deobfuscating all string values. */
183
- deobfuscateObject<T>(obj: T): T {
184
- if (!this.#hasAny) return obj;
185
- return deepWalkStrings(obj, s => this.deobfuscate(s));
186
- }
187
-
188
- /** Deep-walk an object, obfuscating all string values. */
189
- obfuscateObject<T>(obj: T): T {
190
- if (!this.#hasAny) return obj;
191
- return deepWalkStrings(obj, s => this.obfuscate(s));
192
+ return text.replace(PLACEHOLDER_RE, match => this.#deobfuscateMap.get(match) ?? match);
192
193
  }
193
-
194
194
  /** Find the obfuscate index for a known secret value. */
195
195
  #findObfuscateIndex(secret: string): number | undefined {
196
- // Check plain mappings first
197
196
  const plainIndex = this.#plainMappings.get(secret);
198
197
  if (plainIndex !== undefined) return plainIndex;
199
198
 
200
- // Check regex-discovered mappings
201
199
  for (const [index, mapping] of this.#obfuscateMappings) {
202
200
  if (mapping.secret === secret) return index;
203
201
  }
@@ -205,47 +203,200 @@ export class SecretObfuscator {
205
203
  }
206
204
  }
207
205
 
206
+ // ═══════════════════════════════════════════════════════════════════════════
207
+ // Display restore (inbound, persisted/provider → local display)
208
+ // ═══════════════════════════════════════════════════════════════════════════
209
+
210
+ /**
211
+ * Restore secret placeholders for local display. Only message kinds the model
212
+ * itself authored from obfuscated context carry placeholders — assistant
213
+ * content and the LLM-written branch/compaction summaries. User, developer, and
214
+ * tool-result messages are persisted with their literal text, so a literal
215
+ * `#ABCD#` the operator typed must survive untouched; those roles are never
216
+ * walked.
217
+ */
208
218
  export function deobfuscateSessionContext(
209
219
  sessionContext: SessionContext,
210
220
  obfuscator: SecretObfuscator | undefined,
211
221
  ): SessionContext {
212
222
  if (!obfuscator?.hasSecrets()) return sessionContext;
213
- const messages = obfuscator.deobfuscateObject(sessionContext.messages);
223
+ const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages);
214
224
  return messages === sessionContext.messages ? sessionContext : { ...sessionContext, messages };
215
225
  }
216
226
 
227
+ export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] {
228
+ let changed = false;
229
+ const result = messages.map((message): AgentMessage => {
230
+ switch (message.role) {
231
+ case "assistant": {
232
+ const content = deobfuscateAssistantContent(obfuscator, message.content);
233
+ if (content === message.content) return message;
234
+ changed = true;
235
+ return { ...message, content };
236
+ }
237
+ case "branchSummary": {
238
+ const summary = obfuscator.deobfuscate(message.summary);
239
+ if (summary === message.summary) return message;
240
+ changed = true;
241
+ return { ...message, summary };
242
+ }
243
+ case "compactionSummary": {
244
+ const summary = obfuscator.deobfuscate(message.summary);
245
+ const shortSummary =
246
+ message.shortSummary === undefined ? undefined : obfuscator.deobfuscate(message.shortSummary);
247
+ const blocks = message.blocks === undefined ? undefined : deobfuscateTextBlocks(obfuscator, message.blocks);
248
+ if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) {
249
+ return message;
250
+ }
251
+ changed = true;
252
+ return { ...message, summary, shortSummary, blocks };
253
+ }
254
+ default:
255
+ return message;
256
+ }
257
+ });
258
+ return changed ? result : messages;
259
+ }
260
+
261
+ /**
262
+ * Restore placeholders in assistant content: visible text, thinking text, and
263
+ * tool-call arguments/intent/rawBlock. Signatures and redacted-thinking bytes
264
+ * are opaque provider-replay data and pass through byte-identical.
265
+ */
266
+ export function deobfuscateAssistantContent(
267
+ obfuscator: SecretObfuscator,
268
+ content: AssistantMessage["content"],
269
+ ): AssistantMessage["content"] {
270
+ if (!obfuscator.hasSecrets()) return content;
271
+ let changed = false;
272
+ const result = content.map((block): AssistantMessage["content"][number] => {
273
+ if (block.type === "text") {
274
+ const text = obfuscator.deobfuscate(block.text);
275
+ if (text === block.text) return block;
276
+ changed = true;
277
+ return { ...block, text };
278
+ }
279
+ if (block.type === "thinking") {
280
+ const thinking = obfuscator.deobfuscate(block.thinking);
281
+ if (thinking === block.thinking) return block;
282
+ changed = true;
283
+ return { ...block, thinking };
284
+ }
285
+ if (block.type === "toolCall") {
286
+ const args = deobfuscateToolArguments(obfuscator, block.arguments);
287
+ const intent = block.intent === undefined ? undefined : obfuscator.deobfuscate(block.intent);
288
+ const rawBlock = block.rawBlock === undefined ? undefined : obfuscator.deobfuscate(block.rawBlock);
289
+ if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block;
290
+ changed = true;
291
+ return { ...block, arguments: args, intent, rawBlock };
292
+ }
293
+ return block;
294
+ });
295
+ return changed ? result : content;
296
+ }
297
+
298
+ /**
299
+ * Restore placeholders inside a tool call's arguments. Arguments are arbitrary
300
+ * model-authored JSON, so tool-call arguments are the ONLY place a recursive
301
+ * JSON walk runs.
302
+ */
303
+ export function deobfuscateToolArguments(
304
+ obfuscator: SecretObfuscator,
305
+ args: Record<string, unknown>,
306
+ ): Record<string, unknown> {
307
+ if (!obfuscator.hasSecrets()) return args;
308
+ return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record<string, unknown>;
309
+ }
310
+
311
+ /** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */
312
+ export function obfuscateToolArguments(
313
+ obfuscator: SecretObfuscator,
314
+ args: Record<string, unknown>,
315
+ ): Record<string, unknown> {
316
+ if (!obfuscator.hasSecrets()) return args;
317
+ return mapJsonStrings(args as JsonValue, s => obfuscator.obfuscate(s)) as Record<string, unknown>;
318
+ }
319
+
217
320
  // ═══════════════════════════════════════════════════════════════════════════
218
- // Message obfuscation (outbound to LLM)
321
+ // Outbound obfuscation (local provider)
219
322
  // ═══════════════════════════════════════════════════════════════════════════
220
323
 
221
- /** Obfuscate all string content in LLM messages (for outbound interception). */
324
+ type UserFacingMessage = Extract<Message, { role: "user" | "developer" | "toolResult" }>;
325
+
326
+ /** Obfuscate `text` blocks of a content array; image and other blocks pass through. */
327
+ function obfuscateTextBlocks(
328
+ obfuscator: SecretObfuscator,
329
+ content: (TextContent | ImageContent)[],
330
+ ): (TextContent | ImageContent)[] {
331
+ let changed = false;
332
+ const result = content.map((block): TextContent | ImageContent => {
333
+ if (block.type !== "text") return block;
334
+ const text = obfuscator.obfuscate(block.text);
335
+ if (text === block.text) return block;
336
+ changed = true;
337
+ return { ...block, text };
338
+ });
339
+ return changed ? result : content;
340
+ }
341
+
342
+ /** Restore placeholders in `text` blocks of a content array; image and other blocks pass through. */
343
+ function deobfuscateTextBlocks(
344
+ obfuscator: SecretObfuscator,
345
+ content: (TextContent | ImageContent)[],
346
+ ): (TextContent | ImageContent)[] {
347
+ let changed = false;
348
+ const result = content.map((block): TextContent | ImageContent => {
349
+ if (block.type !== "text") return block;
350
+ const text = obfuscator.deobfuscate(block.text);
351
+ if (text === block.text) return block;
352
+ changed = true;
353
+ return { ...block, text };
354
+ });
355
+ return changed ? result : content;
356
+ }
357
+
358
+ /**
359
+ * Redact secrets from outbound messages. Opt-in by origin: only user messages,
360
+ * tool results, and user-authored developer messages (e.g. `@file` mentions)
361
+ * can carry operator secrets. System prompts, tool schemas, and assistant
362
+ * output are author-controlled or model-generated and pass through untouched.
363
+ * Within a targeted message only `text` blocks are rewritten — inline image
364
+ * bytes are never walked.
365
+ */
222
366
  export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
223
- return obfuscator.obfuscateObject(messages);
367
+ if (!obfuscator.hasSecrets()) return messages;
368
+ let changed = false;
369
+ const result = messages.map((message): Message => {
370
+ if (
371
+ message.role !== "user" &&
372
+ message.role !== "toolResult" &&
373
+ !(message.role === "developer" && message.attribution === "user")
374
+ ) {
375
+ return message;
376
+ }
377
+ const target = message as UserFacingMessage;
378
+ if (typeof target.content === "string") {
379
+ const content = obfuscator.obfuscate(target.content);
380
+ if (content === target.content) return message;
381
+ changed = true;
382
+ return { ...target, content } as Message;
383
+ }
384
+ const content = obfuscateTextBlocks(obfuscator, target.content);
385
+ if (content === target.content) return message;
386
+ changed = true;
387
+ return { ...target, content } as Message;
388
+ });
389
+ return changed ? result : messages;
224
390
  }
225
391
 
226
- /** Obfuscate provider request context without walking live tool schema instances. */
392
+ /**
393
+ * Redact outbound provider context. Only conversation messages are rewritten;
394
+ * the static system prompt and tool schemas pass through unchanged.
395
+ */
227
396
  export function obfuscateProviderContext(obfuscator: SecretObfuscator | undefined, context: Context): Context {
228
397
  if (!obfuscator?.hasSecrets()) return context;
229
- return {
230
- ...context,
231
- systemPrompt: obfuscator.obfuscateObject(context.systemPrompt),
232
- messages: obfuscator.obfuscateObject(context.messages),
233
- tools: obfuscateProviderTools(obfuscator, context.tools),
234
- };
235
- }
236
-
237
- /** Convert tool schemas to wire JSON Schema before obfuscating provider-visible strings. */
238
- export function obfuscateProviderTools(
239
- obfuscator: SecretObfuscator | undefined,
240
- tools: Tool[] | undefined,
241
- ): Tool[] | undefined {
242
- if (!tools || !obfuscator?.hasSecrets()) return tools;
243
- return tools.map(tool => ({
244
- ...tool,
245
- description: obfuscator.obfuscate(tool.description),
246
- parameters: obfuscator.obfuscateObject(toolWireSchema(tool)),
247
- customFormat: tool.customFormat ? obfuscator.obfuscateObject(tool.customFormat) : undefined,
248
- }));
398
+ const messages = obfuscateMessages(obfuscator, context.messages);
399
+ return messages === context.messages ? context : { ...context, messages };
249
400
  }
250
401
 
251
402
  // ═══════════════════════════════════════════════════════════════════════════
@@ -264,35 +415,33 @@ function replaceAll(text: string, search: string, replacement: string): string {
264
415
  return result;
265
416
  }
266
417
 
267
- /** Deep-walk an object, transforming all string values. */
268
- function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
269
- if (typeof obj === "string") {
270
- return transform(obj) as unknown as T;
271
- }
272
- if (Array.isArray(obj)) {
418
+ /**
419
+ * Map every string in arbitrary JSON. Used ONLY for tool-call arguments, whose
420
+ * shape is model-authored and not known ahead of time. No other caller may walk
421
+ * untyped data: every message/content path is handled by a typed transformer.
422
+ */
423
+ function mapJsonStrings(value: JsonValue, fn: (s: string) => string): JsonValue {
424
+ if (typeof value === "string") return fn(value);
425
+ if (Array.isArray(value)) {
273
426
  let changed = false;
274
- const result = obj.map(item => {
275
- const transformed = deepWalkStrings(item, transform);
276
- if (transformed !== item) changed = true;
277
- return transformed;
427
+ const out = value.map(item => {
428
+ const next = mapJsonStrings(item, fn);
429
+ if (next !== item) changed = true;
430
+ return next;
278
431
  });
279
- return (changed ? result : obj) as unknown as T;
432
+ return changed ? out : value;
280
433
  }
281
- if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) {
434
+ if (value !== null && typeof value === "object") {
282
435
  let changed = false;
283
- const result: Record<string, unknown> = {};
284
- for (const key of Object.keys(obj)) {
285
- const value = (obj as Record<string, unknown>)[key];
286
- const transformed = deepWalkStrings(value, transform);
287
- if (transformed !== value) changed = true;
288
- result[key] = transformed;
436
+ const out: JsonRecord = {};
437
+ for (const key of Object.keys(value)) {
438
+ const item = value[key];
439
+ if (item === undefined) continue;
440
+ const next = mapJsonStrings(item, fn);
441
+ if (next !== item) changed = true;
442
+ out[key] = next;
289
443
  }
290
- return (changed ? result : obj) as T;
444
+ return changed ? out : value;
291
445
  }
292
- return obj;
293
- }
294
-
295
- function isPlainRecord(obj: object): obj is Record<string, unknown> {
296
- const prototype = Object.getPrototypeOf(obj);
297
- return prototype === Object.prototype || prototype === null;
446
+ return value;
298
447
  }
@@ -210,7 +210,7 @@ import { resolveMemoryBackend } from "../memory-backend";
210
210
  import { shutdownMnemopiEmbedClient } from "../mnemopi/embed-client";
211
211
  import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
212
212
  import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
213
- import { getCurrentThemeName, theme } from "../modes/theme/theme";
213
+ import { theme } from "../modes/theme/theme";
214
214
  import { parseTurnBudget } from "../modes/turn-budget";
215
215
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
216
216
  import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage";
@@ -234,9 +234,10 @@ import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { t
234
234
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
235
235
  import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
236
236
  import {
237
+ deobfuscateAssistantContent,
237
238
  deobfuscateSessionContext,
239
+ obfuscateMessages,
238
240
  obfuscateProviderContext,
239
- obfuscateProviderTools,
240
241
  type SecretObfuscator,
241
242
  } from "../secrets/obfuscator";
242
243
  import { invalidateHostMetadata } from "../ssh/connection-manager";
@@ -477,6 +478,8 @@ export interface AgentSessionConfig {
477
478
  modelRegistry: ModelRegistry;
478
479
  /** Tool registry for LSP and settings */
479
480
  toolRegistry?: Map<string, AgentTool>;
481
+ /** Tool names whose current registry entry is still the built-in implementation. */
482
+ builtInToolNames?: Iterable<string>;
480
483
  /** Current session pre-LLM message transform pipeline */
481
484
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
482
485
  /** Provider payload hook used by the active session request path */
@@ -1290,6 +1293,7 @@ export class AgentSession {
1290
1293
  // Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
1291
1294
  #discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
1292
1295
  #selectedDiscoveredToolNames = new Set<string>();
1296
+ #builtInToolNames = new Set<string>();
1293
1297
  #rpcHostToolNames = new Set<string>();
1294
1298
  #defaultSelectedMCPServerNames = new Set<string>();
1295
1299
  #defaultSelectedMCPToolNames = new Set<string>();
@@ -1566,6 +1570,7 @@ export class AgentSession {
1566
1570
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1567
1571
  this.#validateRetryFallbackChains();
1568
1572
  this.#toolRegistry = config.toolRegistry ?? new Map();
1573
+ this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
1569
1574
  this.#requestedToolNames = config.requestedToolNames;
1570
1575
  this.#transformContext = config.transformContext ?? (messages => messages);
1571
1576
  this.#onPayload = config.onPayload;
@@ -2496,7 +2501,7 @@ export class AgentSession {
2496
2501
  const obfuscator = this.#obfuscator;
2497
2502
  if (obfuscator && event.type === "message_end" && event.message.role === "assistant") {
2498
2503
  const message = event.message;
2499
- const deobfuscatedContent = obfuscator.deobfuscateObject(message.content);
2504
+ const deobfuscatedContent = deobfuscateAssistantContent(obfuscator, message.content);
2500
2505
  if (deobfuscatedContent !== message.content) {
2501
2506
  displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } };
2502
2507
  }
@@ -4496,6 +4501,11 @@ export class AgentSession {
4496
4501
  return this.#toolRegistry.get(name);
4497
4502
  }
4498
4503
 
4504
+ /** True when the current registry entry for `name` came from a built-in factory. */
4505
+ hasBuiltInTool(name: string): boolean {
4506
+ return this.#builtInToolNames.has(name);
4507
+ }
4508
+
4499
4509
  /**
4500
4510
  * Get all configured tool names (built-in via --tools or default, plus custom tools).
4501
4511
  */
@@ -5223,28 +5233,17 @@ export class AgentSession {
5223
5233
  return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
5224
5234
  }
5225
5235
 
5226
- #obfuscateForProvider<T>(value: T): T {
5227
- if (!this.#obfuscator?.hasSecrets()) return value;
5228
- return this.#obfuscator.obfuscateObject(value);
5229
- }
5230
-
5231
5236
  #obfuscateTextForProvider(text: string | undefined): string | undefined {
5232
5237
  if (!text || !this.#obfuscator?.hasSecrets()) return text;
5233
5238
  return this.#obfuscator.obfuscate(text);
5234
5239
  }
5235
5240
 
5236
5241
  #obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
5237
- if (!this.#obfuscator?.hasSecrets()) return preparation;
5238
- if (!preparation.previousSummary && !preparation.previousPreserveData) return preparation;
5239
- return {
5240
- ...preparation,
5241
- previousSummary: preparation.previousSummary
5242
- ? this.#obfuscator.obfuscate(preparation.previousSummary)
5243
- : preparation.previousSummary,
5244
- previousPreserveData: preparation.previousPreserveData
5245
- ? this.#obfuscator.obfuscateObject(preparation.previousPreserveData)
5246
- : preparation.previousPreserveData,
5247
- };
5242
+ if (!this.#obfuscator?.hasSecrets() || !preparation.previousSummary) return preparation;
5243
+ // `previousPreserveData` is opaque provider-replay state (e.g. OpenAI remote-compaction
5244
+ // `encrypted_content`); rewriting it would corrupt replay, so only the plaintext summary
5245
+ // is redacted.
5246
+ return { ...preparation, previousSummary: this.#obfuscator.obfuscate(preparation.previousSummary) };
5248
5247
  }
5249
5248
 
5250
5249
  #deobfuscateFromProvider(text: string): string {
@@ -5261,7 +5260,8 @@ export class AgentSession {
5261
5260
  }
5262
5261
 
5263
5262
  #convertToLlmForSideRequest(messages: AgentMessage[]): Message[] {
5264
- return this.#obfuscateForProvider(convertToLlm(messages));
5263
+ const converted = convertToLlm(messages);
5264
+ return this.#obfuscator?.hasSecrets() ? obfuscateMessages(this.#obfuscator, converted) : converted;
5265
5265
  }
5266
5266
 
5267
5267
  /** Convert session messages using the same pre-LLM pipeline as the active session. */
@@ -6835,7 +6835,7 @@ export class AgentSession {
6835
6835
  this.#pendingNextTurnMessages = [];
6836
6836
  this.#scheduledHiddenNextTurnGeneration = undefined;
6837
6837
 
6838
- this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
6838
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel());
6839
6839
  this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
6840
6840
  if (nextDiscoverySessionToolNames) {
6841
6841
  await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
@@ -7234,16 +7234,20 @@ export class AgentSession {
7234
7234
  return;
7235
7235
  }
7236
7236
 
7237
+ const wasAuto = this.#autoThinking;
7237
7238
  this.#autoThinking = false;
7238
7239
  this.#autoResolvedLevel = undefined;
7239
7240
  const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
7240
- const isChanging = effectiveLevel !== this.#thinkingLevel;
7241
+ // Leaving auto must persist even when the resolved effort is unchanged (e.g.
7242
+ // auto resolved to medium, then the user pins medium): otherwise the latest
7243
+ // session entry keeps `configured: "auto"` and resume re-enables auto.
7244
+ const isChanging = wasAuto || effectiveLevel !== this.#thinkingLevel;
7241
7245
 
7242
7246
  this.#thinkingLevel = effectiveLevel;
7243
7247
  this.#applyThinkingLevelToAgent(effectiveLevel);
7244
7248
 
7245
7249
  if (isChanging) {
7246
- this.sessionManager.appendThinkingLevelChange(effectiveLevel);
7250
+ this.sessionManager.appendThinkingLevelChange(effectiveLevel, effectiveLevel);
7247
7251
  if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
7248
7252
  this.settings.set("defaultThinkingLevel", effectiveLevel);
7249
7253
  }
@@ -7332,7 +7336,7 @@ export class AgentSession {
7332
7336
  this.#thinkingLevel = effort;
7333
7337
  this.#applyThinkingLevelToAgent(effort);
7334
7338
  if (shouldPersistResolution) {
7335
- this.sessionManager.appendThinkingLevelChange(effort);
7339
+ this.sessionManager.appendThinkingLevelChange(effort, AUTO_THINKING);
7336
7340
  }
7337
7341
  this.#emit({
7338
7342
  type: "thinking_level_changed",
@@ -7852,8 +7856,8 @@ export class AgentSession {
7852
7856
  compactionAbortController.signal,
7853
7857
  {
7854
7858
  promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
7855
- extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
7856
- remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
7859
+ extraContext: compactionPrep.hookContext,
7860
+ remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
7857
7861
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
7858
7862
  },
7859
7863
  );
@@ -8047,11 +8051,10 @@ export class AgentSession {
8047
8051
  model,
8048
8052
  this.#modelRegistry.resolver(model, this.sessionId),
8049
8053
  {
8050
- systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
8051
- tools: obfuscateProviderTools(
8052
- this.#obfuscator,
8053
- this.#pruneToolDescriptions ? stripToolDescriptions(this.agent.state.tools) : this.agent.state.tools,
8054
- ),
8054
+ systemPrompt: this.#baseSystemPrompt,
8055
+ tools: this.#pruneToolDescriptions
8056
+ ? stripToolDescriptions(this.agent.state.tools)
8057
+ : this.agent.state.tools,
8055
8058
  customInstructions: this.#obfuscateTextForProvider(customInstructions),
8056
8059
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
8057
8060
  initiatorOverride: "agent",
@@ -9739,8 +9742,8 @@ export class AgentSession {
9739
9742
  autoCompactionSignal,
9740
9743
  {
9741
9744
  promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
9742
- extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
9743
- remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
9745
+ extraContext: compactionPrep.hookContext,
9746
+ remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
9744
9747
  metadata: this.agent.metadataForProvider(candidate.provider),
9745
9748
  initiatorOverride: "agent",
9746
9749
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
@@ -11366,7 +11369,7 @@ export class AgentSession {
11366
11369
  }
11367
11370
  if (event.type === "done") {
11368
11371
  assistantMessage = this.#obfuscator?.hasSecrets()
11369
- ? { ...event.message, content: this.#obfuscator.deobfuscateObject(event.message.content) }
11372
+ ? { ...event.message, content: deobfuscateAssistantContent(this.#obfuscator, event.message.content) }
11370
11373
  : event.message;
11371
11374
  break;
11372
11375
  }
@@ -11608,18 +11611,26 @@ export class AgentSession {
11608
11611
  .some(entry => entry.type === "service_tier_change");
11609
11612
  const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
11610
11613
  const configuredServiceTier = this.settings.get("serviceTier");
11611
- // Session log entries store only concrete levels. When `auto` has resolved
11612
- // for a turn, the persisted context may already carry that concrete level
11613
- // even if the branch scan races a just-flushed thinking entry under isolated
11614
- // parallel test workers. Prefer the concrete context value in that case;
11615
- // otherwise keep the configured `auto` selector so fresh sessions still
11616
- // classify their first turn.
11614
+ // Restore the thinking selector. Each change persists the configured
11615
+ // selector (`auto` or a concrete level), so prefer it: an `auto` session
11616
+ // resumes in auto mode (reclassifying the next turn) instead of freezing at
11617
+ // the last resolved level. Entries written before the `configured` field
11618
+ // existed fall back to the concrete level (legacy pin-on-resume behavior).
11619
+ // With no thinking entry, fall back to the global default so fresh sessions
11620
+ // still classify their first turn.
11621
+ const restoredConfigured = sessionContext.configuredThinkingLevel;
11617
11622
  const restoredThinkingLevel: ConfiguredThinkingLevel | undefined =
11618
11623
  hasThinkingEntry || (defaultThinkingLevel === AUTO_THINKING && sessionContext.thinkingLevel !== "off")
11619
- ? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
11624
+ ? restoredConfigured === AUTO_THINKING
11625
+ ? AUTO_THINKING
11626
+ : (sessionContext.thinkingLevel as ThinkingLevel | undefined)
11620
11627
  : defaultThinkingLevel;
11621
11628
  if (restoredThinkingLevel === AUTO_THINKING) {
11622
11629
  this.#autoThinking = true;
11630
+ // Resume in auto (pending) like a fresh auto session: the next user
11631
+ // turn reclassifies. We intentionally do not seed the last resolved
11632
+ // effort, so the cold (--continue) and in-app switch paths display
11633
+ // identically as `auto` until then.
11623
11634
  this.#autoResolvedLevel = undefined;
11624
11635
  this.#thinkingLevel = resolveProvisionalAutoLevel(this.model);
11625
11636
  } else {
@@ -12513,9 +12524,12 @@ export class AgentSession {
12513
12524
  * @returns Path to exported file
12514
12525
  */
12515
12526
  async exportToHtml(outputPath?: string): Promise<string> {
12516
- const themeName = getCurrentThemeName();
12527
+ // Public HTML export ships in the omp brand palette (collab-web
12528
+ // pink/purple), matching my.omp.sh — not the host's terminal theme.
12529
+ // Callers who want a themed export can pass `palette: "theme"` with
12530
+ // `themeName` directly to `exportSessionToHtml`.
12517
12531
  const { exportSessionToHtml } = await import("../export/html");
12518
- return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
12532
+ return exportSessionToHtml(this.sessionManager, this.state, { outputPath, palette: "web" });
12519
12533
  }
12520
12534
 
12521
12535
  // =========================================================================
@@ -7,6 +7,8 @@ import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry }
7
7
  export interface SessionContext {
8
8
  messages: AgentMessage[];
9
9
  thinkingLevel?: string;
10
+ /** Configured thinking selector (`"auto"` or a concrete level) from the latest change. */
11
+ configuredThinkingLevel?: string;
10
12
  serviceTier?: ServiceTier;
11
13
  /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
12
14
  models: Record<string, string>;
@@ -134,6 +136,7 @@ export function buildSessionContext(
134
136
 
135
137
  // Extract settings and find compaction
136
138
  let thinkingLevel: string | undefined = "off";
139
+ let configuredThinkingLevel: string | undefined;
137
140
  let serviceTier: ServiceTier | undefined;
138
141
  const models: Record<string, string> = {};
139
142
  let compaction: CompactionEntry | null = null;
@@ -154,6 +157,7 @@ export function buildSessionContext(
154
157
  for (const entry of path) {
155
158
  if (entry.type === "thinking_level_change") {
156
159
  thinkingLevel = entry.thinkingLevel ?? "off";
160
+ configuredThinkingLevel = entry.configured ?? entry.thinkingLevel ?? undefined;
157
161
  } else if (entry.type === "model_change") {
158
162
  // New format: { model: "provider/id", role?: string }
159
163
  if (entry.model) {
@@ -388,6 +392,7 @@ export function buildSessionContext(
388
392
  messages,
389
393
  cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined,
390
394
  thinkingLevel,
395
+ configuredThinkingLevel,
391
396
  serviceTier,
392
397
  models,
393
398
  injectedTtsrRules,