@oh-my-pi/pi-coding-agent 17.1.3 → 17.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3759 -3751
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/bash.d.ts +5 -4
  30. package/dist/types/tools/computer/exposure.d.ts +8 -0
  31. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  32. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  33. package/dist/types/tools/computer/worker.d.ts +1 -1
  34. package/dist/types/tools/computer.d.ts +6 -0
  35. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  36. package/dist/types/tools/todo.d.ts +7 -1
  37. package/package.json +12 -12
  38. package/src/advisor/__tests__/advisor.test.ts +80 -10
  39. package/src/advisor/runtime.ts +27 -1
  40. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  41. package/src/cli/auth-gateway-cli.ts +63 -16
  42. package/src/cli/config-cli.ts +25 -7
  43. package/src/cli/update-cli.ts +229 -29
  44. package/src/cli/usage-cli.ts +144 -15
  45. package/src/cli.ts +21 -15
  46. package/src/commands/update.ts +6 -0
  47. package/src/config/__tests__/model-registry.test.ts +42 -7
  48. package/src/config/model-registry.ts +67 -4
  49. package/src/config/settings-schema.ts +39 -8
  50. package/src/config/settings.ts +24 -2
  51. package/src/cursor.ts +153 -0
  52. package/src/dap/session.ts +70 -11
  53. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  54. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  55. package/src/eval/js/process-entry.ts +9 -5
  56. package/src/eval/js/worker-core.ts +6 -2
  57. package/src/exec/bash-executor.ts +4 -2
  58. package/src/extensibility/extensions/runner.ts +23 -8
  59. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  60. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  61. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  62. package/src/extensibility/shared-events.ts +2 -0
  63. package/src/main.ts +22 -1
  64. package/src/modes/acp/acp-agent.ts +6 -0
  65. package/src/modes/components/assistant-message.ts +88 -11
  66. package/src/modes/components/chat-transcript-builder.ts +2 -0
  67. package/src/modes/components/custom-editor.test.ts +170 -0
  68. package/src/modes/components/custom-editor.ts +79 -29
  69. package/src/modes/components/settings-defs.ts +5 -1
  70. package/src/modes/controllers/event-controller.ts +96 -4
  71. package/src/modes/controllers/selector-controller.ts +14 -0
  72. package/src/modes/interactive-mode.ts +34 -8
  73. package/src/modes/utils/context-usage.ts +19 -2
  74. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  75. package/src/plan-mode/approved-plan.ts +18 -10
  76. package/src/prompts/system/custom-system-prompt.md +1 -1
  77. package/src/prompts/system/system-prompt.md +10 -1
  78. package/src/sdk.ts +4 -0
  79. package/src/secrets/obfuscator.ts +36 -126
  80. package/src/session/agent-session.ts +69 -60
  81. package/src/session/prewalk.ts +8 -3
  82. package/src/session/session-advisors.ts +67 -3
  83. package/src/session/session-metadata.ts +53 -0
  84. package/src/session/session-provider-boundary.ts +0 -1
  85. package/src/session/session-tools.ts +35 -1
  86. package/src/session/turn-recovery.ts +36 -19
  87. package/src/slash-commands/builtin-registry.ts +49 -7
  88. package/src/stt/asr-worker.ts +2 -37
  89. package/src/stt/sherpa-runtime.ts +71 -0
  90. package/src/task/executor.ts +6 -5
  91. package/src/thinking.ts +39 -0
  92. package/src/tools/bash.ts +43 -15
  93. package/src/tools/computer/exposure.ts +38 -0
  94. package/src/tools/computer/supervisor.ts +61 -13
  95. package/src/tools/computer/worker-entry.ts +28 -19
  96. package/src/tools/computer/worker.ts +3 -7
  97. package/src/tools/computer.ts +65 -10
  98. package/src/tools/gh-cache-invalidation.ts +2 -82
  99. package/src/tools/shell-tokenize.ts +83 -0
  100. package/src/tools/todo.ts +44 -17
@@ -78,14 +78,35 @@ const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
78
78
  const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
79
79
  const FILE_URI_REGEX = /^file:\/\//i;
80
80
  const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
81
+ /**
82
+ * Alternation of the filesystem prefixes that make a path unambiguously
83
+ * absolute (POSIX root, home, `file://`, UNC, Windows drive). Shared by
84
+ * {@link ABSOLUTE_PATH_PREFIX_REGEX} and {@link INTERIOR_PATH_ANCHOR_REGEX} so
85
+ * the leading-anchor test and the second-anchor test can never disagree about
86
+ * what counts as the start of a path.
87
+ */
88
+ const ABSOLUTE_PATH_PREFIX_SOURCE = String.raw`(?:\/|~\/|file:\/\/|\\\\|[A-Za-z]:[\\/])`;
81
89
  /**
82
90
  * Whole-string anchor for paths that are unambiguously absolute. Restricts the
83
- * "treat the entire clipboard text as one path" branch of
84
- * {@link extractImagePathFromText} to inputs that start with a clearly-anchored
85
- * filesystem prefix, so prose containing a path-shaped fragment (e.g.
86
- * "see /tmp/x.png") never hijacks the smart fallback.
91
+ * "treat the entire text as one path" pass of {@link extractWholeTextImagePath}
92
+ * to inputs that start with a clearly-anchored filesystem prefix, so prose
93
+ * containing a path-shaped fragment (e.g. "see /tmp/x.png") never hijacks the
94
+ * smart fallback.
87
95
  */
88
- const ABSOLUTE_PATH_PREFIX_REGEX = /^(?:\/|~\/|file:\/\/|\\\\|[A-Za-z]:[\\/])/;
96
+ const ABSOLUTE_PATH_PREFIX_REGEX = new RegExp(`^${ABSOLUTE_PATH_PREFIX_SOURCE}`);
97
+ /**
98
+ * A second path anchor after *unescaped* whitespace — the signature of a
99
+ * multi-path payload (`/tmp/a.png /tmp/b shot.png`, `/tmp/a.png ./b shot.png`)
100
+ * rather than of one path whose name merely contains spaces. Anchors are the
101
+ * absolute prefixes plus dot-relative starts (`./`, `../`, `.\`), which never
102
+ * begin a component of a single sane path. Bare relatives (`dir/b shot.png`)
103
+ * are deliberately NOT anchors: an interior `token/` after a space is exactly
104
+ * the shape of a single path with a spaced directory name
105
+ * (`/Users/me/My Photos/shot 1.png`), which this fallback exists to recover.
106
+ * Escaped whitespace (`/tmp/My\ Photos/x.png`) is exempt: the escape is the
107
+ * terminal asserting the space belongs to the path.
108
+ */
109
+ const INTERIOR_PATH_ANCHOR_REGEX = new RegExp(String.raw`(?<!\\)\s(?:${ABSOLUTE_PATH_PREFIX_SOURCE}|\.\.?[\\/])`);
89
110
 
90
111
  /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
91
112
  * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
@@ -228,16 +249,28 @@ export function extractPastePathsFromText(text: string): string[] | undefined {
228
249
  return extractExplicitPathSegments(text);
229
250
  }
230
251
 
231
- export function extractBracketedPastePaths(data: string): string[] | undefined {
232
- if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
233
- const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
234
- if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
235
- return extractExplicitPathSegments(data.slice(BRACKETED_PASTE_START.length, endIndex));
236
- }
237
-
238
- export function extractBracketedImagePastePaths(data: string): string[] | undefined {
239
- const paths = extractBracketedPastePaths(data);
240
- return paths?.every(isImagePath) ? paths : undefined;
252
+ /**
253
+ * Whole-text-as-path pass shared by {@link extractImagePastePathsFromText}
254
+ * and {@link extractImagePathFromText}: treat the entire text as one path
255
+ * when it is anchored by {@link ABSOLUTE_PATH_PREFIX_REGEX}, contains no
256
+ * newlines, and points at a supported image extension. Recovers single paths
257
+ * whose unescaped spaces defeat the segment splitter (macOS screenshot names).
258
+ *
259
+ * Refuses payloads carrying a second {@link INTERIOR_PATH_ANCHOR_REGEX} anchor.
260
+ * Dragging two files at once emits `/tmp/a.png /tmp/b shot.png`, which the
261
+ * splitter also refuses (`shot.png` is not explicit); swallowing it as one path
262
+ * attaches nothing, and `handleImagePathPaste`'s ENOENT branch only surfaces a
263
+ * status — unlike its other failure branches it never re-pastes the text — so
264
+ * both paths would vanish. Genuinely ambiguous input lands here too (a
265
+ * directory whose name ends in a space, as in `/tmp/odd dir /sub/x.png`); a
266
+ * plain text paste is the losing-nothing outcome, so ambiguity resolves that way.
267
+ */
268
+ function extractWholeTextImagePath(text: string): string | undefined {
269
+ const trimmed = text.trim();
270
+ if (!trimmed || /[\r\n]/.test(trimmed) || !ABSOLUTE_PATH_PREFIX_REGEX.test(trimmed)) return undefined;
271
+ if (INTERIOR_PATH_ANCHOR_REGEX.test(trimmed)) return undefined;
272
+ const wholePath = normalizePastedPath(trimmed);
273
+ return wholePath && isExplicitPastedPath(wholePath) && isImagePath(wholePath) ? wholePath : undefined;
241
274
  }
242
275
 
243
276
  /**
@@ -245,10 +278,35 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
245
278
  * payload that has already been stripped of the `\x1b[200~` / `\x1b[201~`
246
279
  * markers — used by the assembled-paste router in {@link CustomEditor.handleInput}
247
280
  * so split bracketed pastes get the same image-path detection as single-chunk ones.
281
+ *
282
+ * When the segment splitter fails (an unescaped space in a real path breaks
283
+ * its every-segment-is-a-path invariant), falls back to
284
+ * {@link extractWholeTextImagePath}, so a dropped macOS screenshot
285
+ * (`Screenshot 2026-06-25 at 1.23.45 PM.png`) attaches as an image instead of
286
+ * degrading to literal text (#6578).
248
287
  */
249
288
  export function extractImagePastePathsFromText(text: string): string[] | undefined {
250
289
  const paths = extractPastePathsFromText(text);
251
- return paths?.every(isImagePath) ? paths : undefined;
290
+ if (paths !== undefined) return paths.every(isImagePath) ? paths : undefined;
291
+ const wholePath = extractWholeTextImagePath(text);
292
+ return wholePath ? [wholePath] : undefined;
293
+ }
294
+
295
+ function bracketedPastePayload(data: string): string | undefined {
296
+ if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
297
+ const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
298
+ if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
299
+ return data.slice(BRACKETED_PASTE_START.length, endIndex);
300
+ }
301
+
302
+ export function extractBracketedPastePaths(data: string): string[] | undefined {
303
+ const payload = bracketedPastePayload(data);
304
+ return payload === undefined ? undefined : extractExplicitPathSegments(payload);
305
+ }
306
+
307
+ export function extractBracketedImagePastePaths(data: string): string[] | undefined {
308
+ const payload = bracketedPastePayload(data);
309
+ return payload === undefined ? undefined : extractImagePastePathsFromText(payload);
252
310
  }
253
311
 
254
312
  export function extractBracketedImagePastePath(data: string): string | undefined {
@@ -272,25 +330,17 @@ export function extractBracketedImagePastePath(data: string): string | undefined
272
330
  * ambiguous multi-path clipboard text like `/tmp/a.png /tmp/b.png`
273
331
  * still falls through to the text fallback instead of being mis-loaded
274
332
  * as one giant path).
275
- * 2. Whole-text-as-path pass — only reached when the splitter failed
276
- * (every segment must look like an explicit path; an unescaped space in
277
- * a real path breaks that). Restricted to inputs anchored by
278
- * {@link ABSOLUTE_PATH_PREFIX_REGEX} so prose containing a path-shaped
279
- * fragment ("see /tmp/x.png") never hijacks the smart fallback. This
280
- * is what recovers macOS screenshot filenames like
333
+ * 2. {@link extractWholeTextImagePath} — only reached when the splitter
334
+ * failed (every segment must look like an explicit path; an unescaped
335
+ * space in a real path breaks that). This is what recovers macOS
336
+ * screenshot filenames like
281
337
  * `/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png`.
282
338
  */
283
339
  export function extractImagePathFromText(text: string): string | undefined {
284
340
  const paths = extractPastePathsFromText(text);
285
341
  if (paths?.length === 1 && isImagePath(paths[0])) return paths[0];
286
342
  if (paths !== undefined) return undefined;
287
- const trimmed = text.trim();
288
- if (!trimmed || /[\r\n]/.test(trimmed) || !ABSOLUTE_PATH_PREFIX_REGEX.test(trimmed)) return undefined;
289
- const wholePath = normalizePastedPath(trimmed);
290
- if (wholePath && isExplicitPastedPath(wholePath) && isImagePath(wholePath)) {
291
- return wholePath;
292
- }
293
- return undefined;
343
+ return extractWholeTextImagePath(text);
294
344
  }
295
345
 
296
346
  /**
@@ -18,6 +18,7 @@ import {
18
18
  getPathsForTab,
19
19
  getType,
20
20
  getUi,
21
+ isCredential,
21
22
  SETTING_TABS,
22
23
  type SettingPath,
23
24
  type SettingTab,
@@ -192,7 +193,10 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
192
193
  if (options) {
193
194
  return { ...base, type: "submenu", options };
194
195
  }
195
- return { ...base, type: "text", secret: ui.secret === true };
196
+ // One classification drives both surfaces: a setting marked `credential`
197
+ // masks here too, so the panel cannot display one that only the CLI knows
198
+ // to redact.
199
+ return { ...base, type: "text", secret: isCredential(path) };
196
200
  }
197
201
 
198
202
  if (schemaType === "array") {
@@ -2,7 +2,7 @@ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
2
2
  import * as AIError from "@oh-my-pi/pi-ai/error";
3
3
  import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
4
4
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
5
- import { logger, prompt } from "@oh-my-pi/pi-utils";
5
+ import { logger, prompt, sanitizeText } from "@oh-my-pi/pi-utils";
6
6
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
7
7
  import { extractTextContent } from "../../commit/utils";
8
8
  import { settings } from "../../config/settings";
@@ -15,7 +15,7 @@ import {
15
15
  readArgsHaveTarget,
16
16
  } from "../../modes/components/read-tool-group";
17
17
  import { TodoReminderComponent } from "../../modes/components/todo-reminder";
18
- import { ToolExecutionComponent } from "../../modes/components/tool-execution";
18
+ import { ToolExecutionComponent, type ToolExecutionHandle } from "../../modes/components/tool-execution";
19
19
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
20
20
  import { createUsageRowBlock } from "../../modes/components/usage-row";
21
21
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
@@ -89,6 +89,17 @@ export class EventController {
89
89
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
90
90
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
91
91
  #toolTimelineComponents = new Map<string, Component>();
92
+ // Completions that arrived before any component existed for their call id.
93
+ // Cursor's server-resolved tools (todo) emit `tool_execution_end` through a
94
+ // synchronous callback fired mid-parse, while the `toolcall_start` for the
95
+ // same call rides `AssistantMessageEventStream` and is delivered a microtask
96
+ // later. When the server packs start and completion into one HTTP/2 chunk
97
+ // the completion is handled FIRST — with no `pendingTools` entry to settle.
98
+ // Dropping it would strand the card the streamed block creates moments
99
+ // later, so the event is held here and replayed the moment that component
100
+ // materializes (`#handleMessageUpdate`). Keyed by call id; ids are unique
101
+ // per turn, and the map is cleared with the other transcript anchors.
102
+ #orphanedToolCompletions = new Map<string, Extract<AgentSessionEvent, { type: "tool_execution_end" }>>();
92
103
  #postToolAssistantComponents = new Map<string, AssistantMessageComponent>();
93
104
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
94
105
  // Assistant component whose turn-ending error is currently mirrored in the
@@ -334,6 +345,7 @@ export class EventController {
334
345
  this.#renderedCustomMessages.clear();
335
346
  this.#lastIntent = undefined;
336
347
  this.#toolTimelineComponents.clear();
348
+ this.#orphanedToolCompletions.clear();
337
349
  this.#postToolAssistantComponents.clear();
338
350
  this.#backgroundTaskCallIds.clear();
339
351
  this.#approvalAttentionToolCallIds.clear();
@@ -422,6 +434,7 @@ export class EventController {
422
434
 
423
435
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
424
436
  this.#toolTimelineComponents.clear();
437
+ this.#orphanedToolCompletions.clear();
425
438
  this.#postToolAssistantComponents.clear();
426
439
  this.#lastIntent = undefined;
427
440
  this.#readToolCallArgs.clear();
@@ -777,7 +790,14 @@ export class EventController {
777
790
  this.#toolArgsReveal.finish(content.id);
778
791
  renderArgs = content.arguments;
779
792
  }
780
- if (!this.ctx.pendingTools.has(content.id)) {
793
+ // `message_update` is cumulative — every update re-lists all blocks
794
+ // of the streaming message — so creation must also be guarded by the
795
+ // timeline map: `pendingTools` loses the id the moment a completion
796
+ // settles the card, and for server-resolved (Cursor) tools that can
797
+ // happen while the message is still streaming. Without the second
798
+ // check the next cumulative update would recreate a card for a call
799
+ // that already finished, permanently pending.
800
+ if (!this.ctx.pendingTools.has(content.id) && !this.#toolTimelineComponents.has(content.id)) {
781
801
  this.#resolveDisplaceablePoll(content.name);
782
802
  this.#resetReadGroup();
783
803
  const component = new ToolExecutionComponent(
@@ -799,6 +819,16 @@ export class EventController {
799
819
  this.ctx.pendingTools.set(content.id, component);
800
820
  this.#toolTimelineComponents.set(content.id, component);
801
821
  this.#toolArgsReveal.bind(content.id, component);
822
+ // A held completion for this call means its `tool_execution_end`
823
+ // outran this streamed block (see #orphanedToolCompletions).
824
+ // Attach it now that the card exists so it settles immediately
825
+ // instead of animating forever. Only the component is settled —
826
+ // the handler's other side effects already ran on first arrival.
827
+ const orphan = this.#orphanedToolCompletions.get(content.id);
828
+ if (orphan) {
829
+ this.#orphanedToolCompletions.delete(content.id);
830
+ this.#settleHeldCompletion(component, orphan);
831
+ }
802
832
  } else {
803
833
  const component = this.ctx.pendingTools.get(content.id);
804
834
  if (component) {
@@ -1066,6 +1096,44 @@ export class EventController {
1066
1096
  }
1067
1097
  }
1068
1098
 
1099
+ /**
1100
+ * Attach a held completion to the component that was created for it after
1101
+ * the fact (see {@link #orphanedToolCompletions}) and settle the card.
1102
+ *
1103
+ * Deliberately NOT a re-entry into `#handleToolExecutionEnd`: every
1104
+ * user-facing side effect of that handler — the todo panel refresh, the
1105
+ * failure warning, plan approval, the terminal-title transition — already
1106
+ * ran when the completion first arrived. Replaying the whole handler would
1107
+ * show the same warning twice and re-push an identical `setTodos`. Only the
1108
+ * component half was missed, because the component did not exist yet.
1109
+ */
1110
+ #settleHeldCompletion(
1111
+ component: ToolExecutionHandle,
1112
+ event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
1113
+ ): void {
1114
+ component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
1115
+ this.ctx.pendingTools.delete(event.toolCallId);
1116
+ if (
1117
+ component instanceof ToolExecutionComponent &&
1118
+ component.isDisplaceableBlock() &&
1119
+ event.toolName === "todo" &&
1120
+ component.canBeDisplacedBy("todo")
1121
+ ) {
1122
+ // Mirrors the displacement bookkeeping in `#handleToolExecutionEnd`:
1123
+ // a successful snapshot supersedes the previous live panel.
1124
+ const previous = this.#displaceableTodoComponent;
1125
+ if (previous && previous !== component && previous.isDisplaceableBlock()) {
1126
+ this.#displaceableTodoComponent = undefined;
1127
+ if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
1128
+ this.ctx.chatContainer.removeChild(previous);
1129
+ }
1130
+ previous.seal();
1131
+ }
1132
+ this.#displaceableTodoComponent = component;
1133
+ }
1134
+ this.ctx.ui.requestRender();
1135
+ }
1136
+
1069
1137
  async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
1070
1138
  // A transient overlay (auto-compaction / auto-retry / handoff) that ran
1071
1139
  // between this tool's start and end could have detached the working
@@ -1142,6 +1210,15 @@ export class EventController {
1142
1210
  }
1143
1211
  }
1144
1212
  this.ctx.ui.requestRender();
1213
+ } else if (event.toolName === "todo") {
1214
+ // No component yet: the streamed block that creates the card has not
1215
+ // been delivered (see #orphanedToolCompletions). Hold the completion
1216
+ // for replay instead of dropping it — scoped to `todo`, the only
1217
+ // tool whose completion is emitted synchronously mid-parse. The
1218
+ // panel/warning side effects below still run NOW, on first arrival;
1219
+ // the replay settles only the component
1220
+ // (`#settleHeldCompletion`), so neither is repeated.
1221
+ this.#orphanedToolCompletions.set(event.toolCallId, event);
1145
1222
  }
1146
1223
  }
1147
1224
  // Update todo display when todo tool completes
@@ -1154,8 +1231,22 @@ export class EventController {
1154
1231
  const textContent = event.result.content.find(
1155
1232
  (content: { type: string; text?: string }) => content.type === "text",
1156
1233
  )?.text;
1234
+ // This text can be a provider error copied verbatim off the wire (the
1235
+ // Cursor todo bridge forwards the server's string), so it may carry
1236
+ // ANSI escapes, other C0/C1 controls, tabs, newlines, or a line far
1237
+ // wider than the terminal. `showWarning` renders through a plain
1238
+ // `Text`, which strips none of that — an escape reaches the terminal
1239
+ // and can repaint outside the row. `sanitizeText` drops the control
1240
+ // sequences (and returns the same reference when there are none),
1241
+ // then `previewLine` collapses the remaining whitespace and bounds
1242
+ // the width. Sanitizing first matters: truncating before stripping
1243
+ // can cut an escape mid-sequence and leave a dangling introducer.
1244
+ //
1245
+ // This is the render boundary, not the persisted result: the stored
1246
+ // error stays full-fidelity for the transcript and for replays.
1247
+ const detail = textContent ? previewLine(sanitizeText(textContent), TRUNCATE_LENGTHS.LINE) : "";
1157
1248
  this.ctx.showWarning(
1158
- `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo succeeds."}`,
1249
+ `Todo update failed${detail ? `: ${detail}` : ". Progress may be stale until todo succeeds."}`,
1159
1250
  );
1160
1251
  }
1161
1252
  // Plan approval rides a `write` to xd://propose: the dispatch metadata on
@@ -1236,6 +1327,7 @@ export class EventController {
1236
1327
  this.#readToolCallArgs.clear();
1237
1328
  this.#readToolCallAssistantComponents.clear();
1238
1329
  this.#toolTimelineComponents.clear();
1330
+ this.#orphanedToolCompletions.clear();
1239
1331
  this.#postToolAssistantComponents.clear();
1240
1332
  this.#resetReadGroup();
1241
1333
  // The turn is over: nothing else lands this turn, so the waiting poll is
@@ -420,6 +420,11 @@ export class SelectorController {
420
420
  this.ctx.session.setAutoCompactionEnabled(value as boolean);
421
421
  this.ctx.statusLine.setAutoCompactEnabled(value as boolean);
422
422
  break;
423
+ case "advisor.enabled":
424
+ this.ctx.session.setAdvisorEnabled(value as boolean);
425
+ this.ctx.statusLine.invalidate();
426
+ this.ctx.ui.requestRender();
427
+ break;
423
428
  case "steeringMode":
424
429
  this.ctx.session.setSteeringMode(value as "all" | "one-at-a-time");
425
430
  break;
@@ -1270,6 +1275,15 @@ export class SelectorController {
1270
1275
  this.ctx.editor.setText(result.editorText);
1271
1276
  }
1272
1277
  this.ctx.showStatus("Navigated to selected point");
1278
+
1279
+ // Re-answering a past `ask` commits a new sibling answer but,
1280
+ // unlike a live `ask`, leaves the agent idle. Resume it now —
1281
+ // after the transcript rebuild above — so the model consumes the
1282
+ // new answer without the resumed turn rendering against the stale
1283
+ // pre-rebuild UI (issue #6483).
1284
+ if (result.askReanswerCommitted) {
1285
+ this.ctx.session.resumeAfterAskReanswer();
1286
+ }
1273
1287
  } catch (error) {
1274
1288
  this.ctx.showError(error instanceof Error ? error.message : String(error));
1275
1289
  } finally {
@@ -1395,6 +1395,11 @@ export class InteractiveMode implements InteractiveModeContext {
1395
1395
  return;
1396
1396
  }
1397
1397
 
1398
+ if (action === "reset" && this.vibeModeEnabled) {
1399
+ this.disableLoopMode("Exit vibe mode before using reset loops. Loop mode disabled.");
1400
+ return;
1401
+ }
1402
+
1398
1403
  if (!consumeLoopLimitIteration(this.loopLimit)) {
1399
1404
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
1400
1405
  return;
@@ -3708,6 +3713,17 @@ export class InteractiveMode implements InteractiveModeContext {
3708
3713
  return;
3709
3714
  }
3710
3715
 
3716
+ // resolveApprovedPlan may return a newer draft than the path recorded in
3717
+ // plan-mode state. `AgentSession.#buildPlanModeMessage()` reads that state,
3718
+ // so if the operator refines (or dismisses and keeps planning) the next
3719
+ // planning turn must target the plan just reviewed — promote the reviewed
3720
+ // path into plan-mode state now, mirroring the print-mode approval handler.
3721
+ const planState = this.session.getPlanModeState();
3722
+ if (planState?.enabled && planState.planFilePath !== planFilePath) {
3723
+ this.session.setPlanModeState({ ...planState, planFilePath });
3724
+ this.sessionManager.appendModeChange("plan", { planFilePath });
3725
+ }
3726
+
3711
3727
  const contextUsage = this.#getPlanApprovalContextUsage();
3712
3728
  const keepContextLabel = this.#formatKeepContextLabel(contextUsage);
3713
3729
  const keepContextDisabled = this.#isKeepContextDisabled(contextUsage);
@@ -4432,6 +4448,12 @@ export class InteractiveMode implements InteractiveModeContext {
4432
4448
  this.#commandController.handleContextCommand();
4433
4449
  }
4434
4450
 
4451
+ #vibeSessionTransitionBlocked(): boolean {
4452
+ if (!this.vibeModeEnabled) return false;
4453
+ this.showWarning("Exit vibe mode first.");
4454
+ return true;
4455
+ }
4456
+
4435
4457
  #prepareSessionSwitch(): void {
4436
4458
  this.#btwController.dispose();
4437
4459
  this.#omfgController.dispose();
@@ -4440,28 +4462,32 @@ export class InteractiveMode implements InteractiveModeContext {
4440
4462
  this.#hidePlanReview();
4441
4463
  }
4442
4464
 
4443
- handleClearCommand(): Promise<void> {
4465
+ async handleClearCommand(): Promise<void> {
4466
+ if (this.#vibeSessionTransitionBlocked()) return;
4444
4467
  this.#prepareSessionSwitch();
4445
- return this.#commandController.handleClearCommand();
4468
+ await this.#commandController.handleClearCommand();
4446
4469
  }
4447
4470
 
4448
4471
  handleFreshCommand(): Promise<void> {
4449
4472
  return this.#commandController.handleFreshCommand();
4450
4473
  }
4451
4474
 
4452
- handleDropCommand(): Promise<void> {
4475
+ async handleDropCommand(): Promise<void> {
4476
+ if (this.#vibeSessionTransitionBlocked()) return;
4453
4477
  this.#prepareSessionSwitch();
4454
- return this.#commandController.handleDropCommand();
4478
+ await this.#commandController.handleDropCommand();
4455
4479
  }
4456
4480
 
4457
- handleForkCommand(): Promise<void> {
4481
+ async handleForkCommand(): Promise<void> {
4482
+ if (this.#vibeSessionTransitionBlocked()) return;
4458
4483
  this.#btwController.dispose();
4459
4484
  this.#omfgController.dispose();
4460
- return this.#commandController.handleForkCommand();
4485
+ await this.#commandController.handleForkCommand();
4461
4486
  }
4462
4487
 
4463
- handleMoveCommand(targetPath?: string): Promise<void> {
4464
- return this.#commandController.handleMoveCommand(targetPath);
4488
+ async handleMoveCommand(targetPath?: string): Promise<void> {
4489
+ if (this.#vibeSessionTransitionBlocked()) return;
4490
+ await this.#commandController.handleMoveCommand(targetPath);
4465
4491
  }
4466
4492
 
4467
4493
  handleRenameCommand(title: string): Promise<void> {
@@ -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
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.