@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.8

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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/stats-cli.d.ts +1 -6
  9. package/dist/types/cli/update-cli.d.ts +8 -0
  10. package/dist/types/cli-commands.d.ts +10 -2
  11. package/dist/types/commands/stats.d.ts +4 -0
  12. package/dist/types/config/settings-schema.d.ts +28 -0
  13. package/dist/types/config/settings.d.ts +9 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +23 -4
  15. package/dist/types/extensibility/extensions/types.d.ts +58 -0
  16. package/dist/types/launch/presence.d.ts +4 -1
  17. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  18. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  19. package/dist/types/mnemopi/backend.d.ts +12 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  21. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  22. package/dist/types/modes/interactive-mode.d.ts +25 -3
  23. package/dist/types/modes/types.d.ts +10 -0
  24. package/dist/types/session/agent-session.d.ts +3 -0
  25. package/dist/types/session/prewalk.d.ts +4 -0
  26. package/dist/types/session/session-entries.d.ts +0 -1
  27. package/dist/types/session/session-manager.d.ts +12 -0
  28. package/dist/types/session/session-stats.d.ts +13 -1
  29. package/dist/types/session/skill-title-input.d.ts +13 -0
  30. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  31. package/dist/types/subprocess/worker-client.d.ts +7 -4
  32. package/dist/types/task/label.d.ts +2 -0
  33. package/dist/types/task/render.d.ts +2 -0
  34. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  35. package/dist/types/tiny/title-client.d.ts +6 -4
  36. package/dist/types/tiny/title-protocol.d.ts +1 -0
  37. package/dist/types/tiny/worker.d.ts +27 -0
  38. package/dist/types/tools/bash.d.ts +1 -1
  39. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/tools/read-format.d.ts +6 -0
  43. package/dist/types/tools/read-summary.d.ts +7 -1
  44. package/dist/types/utils/block-context.d.ts +14 -0
  45. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  46. package/dist/types/utils/git.d.ts +25 -1
  47. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  48. package/package.json +13 -13
  49. package/src/advisor/advise-tool.ts +5 -3
  50. package/src/cli/auth-broker-cli.ts +36 -1
  51. package/src/cli/profile-bootstrap.ts +2 -6
  52. package/src/cli/stats-cli.ts +6 -72
  53. package/src/cli/update-cli.ts +63 -11
  54. package/src/cli-commands.ts +61 -7
  55. package/src/commands/completions.ts +2 -1
  56. package/src/commands/stats.ts +7 -4
  57. package/src/commit/agentic/index.ts +15 -2
  58. package/src/commit/git/diff.ts +6 -2
  59. package/src/config/model-resolver.ts +52 -6
  60. package/src/config/models-config.ts +2 -2
  61. package/src/config/settings-schema.ts +33 -0
  62. package/src/config/settings.ts +159 -30
  63. package/src/discovery/helpers.ts +45 -2
  64. package/src/discovery/omp-plugins.ts +2 -1
  65. package/src/discovery/opencode.ts +56 -3
  66. package/src/edit/hashline/filesystem.ts +9 -3
  67. package/src/edit/modes/patch.ts +31 -5
  68. package/src/eval/js/process-entry.ts +4 -4
  69. package/src/export/html/tool-views.generated.js +19 -19
  70. package/src/extensibility/extensions/loader.ts +11 -0
  71. package/src/extensibility/extensions/runner.ts +118 -5
  72. package/src/extensibility/extensions/types.ts +60 -0
  73. package/src/extensibility/extensions/wrapper.ts +10 -1
  74. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  75. package/src/launch/client.ts +9 -4
  76. package/src/launch/presence.ts +19 -4
  77. package/src/lsp/defaults.json +1 -1
  78. package/src/lsp/writethrough.ts +20 -10
  79. package/src/mcp/manager.ts +41 -20
  80. package/src/mcp/oauth-credentials.ts +38 -0
  81. package/src/mcp/oauth-flow.ts +21 -0
  82. package/src/mcp/tool-bridge.ts +32 -16
  83. package/src/mnemopi/backend.ts +35 -3
  84. package/src/modes/components/model-hub.ts +37 -4
  85. package/src/modes/components/settings-selector.ts +17 -11
  86. package/src/modes/components/tool-execution.ts +97 -29
  87. package/src/modes/components/tree-selector.ts +7 -2
  88. package/src/modes/controllers/event-controller.ts +12 -2
  89. package/src/modes/controllers/input-controller.ts +64 -27
  90. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  91. package/src/modes/interactive-mode.ts +79 -11
  92. package/src/modes/types.ts +11 -0
  93. package/src/prompts/system/memory-extraction-system.md +5 -22
  94. package/src/prompts/system/system-prompt.md +1 -1
  95. package/src/session/agent-session.ts +52 -6
  96. package/src/session/messages.ts +6 -0
  97. package/src/session/prewalk.ts +25 -7
  98. package/src/session/session-entries.ts +0 -1
  99. package/src/session/session-maintenance.ts +10 -1
  100. package/src/session/session-manager.ts +15 -0
  101. package/src/session/session-stats.ts +24 -3
  102. package/src/session/settings-stream-fn.ts +7 -0
  103. package/src/session/skill-title-input.ts +32 -0
  104. package/src/session/turn-recovery.ts +23 -18
  105. package/src/slash-commands/builtin-session.ts +1 -1
  106. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  107. package/src/subprocess/worker-client.ts +8 -5
  108. package/src/task/executor.ts +11 -0
  109. package/src/task/index.ts +2 -0
  110. package/src/task/label.ts +14 -1
  111. package/src/task/persisted-revive.ts +13 -0
  112. package/src/task/render.ts +1 -1
  113. package/src/task/structured-subagent.ts +5 -2
  114. package/src/tiny/completion-prompt.ts +16 -0
  115. package/src/tiny/title-client.ts +15 -6
  116. package/src/tiny/title-protocol.ts +8 -1
  117. package/src/tiny/worker.ts +21 -19
  118. package/src/tools/bash.ts +7 -1
  119. package/src/tools/file-write-fallback.ts +467 -0
  120. package/src/tools/index.ts +1 -0
  121. package/src/tools/path-utils.ts +79 -0
  122. package/src/tools/read-format.ts +16 -2
  123. package/src/tools/read-summary.ts +9 -4
  124. package/src/tools/read.ts +306 -72
  125. package/src/utils/block-context.ts +15 -1
  126. package/src/utils/fetch-timeout.ts +33 -0
  127. package/src/utils/git.ts +54 -11
  128. package/src/web/search/providers/browser-page.ts +21 -3
  129. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -17,7 +17,7 @@ import { expandEmoticons } from "../../modes/emoji-autocomplete";
17
17
  import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
18
18
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
19
19
  import { parseQueueShorthand, splitQueuedMessages } from "../../modes/queue-input";
20
- import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command";
20
+ import { buildSkillCommandPrompt, isKnownSkillCommand } from "../../modes/skill-command";
21
21
  import type { InteractiveModeContext } from "../../modes/types";
22
22
  import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
23
23
  import { USER_INTERRUPT_LABEL } from "../../session/messages";
@@ -173,6 +173,11 @@ export class InputController {
173
173
  },
174
174
  ) {}
175
175
 
176
+ /** Session-level title starts (user `/skill:` via promptCustomMessage) reuse this UI. */
177
+ notifyTitleGenerationStart(): void {
178
+ this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
179
+ }
180
+
176
181
  #enhancedPaste?: EnhancedPasteController;
177
182
  #focusedLeftTapListenerInstalled = false;
178
183
  #focusedPasteListenerInstalled = false;
@@ -1168,21 +1173,42 @@ export class InputController {
1168
1173
  };
1169
1174
 
1170
1175
  this.ctx.editor.clearDraft(text);
1176
+ let optimistic = false;
1171
1177
  try {
1172
- const handled = await invokeSkillCommandFromText(this.ctx, text, streamingBehavior, {
1173
- images: draftImages,
1174
- propagateErrors: true,
1175
- });
1176
- if (!handled) {
1178
+ // Build the user-attributed skill message once so the optimistic
1179
+ // transcript row and the dispatched message share content.
1180
+ const built = await buildSkillCommandPrompt(this.ctx, text, streamingBehavior, draftImages);
1181
+ if (!built) {
1177
1182
  restoreDraft();
1178
1183
  return false;
1179
1184
  }
1185
+ // Paint the row before the awaited dispatch so a slow preflight (memory
1186
+ // recall, before_agent_start hooks, auto-thinking, pre-prompt compaction)
1187
+ // does not leave the submission invisible (issue #8895). A streaming
1188
+ // submission queues instead and surfaces its chip, so only paint when the
1189
+ // turn will run fresh.
1190
+ optimistic = !this.ctx.session.isStreaming;
1191
+ if (optimistic) {
1192
+ // Mirror the message promptCustomMessage will build for the turn so the
1193
+ // canonical message_start reconciles this row rather than duplicating it.
1194
+ this.ctx.renderOptimisticSkillMessage(
1195
+ { role: "custom", ...built.message, timestamp: Date.now() },
1196
+ { imageLinks: draftImageLinks },
1197
+ );
1198
+ }
1199
+ await this.ctx.session.promptCustomMessage(built.message, built.options);
1180
1200
  return true;
1181
1201
  } catch (error) {
1202
+ if (optimistic) this.ctx.clearOptimisticSkillMessage();
1182
1203
  restoreDraft();
1183
1204
  this.ctx.showError(error instanceof Error ? error.message : String(error));
1184
1205
  return true;
1185
1206
  } finally {
1207
+ if (optimistic && this.ctx.optimisticSkillMessagePending) {
1208
+ // Dispatch resolved without a canonical skill message_start (aborted
1209
+ // preflight, or a streaming-race requeue): drop the pending row.
1210
+ this.ctx.clearOptimisticSkillMessage();
1211
+ }
1186
1212
  if (this.ctx.session.isStreaming) {
1187
1213
  this.ctx.updatePendingMessagesDisplay();
1188
1214
  this.ctx.ui.requestRender();
@@ -1637,6 +1663,38 @@ export class InputController {
1637
1663
  const focusedNow = this.ctx.ui.getFocused();
1638
1664
  const promptTarget =
1639
1665
  focusedNow && focusedNow !== this.ctx.editor && hasPasteText(focusedNow) ? focusedNow : null;
1666
+ // #8769: On macOS, Finder `Cmd+C` on an image file puts BOTH a
1667
+ // `public.file-url` representation and a generated 1024x1024
1668
+ // file-icon bitmap on the pasteboard. `arboard::get_image()`
1669
+ // succeeds with the icon, so probing the image representation first
1670
+ // would attach the generic Finder icon instead of the copied
1671
+ // screenshot — a vision model then sees a white `PNG` document
1672
+ // icon. Probe the file URLs before the bitmap and let any that
1673
+ // resolve to a supported image file win over the icon: the
1674
+ // authoritative file bytes are what the user copied.
1675
+ //
1676
+ // #3506: this branch also recovers file-url-only pasteboards
1677
+ // (Finder selections, certain screenshot tools) where
1678
+ // `arboard::get_image()` returns `ContentNotAvailable` and
1679
+ // `pbpaste` is empty. Every image-shaped path routes through
1680
+ // {@link handleImagePathPaste}, matching the bracketed-paste
1681
+ // handler in `CustomEditor.handleInput`; multi-image Finder
1682
+ // selections must not silently drop after the first attach.
1683
+ // `readMacFileUrls` returns an empty list off Darwin, so on every
1684
+ // other platform this is a no-op and the bitmap read below still
1685
+ // runs first.
1686
+ const fileUrls = promptTarget ? [] : ((await this.clipboard.readMacFileUrls?.()) ?? []);
1687
+ let attachedFromFileUrls = false;
1688
+ for (const url of fileUrls) {
1689
+ const candidate = extractImagePathFromText(url);
1690
+ if (!candidate) continue;
1691
+ await this.handleImagePathPaste(candidate);
1692
+ attachedFromFileUrls = true;
1693
+ }
1694
+ if (attachedFromFileUrls) return true;
1695
+ // No usable image-file URL (pure bitmap pasteboard: screenshots,
1696
+ // browser copies, or a non-image Finder selection). Fall to the
1697
+ // image representation.
1640
1698
  const image = await this.clipboard.readImage();
1641
1699
  if (image) {
1642
1700
  if (promptTarget) {
@@ -1652,27 +1710,6 @@ export class InputController {
1652
1710
  `Unsupported clipboard image format: ${image.mimeType}`,
1653
1711
  );
1654
1712
  }
1655
- // #3506: macOS Finder `Cmd+C` puts only a `public.file-url`
1656
- // representation on the pasteboard. `pbpaste` (the backing call
1657
- // for `readText` on Darwin) only surfaces plain text / RTF / EPS,
1658
- // so it returns empty for file-url-only pasteboards — the smart
1659
- // text fallback below would dead-end with "Clipboard is empty".
1660
- // Reach the file URL directly via AppleScript and route every
1661
- // image-shaped path through {@link handleImagePathPaste}, matching
1662
- // the bracketed-paste handler in `CustomEditor.handleInput` which
1663
- // iterates every extracted image path. Multi-image Finder
1664
- // selections must not silently drop after the first attach.
1665
- // `readMacFileUrls` returns an empty list off Darwin, so the
1666
- // check is free on every other platform.
1667
- const fileUrls = promptTarget ? [] : ((await this.clipboard.readMacFileUrls?.()) ?? []);
1668
- let attachedFromFileUrls = false;
1669
- for (const url of fileUrls) {
1670
- const candidate = extractImagePathFromText(url);
1671
- if (!candidate) continue;
1672
- await this.handleImagePathPaste(candidate);
1673
- attachedFromFileUrls = true;
1674
- }
1675
- if (attachedFromFileUrls) return true;
1676
1713
  // Smart paste (#1628): no image on the clipboard — fall back to
1677
1714
  // pasting its text so the same chord covers both payload kinds.
1678
1715
  // Hosts that pre-empt the terminal's own paste (VS Code's
@@ -1198,11 +1198,20 @@ export class MCPCommandController {
1198
1198
  connectionError = error as Error;
1199
1199
  }
1200
1200
 
1201
- // Server connected fine without auth — reauth is not needed. A tool-level
1202
- // challenge overrides this: servers may allow the anonymous handshake yet
1203
- // protect individual tool calls with `_meta["mcp/www_authenticate"]`.
1201
+ // Server connected fine without auth. A tool-level challenge overrides
1202
+ // this: servers may allow the anonymous handshake yet protect individual
1203
+ // tool calls with `_meta["mcp/www_authenticate"]`. Even without such a
1204
+ // challenge, a clean `initialize` is only weak evidence — per the MCP
1205
+ // spec a server MAY permit unauthenticated `initialize` while requiring a
1206
+ // bearer token for `tools/call`. The user explicitly asked to reauth, so
1207
+ // honor it when the server advertises OAuth discovery metadata; only
1208
+ // refuse when there is genuinely no OAuth endpoint to acquire.
1204
1209
  if (connectionSucceeded && !authChallenge) {
1205
- throw new Error("Server connection succeeded without OAuth; reauthorization is not required.");
1210
+ const discovered = "url" in config && config.url ? await discoverOAuthEndpoints(config.url) : null;
1211
+ if (!discovered) {
1212
+ throw new Error("Server connection succeeded without OAuth; reauthorization is not required.");
1213
+ }
1214
+ return discovered;
1206
1215
  }
1207
1216
 
1208
1217
  // Tool calls can carry richer RFC 6750/RFC 9728 hints than the original
@@ -117,7 +117,8 @@ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES, buildTuiBuiltinSlashCommands } fr
117
117
  import { formatDuration } from "../slash-commands/helpers/format";
118
118
  import { STTController, type SttState } from "../stt";
119
119
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
120
- import { formatTaskId } from "../task/render";
120
+ import { labelEchoesHandle } from "../task/label";
121
+ import { agentTypeBadge, formatTaskId } from "../task/render";
121
122
  import type { ConfiguredThinkingLevel } from "../thinking";
122
123
  import { tinyTitleClient } from "../tiny/title-client";
123
124
  import type { LspStartupServerInfo } from "../tools";
@@ -165,7 +166,7 @@ import type { HookInputComponent } from "./components/hook-input";
165
166
  import type { HookSelectorComponent, HookSelectorSlider } from "./components/hook-selector";
166
167
  import { type PlanReviewAnnotationState, PlanReviewOverlay } from "./components/plan-review-overlay";
167
168
  import { StatusLineComponent } from "./components/status-line";
168
- import type { ToolExecutionHandle } from "./components/tool-execution";
169
+ import { stopSharedSpinnerTicker, type ToolExecutionHandle } from "./components/tool-execution";
169
170
  import { TranscriptContainer } from "./components/transcript-container";
170
171
  import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome";
171
172
  import { BtwController } from "./controllers/btw-controller";
@@ -452,8 +453,8 @@ const SUBAGENT_OBSERVER_UI_COALESCE_MS = 100;
452
453
 
453
454
  /**
454
455
  * Build the anchored subagent HUD block: a bold accent "Subagents" header plus
455
- * a bounded set of running-agent rows in the same `Id: description` shape the
456
- * inline task rows use (muted task preview when no description was given).
456
+ * a bounded set of running-agent rows in the same `Id ⟨role⟩: description` shape
457
+ * the inline task rows use (muted task preview when no description was given).
457
458
  * Layout mirrors the Todos HUD exactly: unindented header, then
458
459
  * `renderTreeList` rows (dim connectors) shifted right by one space.
459
460
  * Only detached background spawns are listed: a sync task call blocks the
@@ -476,16 +477,23 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
476
477
  expanded: true,
477
478
  renderItem: session => {
478
479
  const displayId = formatTaskId(session.id);
479
- let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}`;
480
+ const role = session.agent ?? session.progress?.agent;
481
+ const badge = agentTypeBadge(role, theme);
482
+ let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}${badge}`;
480
483
  const description = session.description?.trim() || session.progress?.description?.trim();
481
- if (description) {
482
- const budget = Math.max(TRUNCATE_LENGTHS.SHORT, columns - visibleWidth(displayId) - 10);
483
- line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(description), budget))}`;
484
+ const distinctDescription =
485
+ description && !labelEchoesHandle(session.id, description) ? description : undefined;
486
+ if (distinctDescription) {
487
+ const budget = Math.max(
488
+ TRUNCATE_LENGTHS.SHORT,
489
+ columns - visibleWidth(displayId) - visibleWidth(Bun.stripANSI(badge)) - 10,
490
+ );
491
+ line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(distinctDescription), budget))}`;
484
492
  } else {
485
493
  // No spawn description: fall back to a muted task preview, same as
486
494
  // the inline task rows when a row has no label.
487
495
  const taskPreview = session.progress?.task?.trim();
488
- if (taskPreview) {
496
+ if (taskPreview && !labelEchoesHandle(session.id, taskPreview)) {
489
497
  line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`;
490
498
  }
491
499
  }
@@ -604,6 +612,10 @@ export class InteractiveMode implements InteractiveModeContext {
604
612
  #pendingSubmissionDispose: (() => void) | undefined;
605
613
  #pendingSubmissionPreservesDraft = false;
606
614
  #optimisticUserMessageComponents: Component[] = [];
615
+ #optimisticSkillMessageComponents: Component[] = [];
616
+ /** True while an optimistically-rendered `/skill:` row awaits its canonical
617
+ * `message_start`. Read by the event controller to reconcile the row. */
618
+ optimisticSkillMessagePending = false;
607
619
  lastSigintTime = 0;
608
620
  lastEscapeTime = 0;
609
621
  lastLeftTapTime = 0;
@@ -895,6 +907,9 @@ export class InteractiveMode implements InteractiveModeContext {
895
907
  this.#selectorController = new SelectorController(this);
896
908
  this.#focusController = new SessionFocusController(this);
897
909
  this.#inputController = new InputController(this);
910
+ this.session.setTitleGenerationStart?.(() => {
911
+ this.#inputController.notifyTitleGenerationStart();
912
+ });
898
913
  this.#observerRegistry = new SessionObserverRegistry();
899
914
  }
900
915
 
@@ -1678,6 +1693,51 @@ export class InteractiveMode implements InteractiveModeContext {
1678
1693
  this.addMessageToChat(message, options);
1679
1694
  }
1680
1695
 
1696
+ /**
1697
+ * Optimistically render a user-invoked `/skill:` row before its awaited
1698
+ * dispatch so a slow preflight (memory recall, `before_agent_start` hooks,
1699
+ * auto-thinking classification, pre-prompt compaction) does not leave the
1700
+ * submission invisible — normal prompts paint their row via
1701
+ * {@link startPendingSubmission} the same way (issue #8895). The canonical
1702
+ * skill `message_start` swaps this row in place via
1703
+ * {@link reconcileOptimisticSkillMessage}; a failed or bailed dispatch drops
1704
+ * it via {@link clearOptimisticSkillMessage}.
1705
+ */
1706
+ renderOptimisticSkillMessage(
1707
+ message: AgentMessage,
1708
+ options?: { imageLinks?: readonly (string | undefined)[] },
1709
+ ): void {
1710
+ this.clearOptimisticSkillMessage();
1711
+ this.optimisticSkillMessagePending = true;
1712
+ this.#optimisticSkillMessageComponents = this.#captureAddedChatComponents(() => {
1713
+ this.addMessageToChat(message, options);
1714
+ });
1715
+ this.ensureLoadingAnimation();
1716
+ this.ui.requestRender();
1717
+ }
1718
+
1719
+ /** Replace the optimistic `/skill:` row with the canonical message emitted by
1720
+ * the session, mirroring {@link replaceOptimisticUserMessage} for skills. */
1721
+ reconcileOptimisticSkillMessage(message: AgentMessage): void {
1722
+ this.optimisticSkillMessagePending = false;
1723
+ for (const component of this.#optimisticSkillMessageComponents) {
1724
+ this.chatContainer.removeChild(component);
1725
+ }
1726
+ this.#optimisticSkillMessageComponents = [];
1727
+ this.addMessageToChat(message);
1728
+ }
1729
+
1730
+ /** Drop the optimistic `/skill:` row when dispatch fails or bails before the
1731
+ * message reaches the agent (aborted preflight, streaming-race requeue). */
1732
+ clearOptimisticSkillMessage(): void {
1733
+ this.optimisticSkillMessagePending = false;
1734
+ if (this.#optimisticSkillMessageComponents.length === 0) return;
1735
+ for (const component of this.#optimisticSkillMessageComponents) {
1736
+ this.chatContainer.removeChild(component);
1737
+ }
1738
+ this.#optimisticSkillMessageComponents = [];
1739
+ }
1740
+
1681
1741
  startPendingSubmission(
1682
1742
  input: {
1683
1743
  text: string;
@@ -4149,6 +4209,9 @@ export class InteractiveMode implements InteractiveModeContext {
4149
4209
  this.#stopLoadingAnimation(false);
4150
4210
  }
4151
4211
  this.#cleanupMicAnimation();
4212
+ // Stop the shared tool-spinner ticker: a live block missed by per-component
4213
+ // stopAnimation would otherwise keep an 80ms interval pinning the process.
4214
+ stopSharedSpinnerTicker();
4152
4215
  this.#liveCommandController.dispose();
4153
4216
  this.#cancelTodoAutoClearTimer();
4154
4217
  this.#cancelObserverUiSyncTimer();
@@ -4239,10 +4302,15 @@ export class InteractiveMode implements InteractiveModeContext {
4239
4302
  popTerminalTitle();
4240
4303
  this.stop();
4241
4304
 
4242
- // Print resumption hint if this is a persisted session
4305
+ // Print resumption hint only if the session was actually materialized to
4306
+ // durable storage. Persistence is lazy — a session that exits before its
4307
+ // first assistant message (or dies early to an auth error, a mid-flight
4308
+ // Ctrl+C, or a launch-then-quit) never wrote its JSONL, so the path is
4309
+ // allocated but the file does not exist and `--resume <id>` would fail
4310
+ // (issue #8860).
4243
4311
  const sessionId = this.sessionManager.getSessionId();
4244
4312
  const sessionFile = this.sessionManager.getSessionFile();
4245
- if (sessionId && sessionFile) {
4313
+ if (sessionId && sessionFile && this.sessionManager.isSessionOnDisk()) {
4246
4314
  process.stderr.write(`\n${chalk.dim(`Resume this session with ${APP_NAME} --resume ${sessionId}`)}\n`);
4247
4315
  }
4248
4316
 
@@ -313,6 +313,17 @@ export interface InteractiveModeContext {
313
313
  message: AgentMessage,
314
314
  options?: { imageLinks?: readonly (string | undefined)[] },
315
315
  ): void;
316
+ /** True while an optimistically-rendered `/skill:` row awaits its canonical `message_start`. */
317
+ optimisticSkillMessagePending: boolean;
318
+ /** Optimistically renders a user-invoked `/skill:` row before its awaited dispatch (issue #8895). */
319
+ renderOptimisticSkillMessage(
320
+ message: AgentMessage,
321
+ options?: { imageLinks?: readonly (string | undefined)[] },
322
+ ): void;
323
+ /** Swaps the optimistic `/skill:` row for the canonical message emitted by the session. */
324
+ reconcileOptimisticSkillMessage(message: AgentMessage): void;
325
+ /** Drops the optimistic `/skill:` row when dispatch fails or bails before reaching the agent. */
326
+ clearOptimisticSkillMessage(): void;
316
327
  isKnownSlashCommand(text: string): boolean;
317
328
  addMessageToChat(
318
329
  message: AgentMessage,
@@ -1,26 +1,9 @@
1
- Extract durable, long-term memory items from the user message below.
1
+ You are a precise long-term memory extractor.
2
2
 
3
- Output ONE item per line as a short plain-text statement: no JSON, no bullets, no numbering, no field labels.
4
- Capture only persistent, reusable information:
5
- - facts (name, role, employer, config, ports, versions, numbers)
6
- - explicit instructions to the assistant
7
- - stable preferences
8
- - dated events or deadlines
3
+ Extract only persistent information explicitly stated in the user message: stable facts, explicit instructions to the assistant, stable preferences, dates, deadlines, paths, ports, and versions.
9
4
 
10
- Keep names, numbers, versions, and dates exact, in the message's original language. When a value is updated, output only the latest value. Ignore greetings, acknowledgements, small talk, weather, and one-off remarks.
11
- If nothing qualifies, output exactly: NO_FACTS
5
+ Never infer, explain, invent, or copy information from another message. Ignore greetings, acknowledgements, weather, and one-off plans. When a value is corrected, output only the latest value.
12
6
 
13
- Example
14
- Message: My name is Sam, I work at Globex, and I always use 2-space indents.
15
- Items:
16
- name is Sam
17
- works at Globex
18
- prefers 2-space indents
7
+ Preserve names, numbers, paths, versions, dates, and the original language exactly. Output one short plain-text fact per line with no bullets, numbering, labels, JSON, or commentary.
19
8
 
20
- Example
21
- Message: lol nice weather today, might grab a coffee later
22
- Items:
23
- NO_FACTS
24
-
25
- Message: {text}
26
- Items:
9
+ If nothing qualifies, output exactly NO_FACTS.
@@ -96,7 +96,7 @@ Write JSON args as `content` to `xd://<tool>` via `{{toolRefs.write}}`. Invalid
96
96
 
97
97
  {{#has tools "think"}}
98
98
  § Scratchpad
99
- `{{toolRefs.think}}`: private scratchpad; not shown to user.
99
+ `{{toolRefs.think}}`: private scratchpad; not shown to user. MUST use for planning; other tools become callable when it completes.
100
100
  {{/has}}
101
101
 
102
102
  § Tool Policy
@@ -338,6 +338,7 @@ import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./ses
338
338
  import { SessionStatsTracker, type SessionStatsTrackerHost } from "./session-stats";
339
339
  import { SessionTools, type SessionToolsHost } from "./session-tools";
340
340
  import type { ShakeMode, ShakeResult } from "./shake-types";
341
+ import { skillPromptTitleInput } from "./skill-title-input";
341
342
  import { ToolChoiceQueue } from "./tool-choice-queue";
342
343
  import { planTurnPersistence, sameMessageContent, sessionMessagePersistenceKey } from "./turn-persistence";
343
344
  import { TurnRecovery, type TurnRecoveryHost } from "./turn-recovery";
@@ -533,6 +534,8 @@ export class AgentSession {
533
534
  * generation path. Refresh via {@link AgentSession.setTitleSystemPrompt} when
534
535
  * the session cwd changes. */
535
536
  #titleSystemPrompt: string | undefined;
537
+ #titleGenerationStart: (() => void) | undefined;
538
+ #titleGenerationInFlightFor: string | undefined;
536
539
  #titleGenerationAbortController = new AbortController();
537
540
  #toolChoiceQueue = new ToolChoiceQueue();
538
541
 
@@ -1011,8 +1014,13 @@ export class AgentSession {
1011
1014
  emitNotice: (level, message, source) => this.emitNotice(level, message, source),
1012
1015
  setModelTemporary: (model, thinkingLevel, options) => this.setModelTemporary(model, thinkingLevel, options),
1013
1016
  setActiveToolsByName: names => this.setActiveToolsByName(names),
1017
+ setActiveToolPresentation: (toolNames, mountedToolNames) =>
1018
+ this.setActiveToolPresentation(toolNames, mountedToolNames),
1019
+ runToolRegistryMutation: mutation => this.runToolRegistryMutation(mutation),
1014
1020
  getActiveToolNames: () => this.getActiveToolNames(),
1015
1021
  getEnabledToolNames: () => this.getEnabledToolNames(),
1022
+ getSelectedMCPToolNames: () => this.getSelectedMCPToolNames(),
1023
+ getMountedXdevToolNames: () => this.getMountedXdevToolNames(),
1016
1024
  hasBuiltInTool: name => this.hasBuiltInTool(name),
1017
1025
  getPlanModeState: () => this.getPlanModeState(),
1018
1026
  setPlanModeState: state => this.setPlanModeState(state),
@@ -2361,6 +2369,7 @@ export class AgentSession {
2361
2369
  assistantMsg.contextSnapshot = {
2362
2370
  promptTokens: calculatePromptTokens(assistantMsg.usage),
2363
2371
  nonMessageTokens: this.#stats.pendingNonMessageTokens ?? computeNonMessageTokens(this),
2372
+ compactionEpoch: this.#stats.compactionEpoch,
2364
2373
  };
2365
2374
  }
2366
2375
  }
@@ -5466,11 +5475,20 @@ export class AgentSession {
5466
5475
  let keywordNotices: CustomMessage[] = [];
5467
5476
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user") {
5468
5477
  const details = message.details;
5478
+ let skillName: string | undefined;
5469
5479
  let skillArgs = "";
5470
- if (details && typeof details === "object" && "args" in details && typeof details.args === "string") {
5471
- skillArgs = details.args;
5480
+ if (details && typeof details === "object") {
5481
+ if ("name" in details && typeof details.name === "string") skillName = details.name;
5482
+ if ("args" in details && typeof details.args === "string") skillArgs = details.args;
5472
5483
  }
5473
5484
  keywordNotices = this.#createMagicKeywordNotices(skillArgs);
5485
+ this.maybeStartTitleGeneration(
5486
+ skillPromptTitleInput({
5487
+ name: skillName,
5488
+ args: skillArgs,
5489
+ queueChipText: options?.queueChipText,
5490
+ }),
5491
+ );
5474
5492
  }
5475
5493
 
5476
5494
  if (options?.queueOnly) {
@@ -6538,14 +6556,31 @@ export class AgentSession {
6538
6556
  this.#extensionRunner?.getCommand(
6539
6557
  extensionCommandSpace === -1 ? firstMessage.slice(1) : firstMessage.slice(1, extensionCommandSpace),
6540
6558
  ) !== undefined;
6541
- if (isLocalExtensionCommand || this.sessionName || $env.PI_NO_TITLE || isLowSignalTitleInput(firstMessage)) {
6559
+ const sessionId = this.sessionManager.getSessionId();
6560
+ if (
6561
+ isLocalExtensionCommand ||
6562
+ this.sessionName ||
6563
+ this.#titleGenerationInFlightFor === sessionId ||
6564
+ $env.PI_NO_TITLE ||
6565
+ isLowSignalTitleInput(firstMessage)
6566
+ ) {
6542
6567
  return;
6543
6568
  }
6544
- onStart?.();
6569
+ this.#titleGenerationInFlightFor = sessionId;
6570
+ try {
6571
+ (onStart ?? this.#titleGenerationStart)?.();
6572
+ } catch (error) {
6573
+ if (this.#titleGenerationInFlightFor === sessionId) {
6574
+ this.#titleGenerationInFlightFor = undefined;
6575
+ }
6576
+ throw error;
6577
+ }
6545
6578
  this.generateTitle(firstMessage)
6546
6579
  .then(async title => {
6547
- // Re-check after generation so concurrent attempts cannot replace
6548
- // the first title that completed.
6580
+ // Re-check after generation so a later completion cannot replace
6581
+ // the first title, and a request from a replaced session cannot
6582
+ // name the current one.
6583
+ if (this.sessionManager.getSessionId() !== sessionId) return;
6549
6584
  if (title && !this.sessionName) {
6550
6585
  await this.sessionManager.setSessionName(title, "auto");
6551
6586
  }
@@ -6556,6 +6591,11 @@ export class AgentSession {
6556
6591
  reason: "uncaught-auto-title-error",
6557
6592
  error: err instanceof Error ? err.message : String(err),
6558
6593
  });
6594
+ })
6595
+ .finally(() => {
6596
+ if (this.#titleGenerationInFlightFor === sessionId) {
6597
+ this.#titleGenerationInFlightFor = undefined;
6598
+ }
6559
6599
  });
6560
6600
  }
6561
6601
 
@@ -6602,6 +6642,12 @@ export class AgentSession {
6602
6642
  this.#titleSystemPrompt = prompt;
6603
6643
  }
6604
6644
 
6645
+ /** Install the interactive title-download UI hook. Used when `/skill:` starts
6646
+ * titling from {@link promptCustomMessage} without the input-controller callback. */
6647
+ setTitleGenerationStart(handler: (() => void) | undefined): void {
6648
+ this.#titleGenerationStart = handler;
6649
+ }
6650
+
6605
6651
  /**
6606
6652
  * Abort current operation and wait for agent to become idle.
6607
6653
  *
@@ -38,6 +38,7 @@ export {
38
38
 
39
39
  import type { OutputMeta } from "../tools/output-meta";
40
40
  import { formatOutputNotice } from "../tools/output-meta";
41
+ import { titleTextFromSkillPrompt } from "./skill-title-input";
41
42
 
42
43
  export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
43
44
  export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
@@ -163,6 +164,11 @@ function thinkingFromContent(content: unknown): string {
163
164
  }
164
165
 
165
166
  function titleConversationTurnFromMessage(message: AgentMessage): TitleConversationTurn | undefined {
167
+ if (message.role === "custom") {
168
+ const text = titleTextFromSkillPrompt(message);
169
+ if (!text) return undefined;
170
+ return { role: "user", text };
171
+ }
166
172
  if (message.role !== "user" && message.role !== "assistant") return undefined;
167
173
  const text = textFromContent(message.content);
168
174
  const thinking = message.role === "assistant" ? thinkingFromContent(message.content) : undefined;
@@ -11,6 +11,7 @@ import prewalkChecklistPrompt from "../prompts/system/prewalk-checklist.md" with
11
11
  import prewalkContinuePrompt from "../prompts/system/prewalk-continue.md" with { type: "text" };
12
12
  import prewalkPlanPrompt from "../prompts/system/prewalk-plan.md" with { type: "text" };
13
13
  import { type ConfiguredThinkingLevel, prewalkWouldBeNoop } from "../thinking";
14
+ import { isMCPToolName } from "../tools/builtin-names";
14
15
  import type { PlanProposalHandler } from "../tools/resolve";
15
16
  import { ToolError } from "../tools/tool-errors";
16
17
  import type { PlanYolo, Prewalk } from "./agent-session-types";
@@ -65,8 +66,12 @@ export interface PrewalkCoordinatorHost {
65
66
  options?: { ephemeral?: boolean },
66
67
  ): Promise<void>;
67
68
  setActiveToolsByName(names: string[]): Promise<void>;
69
+ setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void>;
70
+ runToolRegistryMutation<T>(mutation: () => Promise<T>): Promise<T>;
68
71
  getActiveToolNames(): string[];
69
72
  getEnabledToolNames(): string[];
73
+ getSelectedMCPToolNames(): string[];
74
+ getMountedXdevToolNames(): string[];
70
75
  hasBuiltInTool(name: string): boolean;
71
76
  getPlanModeState(): PlanModeState | undefined;
72
77
  setPlanModeState(state: PlanModeState | undefined): void;
@@ -90,7 +95,7 @@ export class PrewalkCoordinator {
90
95
  #continuePending = false;
91
96
  #todoSeen = false;
92
97
  #planYolo: PlanYolo | undefined;
93
- #planYoloPreviousTools: string[] | undefined;
98
+ #planYoloPreviousNonMCPPresentation: { enabled: string[]; mounted: string[] } | undefined;
94
99
  #planYoloArmed = false;
95
100
 
96
101
  constructor(host: PrewalkCoordinatorHost, options: PrewalkCoordinatorOptions = {}) {
@@ -247,10 +252,14 @@ export class PrewalkCoordinator {
247
252
  async armPlanYoloIfNeeded(): Promise<void> {
248
253
  if (!this.#planYolo || this.#planYoloArmed) return;
249
254
  this.#planYoloArmed = true;
250
- const previousTools = this.#host.getEnabledToolNames();
255
+ const previousEnabledTools = this.#host.getEnabledToolNames();
256
+ const previousMountedTools = this.#host.getMountedXdevToolNames();
251
257
  const augmentations = this.#host.hasBuiltInTool("write") ? ["write"] : [];
252
- await this.#host.setActiveToolsByName([...new Set([...previousTools, ...augmentations])]);
253
- this.#planYoloPreviousTools = previousTools;
258
+ await this.#host.setActiveToolsByName([...new Set([...previousEnabledTools, ...augmentations])]);
259
+ this.#planYoloPreviousNonMCPPresentation = {
260
+ enabled: previousEnabledTools.filter(name => !isMCPToolName(name)),
261
+ mounted: previousMountedTools.filter(name => !isMCPToolName(name)),
262
+ };
254
263
  this.#host.setPlanModeState({
255
264
  enabled: true,
256
265
  planFilePath: this.#host.getPlanReferencePath() || "local://PLAN.md",
@@ -287,16 +296,25 @@ export class PrewalkCoordinator {
287
296
  listPlanFiles: () => listPlanFiles({ localProtocolOptions: this.#host.localProtocolOptions() }),
288
297
  });
289
298
  this.#host.setPlanModeState(undefined);
290
- const previousTools = this.#planYoloPreviousTools;
299
+ const previousPresentation = this.#planYoloPreviousNonMCPPresentation;
291
300
  try {
292
- if (previousTools) await this.#host.setActiveToolsByName(previousTools);
301
+ if (previousPresentation) {
302
+ await this.#host.runToolRegistryMutation(async () => {
303
+ const liveMCP = this.#host.getSelectedMCPToolNames();
304
+ const liveMountedMCP = this.#host.getMountedXdevToolNames().filter(isMCPToolName);
305
+ await this.#host.setActiveToolPresentation(
306
+ [...new Set([...previousPresentation.enabled, ...liveMCP])],
307
+ [...new Set([...previousPresentation.mounted, ...liveMountedMCP])],
308
+ );
309
+ });
310
+ }
293
311
  } catch (error) {
294
312
  this.#host.setPlanModeState(state);
295
313
  throw error;
296
314
  }
297
315
  this.#host.setPlanProposalHandler(null);
298
316
  this.#planYolo = undefined;
299
- this.#planYoloPreviousTools = undefined;
317
+ this.#planYoloPreviousNonMCPPresentation = undefined;
300
318
  await this.#host.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
301
319
  this.#host.emitNotice(
302
320
  "info",
@@ -171,7 +171,6 @@ declare module "@oh-my-pi/pi-agent-core/compaction/entries" {
171
171
  interface CustomCompactionSessionEntries {
172
172
  titleChange: TitleChangeEntry;
173
173
  credentialPin: CredentialPinEntry;
174
- resetBoundary: ResetBoundaryEntry;
175
174
  }
176
175
  }
177
176
 
@@ -477,7 +477,9 @@ export class SessionMaintenance {
477
477
  const config = this.#withPlanProtection({
478
478
  ...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG),
479
479
  // Skip entries summarized away by the latest compaction — shaking them
480
- // only churns persisted history with no prompt/cache effect.
480
+ // only churns persisted history with no prompt/cache effect. The cut is
481
+ // unconditional on the wire (see `buildSessionContext`), so a compaction
482
+ // the active model cannot replay still hides its prefix from the prompt.
481
483
  keepBoundaryId: latestCompaction?.firstKeptEntryId,
482
484
  });
483
485
  const regions = collectShakeRegions(branchEntries, config);
@@ -2729,9 +2731,16 @@ export class SessionMaintenance {
2729
2731
  }
2730
2732
 
2731
2733
  const retryAfterMs = this.#host.parseRetryAfterMsFromError(message);
2734
+ // An input the summarizer cannot fit is deterministic: the same
2735
+ // prompt fails identically every attempt, so the retry budget is
2736
+ // pure latency and the next candidate (a larger window) is the
2737
+ // only move that can succeed. Overflow therefore vetoes the
2738
+ // transient/usage-limit arms, which a provider blob can trip on
2739
+ // coincidence alone.
2732
2740
  const shouldRetry =
2733
2741
  retrySettings.enabled &&
2734
2742
  attempt < retrySettings.maxRetries &&
2743
+ !AIError.is(id, AIError.Flag.ContextOverflow) &&
2735
2744
  (retryAfterMs !== undefined ||
2736
2745
  AIError.is(id, AIError.Flag.Transient) ||
2737
2746
  AIError.is(id, AIError.Flag.UsageLimit));