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

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 (58) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +2811 -2812
  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/hindsight/content.d.ts +7 -0
  6. package/dist/types/hindsight/transcript.d.ts +1 -1
  7. package/dist/types/modes/components/hook-editor.d.ts +2 -1
  8. package/dist/types/modes/interactive-mode.d.ts +5 -0
  9. package/dist/types/modes/types.d.ts +17 -0
  10. package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
  11. package/dist/types/session/agent-session.d.ts +4 -0
  12. package/dist/types/session/session-context.d.ts +2 -0
  13. package/dist/types/session/session-entries.d.ts +6 -0
  14. package/dist/types/session/session-manager.d.ts +2 -1
  15. package/dist/types/tools/acp-bridge.d.ts +29 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +7 -0
  17. package/dist/types/tools/browser/tab-worker.d.ts +20 -0
  18. package/dist/types/utils/image-loading.d.ts +4 -3
  19. package/dist/types/utils/jj.d.ts +25 -0
  20. package/dist/types/utils/title-generator.d.ts +0 -2
  21. package/package.json +12 -12
  22. package/scripts/generate-share-viewer.ts +4 -2
  23. package/src/autoresearch/git.ts +12 -0
  24. package/src/discovery/opencode.ts +47 -4
  25. package/src/edit/hashline/filesystem.ts +8 -0
  26. package/src/edit/modes/patch.ts +18 -2
  27. package/src/edit/modes/replace.ts +13 -10
  28. package/src/export/html/index.ts +50 -8
  29. package/src/export/html/web-palette.ts +142 -0
  30. package/src/hindsight/backend.ts +4 -4
  31. package/src/hindsight/content.ts +17 -1
  32. package/src/hindsight/transcript.ts +2 -2
  33. package/src/internal-urls/docs-index.generated.txt +1 -1
  34. package/src/modes/components/agent-dashboard.ts +8 -8
  35. package/src/modes/components/hook-editor.ts +13 -10
  36. package/src/modes/components/session-selector.ts +3 -0
  37. package/src/modes/controllers/event-controller.ts +9 -5
  38. package/src/modes/controllers/extension-ui-controller.ts +6 -2
  39. package/src/modes/interactive-mode.ts +69 -29
  40. package/src/modes/theme/dark.json +1 -1
  41. package/src/modes/types.ts +18 -0
  42. package/src/modes/utils/keybinding-matchers.ts +36 -1
  43. package/src/modes/utils/ui-helpers.ts +1 -0
  44. package/src/prompts/tools/browser.md +3 -2
  45. package/src/sdk.ts +12 -1
  46. package/src/session/agent-session.ts +38 -14
  47. package/src/session/session-context.ts +5 -0
  48. package/src/session/session-entries.ts +6 -0
  49. package/src/session/session-manager.ts +3 -1
  50. package/src/task/worktree.ts +12 -4
  51. package/src/thinking.ts +1 -1
  52. package/src/tools/acp-bridge.ts +66 -0
  53. package/src/tools/browser/cmux/cmux-tab.ts +37 -0
  54. package/src/tools/browser/tab-worker.ts +160 -37
  55. package/src/tools/write.ts +2 -25
  56. package/src/utils/image-loading.ts +6 -3
  57. package/src/utils/jj.ts +47 -0
  58. package/src/utils/title-generator.ts +31 -99
@@ -52,7 +52,12 @@ import { discoverAgents } from "../../task/discovery";
52
52
  import type { AgentDefinition, AgentSource } from "../../task/types";
53
53
  import { shortenPath } from "../../tools/render-utils";
54
54
  import { getEditorTheme, theme } from "../theme/theme";
55
- import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
55
+ import {
56
+ matchesAppFollowUp,
57
+ matchesAppInterrupt,
58
+ matchesSelectDown,
59
+ matchesSelectUp,
60
+ } from "../utils/keybinding-matchers";
56
61
  import { DynamicBorder } from "./dynamic-border";
57
62
 
58
63
  type SourceTabId = "all" | AgentSource;
@@ -652,11 +657,6 @@ export class AgentDashboard extends Container {
652
657
  this.#buildLayout();
653
658
  }
654
659
 
655
- #shouldSubmitCreateDescription(data: string): boolean {
656
- if (matchesKey(data, "ctrl+enter")) return true;
657
- return process.platform === "win32" && data === "\n" && this.#createDescription.trim().length > 0;
658
- }
659
-
660
660
  async #generateAgentFromDescription(rawDescription: string): Promise<void> {
661
661
  const description = rawDescription.trim();
662
662
  this.#createDescription = description;
@@ -911,7 +911,7 @@ export class AgentDashboard extends Container {
911
911
  this.addChild(new Spacer(1));
912
912
  const hints = this.#createGenerating
913
913
  ? " Generating..."
914
- : " Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel";
914
+ : " Ctrl+Q/Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel";
915
915
  this.addChild(new Text(theme.fg("dim", hints), 0, 0));
916
916
  }
917
917
 
@@ -1102,7 +1102,7 @@ export class AgentDashboard extends Container {
1102
1102
  }
1103
1103
  return;
1104
1104
  }
1105
- if (!this.#createGenerating && this.#shouldSubmitCreateDescription(data)) {
1105
+ if (!this.#createGenerating && matchesAppFollowUp(data)) {
1106
1106
  this.#submitCreateDescription();
1107
1107
  return;
1108
1108
  }
@@ -3,12 +3,17 @@
3
3
  * Supports Ctrl+G for external editor.
4
4
  *
5
5
  * Two modes:
6
- * - Default (hook): Enter inserts newline, Ctrl+Enter submits, bordered popup
6
+ * - Default (hook): Enter inserts newline, the `app.message.followUp` chord
7
+ * (Ctrl+Q / Ctrl+Enter) submits, bordered popup
7
8
  * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome
8
9
  */
9
10
  import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
11
  import { getEditorTheme, theme } from "../../modes/theme/theme";
11
- import { matchesAppExternalEditor, matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
12
+ import {
13
+ matchesAppExternalEditor,
14
+ matchesAppFollowUp,
15
+ matchesAppInterrupt,
16
+ } from "../../modes/utils/keybinding-matchers";
12
17
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
13
18
  import { DynamicBorder } from "./dynamic-border";
14
19
 
@@ -17,10 +22,6 @@ export interface HookEditorOptions {
17
22
  promptStyle?: boolean;
18
23
  }
19
24
 
20
- function isCtrlEnterSubmit(keyData: string): boolean {
21
- return matchesKey(keyData, "ctrl+enter") || (keyData.charCodeAt(0) === 10 && keyData.length > 1);
22
- }
23
-
24
25
  export class HookEditorComponent extends Container {
25
26
  #editor: Editor;
26
27
  #onSubmitCallback: (value: string) => void;
@@ -67,7 +68,7 @@ export class HookEditorComponent extends Container {
67
68
  // Hint
68
69
  const hint = this.#promptStyle
69
70
  ? "enter submit esc cancel ctrl+g external editor"
70
- : "ctrl+enter submit esc cancel ctrl+g external editor";
71
+ : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
71
72
  this.addChild(new Text(theme.fg("dim", hint), 1, 0));
72
73
 
73
74
  this.addChild(new Spacer(1));
@@ -118,10 +119,12 @@ export class HookEditorComponent extends Container {
118
119
  this.#editor.handleInput(keyData);
119
120
  }
120
121
 
121
- /** Hook-style: Enter=newline, Ctrl+Enter=submit (original behavior) */
122
+ /** Hook-style: Enter=newline, app.message.followUp chord (Ctrl+Q/Ctrl+Enter) submits. */
122
123
  #handleHookStyleInput(keyData: string): void {
123
- // Ctrl+Enter to submit. Use key matching so lock-key and keypad Enter variants work.
124
- if (isCtrlEnterSubmit(keyData)) {
124
+ // Submit on the follow-up chord. Uses the shared keybinding so Ctrl+Q works
125
+ // on Windows Terminal (#1903) and any user remap of `app.message.followUp`
126
+ // applies here too.
127
+ if (matchesAppFollowUp(keyData)) {
125
128
  this.#submitCurrentText();
126
129
  return;
127
130
  }
@@ -353,6 +353,9 @@ class SessionList implements Component {
353
353
  if (status) {
354
354
  metadata += ` ${dot} ${status}`;
355
355
  }
356
+ if (session.parentSessionPath) {
357
+ metadata += ` ${dot} ${dim(`${theme.icon.branch} fork`)}`;
358
+ }
356
359
  if (this.#showCwd && session.cwd) {
357
360
  metadata += ` ${dot} ${dim(shortenPath(session.cwd))}`;
358
361
  }
@@ -296,8 +296,15 @@ export class EventController {
296
296
  this.#resetReadGroup();
297
297
  this.#resolveDisplaceablePoll();
298
298
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
299
- const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
300
- if (!wasOptimistic) {
299
+ const matchedLocalSubmission = this.ctx.locallySubmittedUserSignatures.delete(signature);
300
+ const replacesOptimistic =
301
+ this.ctx.optimisticUserMessageSignature !== undefined && !wasOptimistic && !matchedLocalSubmission;
302
+ const wasLocallySubmitted = matchedLocalSubmission || wasOptimistic || replacesOptimistic;
303
+ if (wasOptimistic) {
304
+ this.ctx.clearOptimisticUserMessage();
305
+ } else if (replacesOptimistic) {
306
+ this.ctx.replaceOptimisticUserMessage(event.message);
307
+ } else {
301
308
  // Append synchronously: #emit dispatches to this listener fire-and-forget
302
309
  // (see AgentSession.#emit), so any await between the user message_start and
303
310
  // addMessageToChat lets later events (assistant message_start, tool execution
@@ -306,9 +313,6 @@ export class EventController {
306
313
  // links via the synchronous putBlobSync fallback, so no await is needed here.
307
314
  this.ctx.addMessageToChat(event.message);
308
315
  }
309
- if (wasOptimistic) {
310
- this.ctx.optimisticUserMessageSignature = undefined;
311
- }
312
316
 
313
317
  // Clear the editor only when the submission did not originate from a
314
318
  // local submission (optimistic or queued-while-streaming). Both local
@@ -810,8 +810,12 @@ export class ExtensionUiController {
810
810
 
811
811
  #applyCustomMessageDisplay(wasStreaming: boolean, shouldDisplay: boolean | undefined): void {
812
812
  // For non-streaming cases with display=true, update UI
813
- // (streaming cases update via message_end event)
814
- if (!wasStreaming && shouldDisplay) {
813
+ // (streaming cases update via message_end event).
814
+ // Gate on initialChatRendered (#1955): an extension's session_start
815
+ // sendMessage({display:true}) runs before renderInitialMessages, which would
816
+ // re-render from session entries AND re-append via preserveExistingChat,
817
+ // duplicating the message. After the initial render the rebuild must run.
818
+ if (!wasStreaming && shouldDisplay && this.ctx.initialChatRendered) {
815
819
  this.ctx.rebuildChatFromMessages();
816
820
  }
817
821
  }
@@ -390,6 +390,7 @@ export class InteractiveMode implements InteractiveModeContext {
390
390
  statusLine: StatusLineComponent;
391
391
 
392
392
  isInitialized = false;
393
+ initialChatRendered = false;
393
394
  isBashMode = false;
394
395
  toolOutputExpanded = false;
395
396
  todoExpanded = false;
@@ -435,6 +436,7 @@ export class InteractiveMode implements InteractiveModeContext {
435
436
  locallySubmittedUserSignatures: Set<string> = new Set();
436
437
  #pendingSubmittedInput: SubmittedUserInput | undefined;
437
438
  #pendingSubmissionDispose: (() => void) | undefined;
439
+ #optimisticUserMessageComponents: Component[] = [];
438
440
  lastSigintTime = 0;
439
441
  lastEscapeTime = 0;
440
442
  lastLeftTapTime = 0;
@@ -1247,6 +1249,32 @@ export class InteractiveMode implements InteractiveModeContext {
1247
1249
  throw err;
1248
1250
  }
1249
1251
  }
1252
+ #captureAddedChatComponents(render: () => void): Component[] {
1253
+ const start = this.chatContainer.children.length;
1254
+ render();
1255
+ return this.chatContainer.children.slice(start);
1256
+ }
1257
+
1258
+ clearOptimisticUserMessage(): void {
1259
+ this.optimisticUserMessageSignature = undefined;
1260
+ this.#pendingSubmissionDispose?.();
1261
+ this.#pendingSubmissionDispose = undefined;
1262
+ this.#optimisticUserMessageComponents = [];
1263
+ }
1264
+
1265
+ replaceOptimisticUserMessage(
1266
+ message: AgentMessage,
1267
+ options?: { imageLinks?: readonly (string | undefined)[] },
1268
+ ): void {
1269
+ this.optimisticUserMessageSignature = undefined;
1270
+ this.#pendingSubmissionDispose?.();
1271
+ this.#pendingSubmissionDispose = undefined;
1272
+ for (const component of this.#optimisticUserMessageComponents) {
1273
+ this.chatContainer.removeChild(component);
1274
+ }
1275
+ this.#optimisticUserMessageComponents = [];
1276
+ this.addMessageToChat(message, options);
1277
+ }
1250
1278
 
1251
1279
  startPendingSubmission(input: {
1252
1280
  text: string;
@@ -1272,18 +1300,19 @@ export class InteractiveMode implements InteractiveModeContext {
1272
1300
  const imageCount = submission.images?.length ?? 0;
1273
1301
  this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
1274
1302
  this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
1275
- this.addMessageToChat(
1276
- {
1277
- role: "user",
1278
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1279
- attribution: "user",
1280
- timestamp: Date.now(),
1281
- },
1282
- { imageLinks: input.imageLinks },
1283
- );
1303
+ this.#optimisticUserMessageComponents = this.#captureAddedChatComponents(() => {
1304
+ this.addMessageToChat(
1305
+ {
1306
+ role: "user",
1307
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1308
+ attribution: "user",
1309
+ timestamp: Date.now(),
1310
+ },
1311
+ { imageLinks: input.imageLinks },
1312
+ );
1313
+ });
1284
1314
  } else {
1285
- this.optimisticUserMessageSignature = undefined;
1286
- this.#pendingSubmissionDispose = undefined;
1315
+ this.clearOptimisticUserMessage();
1287
1316
  }
1288
1317
  this.editor.setText("");
1289
1318
  this.editor.imageLinks = undefined;
@@ -1300,9 +1329,7 @@ export class InteractiveMode implements InteractiveModeContext {
1300
1329
 
1301
1330
  submission.cancelled = true;
1302
1331
  this.#pendingSubmittedInput = undefined;
1303
- this.optimisticUserMessageSignature = undefined;
1304
- this.#pendingSubmissionDispose?.();
1305
- this.#pendingSubmissionDispose = undefined;
1332
+ this.clearOptimisticUserMessage();
1306
1333
  this.#pendingWorkingMessage = undefined;
1307
1334
  if (submission.customType === "goal-continuation") {
1308
1335
  this.#goalContinuationTurnInFlight = false;
@@ -1344,6 +1371,7 @@ export class InteractiveMode implements InteractiveModeContext {
1344
1371
  if (wasPendingSubmission && !this.session.isStreaming && !this.streamingComponent) {
1345
1372
  this.optimisticUserMessageSignature = undefined;
1346
1373
  pendingSubmissionDispose?.();
1374
+ this.#optimisticUserMessageComponents = [];
1347
1375
  this.#pendingWorkingMessage = undefined;
1348
1376
  if (this.loadingAnimation) {
1349
1377
  this.#stopLoadingAnimation(true);
@@ -1434,15 +1462,17 @@ export class InteractiveMode implements InteractiveModeContext {
1434
1462
  if (!this.optimisticUserMessageSignature) return;
1435
1463
  const submission = this.#pendingSubmittedInput;
1436
1464
  if (!submission || submission.cancelled || submission.customType) return;
1437
- this.addMessageToChat(
1438
- {
1439
- role: "user",
1440
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1441
- attribution: "user",
1442
- timestamp: Date.now(),
1443
- },
1444
- { imageLinks: submission.imageLinks },
1445
- );
1465
+ this.#optimisticUserMessageComponents = this.#captureAddedChatComponents(() => {
1466
+ this.addMessageToChat(
1467
+ {
1468
+ role: "user",
1469
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1470
+ attribution: "user",
1471
+ timestamp: Date.now(),
1472
+ },
1473
+ { imageLinks: submission.imageLinks },
1474
+ );
1475
+ });
1446
1476
  }
1447
1477
 
1448
1478
  #formatTodoLine(todo: TodoItem, prefix: string, matched: boolean): string {
@@ -1926,9 +1956,21 @@ export class InteractiveMode implements InteractiveModeContext {
1926
1956
 
1927
1957
  const planFilePath = options?.planFilePath ?? (await this.#getPlanFilePath());
1928
1958
  const previousTools = this.session.getActiveToolNames();
1929
- const hasResolveTool = this.session.getToolByName("resolve") !== undefined;
1930
- const planTools = hasResolveTool ? [...previousTools, "resolve"] : previousTools;
1931
- const uniquePlanTools = [...new Set(planTools)];
1959
+ // `plan-mode-active.md` instructs the agent to draft the plan file with
1960
+ // `write` and refine it with `edit`. Both must be in the active set or the
1961
+ // agent falls back to `edit` on a non-existent file and stalls. `edit` is an
1962
+ // essential built-in so it survives `tools.discoveryMode === "all"`, but
1963
+ // `write` has `loadMode: "discoverable"` and is hidden behind
1964
+ // `search_tool_bm25` — re-activate it here only when the current registry
1965
+ // entry is the built-in write tool (issue #3165). A shadowing extension
1966
+ // tool named `write` must stay inactive because plan mode's read-only
1967
+ // guarantee relies on the built-in write/edit guard. `resolve` is hidden
1968
+ // too; the standing handler below consumes plan-approval calls through it.
1969
+ const planAugmentations = ["resolve"];
1970
+ if (this.session.hasBuiltInTool("write")) {
1971
+ planAugmentations.push("write");
1972
+ }
1973
+ const uniquePlanTools = [...new Set([...previousTools, ...planAugmentations])];
1932
1974
 
1933
1975
  this.#planModePreviousTools = previousTools;
1934
1976
  this.planModePlanFilePath = planFilePath;
@@ -3244,9 +3286,7 @@ export class InteractiveMode implements InteractiveModeContext {
3244
3286
 
3245
3287
  showError(message: string): void {
3246
3288
  this.#pendingSubmittedInput = undefined;
3247
- this.optimisticUserMessageSignature = undefined;
3248
- this.#pendingSubmissionDispose?.();
3249
- this.#pendingSubmissionDispose = undefined;
3289
+ this.clearOptimisticUserMessage();
3250
3290
  this.#pendingWorkingMessage = undefined;
3251
3291
  if (this.loadingAnimation) {
3252
3292
  this.#stopLoadingAnimation(true);
@@ -90,6 +90,6 @@
90
90
  "export": {
91
91
  "pageBg": "#18181e",
92
92
  "cardBg": "#1e1e24",
93
- "infoBg": "#3c3728"
93
+ "infoBg": "#26262e"
94
94
  }
95
95
  }
@@ -137,6 +137,17 @@ export interface InteractiveModeContext {
137
137
 
138
138
  // State
139
139
  isInitialized: boolean;
140
+ /**
141
+ * `true` once `renderInitialMessages` has rendered the session transcript
142
+ * into `chatContainer` at least once.
143
+ *
144
+ * Extension chat-rebuilds (`ExtensionUiController.#applyCustomMessageDisplay`)
145
+ * are gated on this: rebuilding before the initial render would plant a
146
+ * session-derived component into the chat that `renderInitialMessages` then
147
+ * both re-renders from session entries AND re-appends via
148
+ * `preserveExistingChat`, duplicating the message (issue #1955).
149
+ */
150
+ initialChatRendered: boolean;
140
151
  isBashMode: boolean;
141
152
  toolOutputExpanded: boolean;
142
153
  todoExpanded: boolean;
@@ -258,6 +269,13 @@ export interface InteractiveModeContext {
258
269
  * delivery error should leave the signature set untouched.
259
270
  */
260
271
  withLocalSubmission<T>(text: string, fn: () => Promise<T>, options?: { imageCount?: number }): Promise<T>;
272
+ /** Clears bookkeeping for an optimistic local user message once the matching session event arrives. */
273
+ clearOptimisticUserMessage(): void;
274
+ /** Replaces the raw optimistic user render with the canonical message emitted by the session. */
275
+ replaceOptimisticUserMessage(
276
+ message: AgentMessage,
277
+ options?: { imageLinks?: readonly (string | undefined)[] },
278
+ ): void;
261
279
  isKnownSlashCommand(text: string): boolean;
262
280
  addMessageToChat(
263
281
  message: AgentMessage,
@@ -1,4 +1,4 @@
1
- import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
1
+ import { getKeybindings, type KeyId, matchesKey } from "@oh-my-pi/pi-tui";
2
2
 
3
3
  /**
4
4
  * Match the coding-agent interrupt key.
@@ -49,3 +49,38 @@ export function matchesAppExternalEditor(data: string): boolean {
49
49
  }
50
50
  return matchesKey(data, "ctrl+g");
51
51
  }
52
+
53
+ function matchesEffectiveKey(data: string, key: KeyId): boolean {
54
+ if ((key === "ctrl+enter" || key === "ctrl+return") && data.charCodeAt(0) === 10 && data.length > 1) {
55
+ return true;
56
+ }
57
+ return matchesKey(data, key);
58
+ }
59
+
60
+ function matchesEffectiveKeys(data: string, keys: readonly KeyId[]): boolean {
61
+ for (const key of keys) {
62
+ if (matchesEffectiveKey(data, key)) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Match the "submit multi-line text input" keybinding (`app.message.followUp`).
69
+ *
70
+ * Used by forms where plain Enter inserts a newline and a modified-Enter chord
71
+ * submits — the main editor's follow-up handler, the agent dashboard's new-agent
72
+ * description, and the hook editor's hook-style mode. The keybinding defaults to
73
+ * `["ctrl+q", "ctrl+enter"]` so Windows Terminal (which can't deliver a distinct
74
+ * Ctrl+Enter event; #1903) still has a working chord without user remapping.
75
+ *
76
+ * Also recognizes modifier-tagged LF as Ctrl+Enter only when Ctrl+Enter is an
77
+ * effective follow-up binding.
78
+ */
79
+ export function matchesAppFollowUp(data: string): boolean {
80
+ const keybindings = getKeybindings();
81
+ const keys = keybindings.getKeys("app.message.followUp");
82
+ if (keys.length > 0) {
83
+ return matchesEffectiveKeys(data, keys);
84
+ }
85
+ return matchesEffectiveKeys(data, ["ctrl+enter", "ctrl+q"]);
86
+ }
@@ -605,6 +605,7 @@ export class UiHelpers {
605
605
  // dispose them (stopping any live timers/subscriptions) before clearing. When
606
606
  // preserving, the same instances are re-added below, so detach without dispose.
607
607
  const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
608
+ this.ctx.initialChatRendered = true;
608
609
  if (preservedChatChildren) {
609
610
  this.ctx.chatContainer.clear();
610
611
  } else {
@@ -19,17 +19,18 @@ Drives real Chromium tab; full puppeteer access via JS.
19
19
  - `tab.ref("e5")` — `[ref=eN]` from the last ariaSnapshot → element handle with the common action methods (`.click()`, `.type()`, `.fill()`, `.hover()`, `.evaluate()`, …); the primary way to act on a ref. For convenience `aria-ref=e5` also works inline in `tab.click`/`type`/`fill`/`waitFor`/`scrollIntoView` (e.g. `tab.click("aria-ref=e5")`).
20
20
  - `tab.id(n)` — id from last observe → `ElementHandle` (`.click()`, `.type()`, …).
21
21
  - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)`.
22
- - `tab.waitFor(selector)` — wait until attached; returns `ElementHandle`.
22
+ - `tab.waitFor(selector, { timeout? })` / `tab.waitForSelector(selector, { timeout?, visible?, hidden? })` — wait until attached (optionally visible/hidden); returns the `ElementHandle`.
23
23
  - `tab.drag(from, to)` — endpoints: selector (center-to-center) or `{ x, y }` viewport point (canvases, sliders).
24
24
  - `tab.scrollIntoView(selector)` — center in viewport; before clicking off-screen elements.
25
25
  - `tab.select(selector, …values)` — set `<select>` option(s); returns selection. `tab.fill` NEVER works for selects.
26
26
  - `tab.uploadFile(selector, …filePaths)` — attach files to `<input type="file">`; paths relative to cwd.
27
27
  - `tab.waitForUrl(pattern, { timeout? })` — substring or `RegExp` (matches SPA pushState nav); returns matched URL.
28
28
  - `tab.waitForResponse(pattern, { timeout? })` — substring, `RegExp`, or `(response) => boolean`; returns puppeteer `HTTPResponse` (`.text()`/`.json()`/`.status()`/`.headers()`).
29
+ - `tab.waitForNavigation({ waitUntil?, timeout? })` — resolves on the next navigation. Start it BEFORE the click/submit that triggers it; after `tab.goto` (which already waits) use `tab.waitForUrl`/`tab.waitForSelector` instead.
29
30
  - `tab.evaluate(fn, …args)` — `page.evaluate` for ad-hoc DOM reads.
30
31
  - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture + attach for viewing (`silent: true` skips). Pass `save` only when a later step needs the file.
31
32
  - `tab.extract(format = "markdown")` — readable page content (`"markdown"` | `"text"`); throws when nothing readable.
32
- - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`.
33
+ - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.<op> timed out` error, never the whole-cell timeout.
33
34
  </instruction>
34
35
 
35
36
  <critical>
package/src/sdk.ts CHANGED
@@ -1288,7 +1288,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1288
1288
  const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => {
1289
1289
  let level = options.thinkingLevel;
1290
1290
  if (level === undefined && hasExistingSession && hasThinkingEntry) {
1291
- level = parseThinkingLevel(existingSession.thinkingLevel);
1291
+ level =
1292
+ parseConfiguredThinkingLevel(existingSession.configuredThinkingLevel) ??
1293
+ parseThinkingLevel(existingSession.thinkingLevel);
1292
1294
  }
1293
1295
  if (level === undefined && !hasThinkingEntry && restoredSessionThinkingLevel !== undefined) {
1294
1296
  level = restoredSessionThinkingLevel;
@@ -2019,18 +2021,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2019
2021
  );
2020
2022
 
2021
2023
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
2024
+ const builtInRegistryToolNames = new Set<string>();
2022
2025
  const toolRegistry = new Map<string, Tool>();
2023
2026
  for (const tool of builtinTools) {
2024
2027
  toolRegistry.set(tool.name, tool);
2028
+ builtInRegistryToolNames.add(tool.name);
2025
2029
  }
2026
2030
  if (!toolRegistry.has("goal") && settings.get("goal.enabled")) {
2027
2031
  const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
2028
2032
  if (goalTool) {
2029
2033
  toolRegistry.set(goalTool.name, wrapToolWithMetaNotice(goalTool));
2034
+ builtInRegistryToolNames.add(goalTool.name);
2030
2035
  }
2031
2036
  }
2032
2037
  for (const tool of wrappedExtensionTools) {
2033
2038
  toolRegistry.set(tool.name, tool);
2039
+ builtInRegistryToolNames.delete(tool.name);
2034
2040
  }
2035
2041
  if (deferMCPDiscoveryForUI && mcpManager) {
2036
2042
  for (const name of collectPendingMCPToolNames(options.toolNames, existingSession.selectedMCPToolNames)) {
@@ -2048,6 +2054,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2048
2054
  }
2049
2055
  if (model?.provider === "cursor") {
2050
2056
  toolRegistry.delete("edit");
2057
+ builtInRegistryToolNames.delete("edit");
2051
2058
  }
2052
2059
 
2053
2060
  // `resolve` is hidden but must stay in the registry whenever any code path can invoke it:
@@ -2060,10 +2067,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2060
2067
  const needsResolveTool = hasDeferrableTools || planModeAvailable;
2061
2068
  if (!needsResolveTool) {
2062
2069
  toolRegistry.delete("resolve");
2070
+ builtInRegistryToolNames.delete("resolve");
2063
2071
  } else if (!toolRegistry.has("resolve")) {
2064
2072
  const resolveTool = await logger.time("createTools:resolve:session", HIDDEN_TOOLS.resolve, toolSession);
2065
2073
  if (resolveTool) {
2066
2074
  toolRegistry.set(resolveTool.name, wrapToolWithMetaNotice(resolveTool));
2075
+ builtInRegistryToolNames.add(resolveTool.name);
2067
2076
  }
2068
2077
  }
2069
2078
 
@@ -2080,6 +2089,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2080
2089
  searchTool.name,
2081
2090
  new ExtensionToolWrapper(wrapToolWithMetaNotice(searchTool), extensionRunner) as Tool,
2082
2091
  );
2092
+ builtInRegistryToolNames.add(searchTool.name);
2083
2093
  }
2084
2094
  let mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
2085
2095
 
@@ -2623,6 +2633,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2623
2633
  skillsSettings: settings.getGroup("skills"),
2624
2634
  modelRegistry,
2625
2635
  toolRegistry,
2636
+ builtInToolNames: builtInRegistryToolNames,
2626
2637
  transformContext,
2627
2638
  onPayload,
2628
2639
  onResponse,
@@ -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";
@@ -477,6 +477,8 @@ export interface AgentSessionConfig {
477
477
  modelRegistry: ModelRegistry;
478
478
  /** Tool registry for LSP and settings */
479
479
  toolRegistry?: Map<string, AgentTool>;
480
+ /** Tool names whose current registry entry is still the built-in implementation. */
481
+ builtInToolNames?: Iterable<string>;
480
482
  /** Current session pre-LLM message transform pipeline */
481
483
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
482
484
  /** Provider payload hook used by the active session request path */
@@ -1290,6 +1292,7 @@ export class AgentSession {
1290
1292
  // Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
1291
1293
  #discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
1292
1294
  #selectedDiscoveredToolNames = new Set<string>();
1295
+ #builtInToolNames = new Set<string>();
1293
1296
  #rpcHostToolNames = new Set<string>();
1294
1297
  #defaultSelectedMCPServerNames = new Set<string>();
1295
1298
  #defaultSelectedMCPToolNames = new Set<string>();
@@ -1566,6 +1569,7 @@ export class AgentSession {
1566
1569
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1567
1570
  this.#validateRetryFallbackChains();
1568
1571
  this.#toolRegistry = config.toolRegistry ?? new Map();
1572
+ this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
1569
1573
  this.#requestedToolNames = config.requestedToolNames;
1570
1574
  this.#transformContext = config.transformContext ?? (messages => messages);
1571
1575
  this.#onPayload = config.onPayload;
@@ -4496,6 +4500,11 @@ export class AgentSession {
4496
4500
  return this.#toolRegistry.get(name);
4497
4501
  }
4498
4502
 
4503
+ /** True when the current registry entry for `name` came from a built-in factory. */
4504
+ hasBuiltInTool(name: string): boolean {
4505
+ return this.#builtInToolNames.has(name);
4506
+ }
4507
+
4499
4508
  /**
4500
4509
  * Get all configured tool names (built-in via --tools or default, plus custom tools).
4501
4510
  */
@@ -6835,7 +6844,7 @@ export class AgentSession {
6835
6844
  this.#pendingNextTurnMessages = [];
6836
6845
  this.#scheduledHiddenNextTurnGeneration = undefined;
6837
6846
 
6838
- this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
6847
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel());
6839
6848
  this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
6840
6849
  if (nextDiscoverySessionToolNames) {
6841
6850
  await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
@@ -7234,16 +7243,20 @@ export class AgentSession {
7234
7243
  return;
7235
7244
  }
7236
7245
 
7246
+ const wasAuto = this.#autoThinking;
7237
7247
  this.#autoThinking = false;
7238
7248
  this.#autoResolvedLevel = undefined;
7239
7249
  const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
7240
- const isChanging = effectiveLevel !== this.#thinkingLevel;
7250
+ // Leaving auto must persist even when the resolved effort is unchanged (e.g.
7251
+ // auto resolved to medium, then the user pins medium): otherwise the latest
7252
+ // session entry keeps `configured: "auto"` and resume re-enables auto.
7253
+ const isChanging = wasAuto || effectiveLevel !== this.#thinkingLevel;
7241
7254
 
7242
7255
  this.#thinkingLevel = effectiveLevel;
7243
7256
  this.#applyThinkingLevelToAgent(effectiveLevel);
7244
7257
 
7245
7258
  if (isChanging) {
7246
- this.sessionManager.appendThinkingLevelChange(effectiveLevel);
7259
+ this.sessionManager.appendThinkingLevelChange(effectiveLevel, effectiveLevel);
7247
7260
  if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
7248
7261
  this.settings.set("defaultThinkingLevel", effectiveLevel);
7249
7262
  }
@@ -7332,7 +7345,7 @@ export class AgentSession {
7332
7345
  this.#thinkingLevel = effort;
7333
7346
  this.#applyThinkingLevelToAgent(effort);
7334
7347
  if (shouldPersistResolution) {
7335
- this.sessionManager.appendThinkingLevelChange(effort);
7348
+ this.sessionManager.appendThinkingLevelChange(effort, AUTO_THINKING);
7336
7349
  }
7337
7350
  this.#emit({
7338
7351
  type: "thinking_level_changed",
@@ -11608,18 +11621,26 @@ export class AgentSession {
11608
11621
  .some(entry => entry.type === "service_tier_change");
11609
11622
  const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
11610
11623
  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.
11624
+ // Restore the thinking selector. Each change persists the configured
11625
+ // selector (`auto` or a concrete level), so prefer it: an `auto` session
11626
+ // resumes in auto mode (reclassifying the next turn) instead of freezing at
11627
+ // the last resolved level. Entries written before the `configured` field
11628
+ // existed fall back to the concrete level (legacy pin-on-resume behavior).
11629
+ // With no thinking entry, fall back to the global default so fresh sessions
11630
+ // still classify their first turn.
11631
+ const restoredConfigured = sessionContext.configuredThinkingLevel;
11617
11632
  const restoredThinkingLevel: ConfiguredThinkingLevel | undefined =
11618
11633
  hasThinkingEntry || (defaultThinkingLevel === AUTO_THINKING && sessionContext.thinkingLevel !== "off")
11619
- ? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
11634
+ ? restoredConfigured === AUTO_THINKING
11635
+ ? AUTO_THINKING
11636
+ : (sessionContext.thinkingLevel as ThinkingLevel | undefined)
11620
11637
  : defaultThinkingLevel;
11621
11638
  if (restoredThinkingLevel === AUTO_THINKING) {
11622
11639
  this.#autoThinking = true;
11640
+ // Resume in auto (pending) like a fresh auto session: the next user
11641
+ // turn reclassifies. We intentionally do not seed the last resolved
11642
+ // effort, so the cold (--continue) and in-app switch paths display
11643
+ // identically as `auto` until then.
11623
11644
  this.#autoResolvedLevel = undefined;
11624
11645
  this.#thinkingLevel = resolveProvisionalAutoLevel(this.model);
11625
11646
  } else {
@@ -12513,9 +12534,12 @@ export class AgentSession {
12513
12534
  * @returns Path to exported file
12514
12535
  */
12515
12536
  async exportToHtml(outputPath?: string): Promise<string> {
12516
- const themeName = getCurrentThemeName();
12537
+ // Public HTML export ships in the omp brand palette (collab-web
12538
+ // pink/purple), matching my.omp.sh — not the host's terminal theme.
12539
+ // Callers who want a themed export can pass `palette: "theme"` with
12540
+ // `themeName` directly to `exportSessionToHtml`.
12517
12541
  const { exportSessionToHtml } = await import("../export/html");
12518
- return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
12542
+ return exportSessionToHtml(this.sessionManager, this.state, { outputPath, palette: "web" });
12519
12543
  }
12520
12544
 
12521
12545
  // =========================================================================