@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.2

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 (102) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/startup-cwd.d.ts +2 -0
  3. package/dist/types/commands/launch.d.ts +3 -0
  4. package/dist/types/config/keybindings.d.ts +2 -2
  5. package/dist/types/config/model-provider-priority.d.ts +1 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -1
  7. package/dist/types/config/settings.d.ts +7 -2
  8. package/dist/types/debug/report-bundle.d.ts +3 -0
  9. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  10. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  12. package/dist/types/lsp/client.d.ts +10 -0
  13. package/dist/types/main.d.ts +3 -9
  14. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  16. package/dist/types/modes/components/status-line.d.ts +2 -0
  17. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/magic-keywords.d.ts +1 -1
  20. package/dist/types/modes/markdown-prose.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +3 -0
  22. package/dist/types/modes/workflow.d.ts +3 -3
  23. package/dist/types/session/auth-storage.d.ts +1 -1
  24. package/dist/types/session/session-manager.d.ts +5 -2
  25. package/dist/types/task/executor.d.ts +10 -0
  26. package/dist/types/tools/eval.d.ts +8 -0
  27. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  28. package/dist/types/tools/github-cache.d.ts +12 -0
  29. package/dist/types/tools/path-utils.d.ts +8 -0
  30. package/dist/types/tools/search.d.ts +2 -2
  31. package/dist/types/tools/yield.d.ts +8 -0
  32. package/package.json +9 -9
  33. package/src/cli/args.ts +3 -1
  34. package/src/cli/dry-balance-cli.ts +2 -4
  35. package/src/cli/startup-cwd.ts +68 -0
  36. package/src/commands/launch.ts +3 -0
  37. package/src/commit/model-selection.ts +3 -2
  38. package/src/config/model-provider-priority.ts +55 -0
  39. package/src/config/model-registry.ts +4 -22
  40. package/src/config/model-resolver.ts +39 -7
  41. package/src/config/settings.ts +86 -41
  42. package/src/debug/index.ts +8 -0
  43. package/src/debug/raw-sse-buffer.ts +7 -4
  44. package/src/debug/report-bundle.ts +9 -0
  45. package/src/edit/file-snapshot-store.ts +33 -1
  46. package/src/edit/hashline/filesystem.ts +2 -1
  47. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  48. package/src/eval/js/context-manager.ts +32 -15
  49. package/src/eval/llm-bridge.ts +14 -3
  50. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  51. package/src/eval/py/executor.ts +23 -11
  52. package/src/eval/py/prelude.py +1 -1
  53. package/src/extensibility/extensions/types.ts +10 -1
  54. package/src/internal-urls/docs-index.generated.ts +3 -3
  55. package/src/lsp/client.ts +23 -11
  56. package/src/lsp/config.ts +11 -1
  57. package/src/lsp/index.ts +61 -9
  58. package/src/main.ts +91 -65
  59. package/src/mcp/tool-bridge.ts +2 -0
  60. package/src/memories/index.ts +2 -2
  61. package/src/modes/components/custom-editor.ts +143 -111
  62. package/src/modes/components/model-selector.ts +59 -13
  63. package/src/modes/components/oauth-selector.ts +33 -7
  64. package/src/modes/components/status-line.ts +19 -4
  65. package/src/modes/components/tips.txt +1 -1
  66. package/src/modes/components/user-message.ts +1 -1
  67. package/src/modes/controllers/event-controller.ts +26 -0
  68. package/src/modes/controllers/input-controller.ts +46 -7
  69. package/src/modes/interactive-mode.ts +107 -20
  70. package/src/modes/magic-keywords.ts +1 -1
  71. package/src/modes/markdown-prose.ts +1 -1
  72. package/src/modes/theme/shimmer.ts +20 -9
  73. package/src/modes/types.ts +3 -0
  74. package/src/modes/workflow.ts +10 -10
  75. package/src/prompts/system/workflow-notice.md +1 -1
  76. package/src/prompts/tools/bash.md +9 -0
  77. package/src/prompts/tools/browser.md +1 -1
  78. package/src/prompts/tools/eval.md +2 -1
  79. package/src/prompts/tools/read.md +2 -2
  80. package/src/sdk.ts +26 -9
  81. package/src/session/agent-session.ts +37 -12
  82. package/src/session/auth-storage.ts +2 -0
  83. package/src/session/session-manager.ts +96 -23
  84. package/src/task/executor.ts +71 -36
  85. package/src/task/render.ts +3 -4
  86. package/src/tools/bash.ts +7 -0
  87. package/src/tools/browser/tab-supervisor.ts +13 -1
  88. package/src/tools/browser/tab-worker.ts +33 -4
  89. package/src/tools/eval.ts +13 -2
  90. package/src/tools/find.ts +7 -0
  91. package/src/tools/gh-cache-invalidation.ts +200 -0
  92. package/src/tools/github-cache.ts +25 -0
  93. package/src/tools/inspect-image.ts +2 -2
  94. package/src/tools/path-utils.ts +28 -2
  95. package/src/tools/plan-mode-guard.ts +52 -7
  96. package/src/tools/read.ts +25 -12
  97. package/src/tools/search.ts +38 -3
  98. package/src/tools/write.ts +2 -2
  99. package/src/tools/yield.ts +10 -1
  100. package/src/utils/commit-message-generator.ts +2 -2
  101. package/src/utils/enhanced-paste.ts +30 -2
  102. package/src/web/search/providers/codex.ts +37 -8
@@ -26,6 +26,16 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
26
26
 
27
27
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
28
28
 
29
+ /**
30
+ * Loader label shown the instant a user interrupt (Esc) is requested, kept until
31
+ * the agent turn fully unwinds. Esc fires the abort synchronously, but the loop
32
+ * only stops the spinner at `agent_end`, which it cannot reach until every
33
+ * in-flight tool settles its abort in `executeToolCalls` (`Promise.allSettled`).
34
+ * Swapping the steady "Working…" for this acknowledges the keypress instead of
35
+ * reading as an ignored Esc for the seconds a slow tool takes to tear down.
36
+ */
37
+ export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
38
+
29
39
  // Events that change foreground streaming state, or that reset a turn. The TUI
30
40
  // eager native-scrollback rebuild mode is recomputed only on these so unrelated
31
41
  // IRC/notices/status refreshes do not toggle scrollback replay policy.
@@ -57,6 +67,7 @@ export class EventController {
57
67
  #backgroundToolCallIds = new Set<string>();
58
68
  #assistantMessageStreaming = false;
59
69
  #agentTurnActive = false;
70
+ #interrupting = false;
60
71
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
61
72
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
62
73
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -167,6 +178,7 @@ export class EventController {
167
178
  return true;
168
179
  }
169
180
  #updateWorkingMessageFromIntent(intent: unknown): void {
181
+ if (this.#interrupting) return;
170
182
  // Streamed JSON can deliver non-string `_i` (object, number, boolean) before
171
183
  // schema validation; `?.` only guards null/undefined, so guard the type too.
172
184
  if (typeof intent !== "string") return;
@@ -176,6 +188,19 @@ export class EventController {
176
188
  this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`);
177
189
  }
178
190
 
191
+ /**
192
+ * Acknowledge a user interrupt (Esc) immediately: switch the loader to
193
+ * `INTERRUPTING_WORKING_MESSAGE` and freeze intent-driven working-message
194
+ * updates for the rest of the turn so a late `tool_execution_start` intent
195
+ * cannot repaint a "Working…/<intent>" line over the acknowledgment. Reset at
196
+ * the next `agent_start`. No-op outside an active turn or if already set.
197
+ */
198
+ notifyInterrupting(): void {
199
+ if (!this.#agentTurnActive || this.#interrupting) return;
200
+ this.#interrupting = true;
201
+ this.ctx.setWorkingMessage(INTERRUPTING_WORKING_MESSAGE);
202
+ }
203
+
179
204
  subscribeToAgent(): void {
180
205
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
181
206
  await this.handleEvent(event);
@@ -220,6 +245,7 @@ export class EventController {
220
245
 
221
246
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
222
247
  this.#agentTurnActive = true;
248
+ this.#interrupting = false;
223
249
  this.#lastIntent = undefined;
224
250
  this.#readToolCallArgs.clear();
225
251
  this.#readToolCallAssistantComponents.clear();
@@ -94,6 +94,7 @@ export class InputController {
94
94
  if (this.ctx.loopModeEnabled) {
95
95
  this.ctx.pauseLoop();
96
96
  if (this.ctx.session.isStreaming) {
97
+ this.ctx.notifyInterrupting();
97
98
  void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
98
99
  } else {
99
100
  this.ctx.cancelPendingSubmission();
@@ -124,6 +125,7 @@ export class InputController {
124
125
  this.ctx.isPythonMode = false;
125
126
  this.ctx.updateEditorBorderColor();
126
127
  } else if (this.ctx.session.isStreaming) {
128
+ this.ctx.notifyInterrupting();
127
129
  void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
128
130
  } else if (!this.ctx.editor.getText().trim()) {
129
131
  // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
@@ -499,17 +501,44 @@ export class InputController {
499
501
  }
500
502
 
501
503
  handleCtrlZ(): void {
502
- // Set up handler to restore TUI when resumed
503
- process.once("SIGCONT", () => {
504
+ // SIGTSTP is POSIX job-control: Windows has no equivalent and
505
+ // `process.kill(_, "SIGTSTP")` throws `TypeError: Unknown signal:
506
+ // SIGTSTP` there, taking the whole agent down via an uncaught
507
+ // exception (issue #2036). No-op on platforms that cannot suspend.
508
+ if (process.platform === "win32") {
509
+ this.ctx.showStatus("Suspend (Ctrl+Z) is not supported on this platform");
510
+ return;
511
+ }
512
+
513
+ // Capture the listener so we can detach it if the signal never
514
+ // fires; otherwise a failed suspend would leave a stale SIGCONT
515
+ // handler that fires on the next unrelated continue and tries to
516
+ // re-`start()` an already-running TUI.
517
+ const onResume = (): void => {
504
518
  this.ctx.ui.start();
505
519
  this.ctx.ui.requestRender(true);
506
- });
520
+ };
521
+ process.once("SIGCONT", onResume);
507
522
 
508
- // Stop the TUI (restore terminal to normal mode)
523
+ // Stop the TUI (restore terminal to normal mode) before sending the
524
+ // signal so the parent shell sees a sane terminal state.
509
525
  this.ctx.ui.stop();
510
526
 
511
- // Send SIGTSTP to process group (pid=0 means all processes in group)
512
- process.kill(0, "SIGTSTP");
527
+ try {
528
+ // pid=0 → entire foreground process group; the shell receives
529
+ // SIGTSTP and parks the job.
530
+ process.kill(0, "SIGTSTP");
531
+ } catch (err) {
532
+ // Either the runtime refused the signal or the kernel rejected
533
+ // it (some sandboxes block sending to pid=0). Tear the resume
534
+ // hook down and bring the TUI back so the user is not stranded
535
+ // on a frozen prompt.
536
+ process.removeListener("SIGCONT", onResume);
537
+ this.ctx.ui.start();
538
+ this.ctx.ui.requestRender(true);
539
+ const reason = err instanceof Error ? err.message : String(err);
540
+ this.ctx.showError(`Failed to suspend: ${reason}`);
541
+ }
513
542
  }
514
543
 
515
544
  handleDequeue(): void {
@@ -589,7 +618,7 @@ export class InputController {
589
618
 
590
619
  /** Send editor text as a follow-up message (queued behind current stream). */
591
620
  async handleFollowUp(): Promise<void> {
592
- const text = this.ctx.editor.getText().trim();
621
+ let text = this.ctx.editor.getText().trim();
593
622
  if (!text) return;
594
623
 
595
624
  // Compaction first: while compacting, free text gets queued via
@@ -603,6 +632,16 @@ export class InputController {
603
632
  return;
604
633
  }
605
634
 
635
+ const slashResult = await executeBuiltinSlashCommand(text, {
636
+ ctx: this.ctx,
637
+ });
638
+ if (slashResult === true) {
639
+ return;
640
+ }
641
+ if (typeof slashResult === "string") {
642
+ text = slashResult;
643
+ }
644
+
606
645
  // Skill commands invoke through the custom-message path regardless of
607
646
  // which keybinding submitted them. Enter routes them as `steer`;
608
647
  // Ctrl+Enter (this handler) routes them as `followUp`.
@@ -50,7 +50,7 @@ import chalk from "chalk";
50
50
  import { reset as resetCapabilities } from "../capability";
51
51
  import { KeybindingsManager } from "../config/keybindings";
52
52
  import { MODEL_ROLES, type ModelRole } from "../config/model-registry";
53
- import { isSettingsInitialized, Settings, settings } from "../config/settings";
53
+ import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
54
54
  import { clearClaudePluginRootsCache } from "../discovery/helpers";
55
55
  import type {
56
56
  ContextUsage,
@@ -125,7 +125,7 @@ import {
125
125
  import { OAuthManualInputManager } from "./oauth-manual-input";
126
126
  import { SessionObserverRegistry } from "./session-observer-registry";
127
127
  import { interruptHint } from "./shared";
128
- import { type ShimmerPalette, shimmerSegments, shimmerText } from "./theme/shimmer";
128
+ import { type ShimmerPalette, shimmerEnabled, shimmerSegments, shimmerText } from "./theme/shimmer";
129
129
  import type { Theme } from "./theme/theme";
130
130
  import {
131
131
  getEditorTheme,
@@ -157,6 +157,12 @@ interface WorkingMessageAccent {
157
157
  dim: string;
158
158
  }
159
159
 
160
+ interface WorkingMessageAccentCacheKey {
161
+ sessionName: string | undefined;
162
+ accentSurfaceLuminance: number | undefined;
163
+ sessionAccentEnabled: boolean;
164
+ }
165
+
160
166
  function renderWorkingMessage(message: string, accent?: WorkingMessageAccent): string {
161
167
  const palette = accent
162
168
  ? ({
@@ -301,6 +307,9 @@ export class InteractiveMode implements InteractiveModeContext {
301
307
  autoCompactionLoader: Loader | undefined = undefined;
302
308
  retryLoader: Loader | undefined = undefined;
303
309
  #pendingWorkingMessage: string | undefined;
310
+ #workingMessageAccentCacheKey?: WorkingMessageAccentCacheKey;
311
+ #workingMessageAccentCacheValue?: WorkingMessageAccent;
312
+ #workingMessageAccentCacheHasValue = false;
304
313
  get #defaultWorkingMessage(): string {
305
314
  return `Working…${interruptHint()}`;
306
315
  }
@@ -638,9 +647,17 @@ export class InteractiveMode implements InteractiveModeContext {
638
647
  this.session.subscribe(event => {
639
648
  void this.#handleGoalSessionEvent(event);
640
649
  }),
650
+ this.sessionManager.onSessionNameChanged(() => {
651
+ this.#handleSessionAccentInputsChanged();
652
+ }),
653
+ onStatusLineSessionAccentChanged(() => {
654
+ this.#syncStatusLineSettings();
655
+ this.#handleSessionAccentInputsChanged();
656
+ }),
641
657
  );
642
658
  // Set up theme file watcher
643
659
  onThemeChange(() => {
660
+ this.#clearWorkingMessageAccentCache();
644
661
  clearRenderCache();
645
662
  this.ui.invalidate();
646
663
  this.updateEditorBorderColor();
@@ -965,9 +982,7 @@ export class InteractiveMode implements InteractiveModeContext {
965
982
  this.#goalContinuationTurnInFlight = false;
966
983
  }
967
984
  if (this.loadingAnimation) {
968
- this.loadingAnimation.stop();
969
- this.loadingAnimation = undefined;
970
- this.statusContainer.clear();
985
+ this.#stopLoadingAnimation(true);
971
986
  }
972
987
  if (!submission.customType) {
973
988
  this.pendingImages = submission.images ? [...submission.images] : [];
@@ -1005,9 +1020,7 @@ export class InteractiveMode implements InteractiveModeContext {
1005
1020
  pendingSubmissionDispose?.();
1006
1021
  this.#pendingWorkingMessage = undefined;
1007
1022
  if (this.loadingAnimation) {
1008
- this.loadingAnimation.stop();
1009
- this.loadingAnimation = undefined;
1010
- this.statusContainer.clear();
1023
+ this.#stopLoadingAnimation(true);
1011
1024
  }
1012
1025
  }
1013
1026
  }
@@ -1023,6 +1036,24 @@ export class InteractiveMode implements InteractiveModeContext {
1023
1036
  this.editor.setMaxHeight(this.#computeEditorMaxHeight());
1024
1037
  }
1025
1038
 
1039
+ #syncStatusLineSettings(): void {
1040
+ this.statusLine.updateSettings({
1041
+ preset: settings.get("statusLine.preset"),
1042
+ leftSegments: settings.get("statusLine.leftSegments"),
1043
+ rightSegments: settings.get("statusLine.rightSegments"),
1044
+ separator: settings.get("statusLine.separator"),
1045
+ showHookStatus: settings.get("statusLine.showHookStatus"),
1046
+ sessionAccent: settings.get("statusLine.sessionAccent"),
1047
+ segmentOptions: settings.get("statusLine.segmentOptions"),
1048
+ });
1049
+ }
1050
+
1051
+ #handleSessionAccentInputsChanged(): void {
1052
+ this.#clearWorkingMessageAccentCache();
1053
+ this.statusLine.invalidate();
1054
+ this.updateEditorBorderColor();
1055
+ }
1056
+
1026
1057
  updateEditorBorderColor(): void {
1027
1058
  if (this.isBashMode) {
1028
1059
  this.editor.borderColor = theme.getBashModeBorderColor();
@@ -2416,8 +2447,7 @@ export class InteractiveMode implements InteractiveModeContext {
2416
2447
 
2417
2448
  stop(): void {
2418
2449
  if (this.loadingAnimation) {
2419
- this.loadingAnimation.stop();
2420
- this.loadingAnimation = undefined;
2450
+ this.#stopLoadingAnimation(false);
2421
2451
  }
2422
2452
  this.#cleanupMicAnimation();
2423
2453
  this.#cancelTodoAutoClearTimer();
@@ -2581,9 +2611,7 @@ export class InteractiveMode implements InteractiveModeContext {
2581
2611
  this.#pendingSubmissionDispose = undefined;
2582
2612
  this.#pendingWorkingMessage = undefined;
2583
2613
  if (this.loadingAnimation) {
2584
- this.loadingAnimation.stop();
2585
- this.loadingAnimation = undefined;
2586
- this.statusContainer.clear();
2614
+ this.#stopLoadingAnimation(true);
2587
2615
  }
2588
2616
  this.#uiHelpers.showError(message);
2589
2617
  }
@@ -2646,24 +2674,69 @@ export class InteractiveMode implements InteractiveModeContext {
2646
2674
  this.ui.requestRender();
2647
2675
  }
2648
2676
 
2677
+ #clearWorkingMessageAccentCache(): void {
2678
+ this.#workingMessageAccentCacheKey = undefined;
2679
+ this.#workingMessageAccentCacheValue = undefined;
2680
+ this.#workingMessageAccentCacheHasValue = false;
2681
+ }
2682
+
2683
+ #buildWorkingMessageAccentCacheKey(): WorkingMessageAccentCacheKey {
2684
+ const sessionAccentEnabled = !isSettingsInitialized() || settings.get("statusLine.sessionAccent") !== false;
2685
+ return {
2686
+ sessionAccentEnabled,
2687
+ sessionName: sessionAccentEnabled ? this.sessionManager.getSessionName() : undefined,
2688
+ accentSurfaceLuminance: theme.accentSurfaceLuminance,
2689
+ };
2690
+ }
2691
+
2692
+ #workingMessageAccentCacheKeyEquals(a: WorkingMessageAccentCacheKey, b: WorkingMessageAccentCacheKey): boolean {
2693
+ return (
2694
+ a.sessionName === b.sessionName &&
2695
+ a.accentSurfaceLuminance === b.accentSurfaceLuminance &&
2696
+ a.sessionAccentEnabled === b.sessionAccentEnabled
2697
+ );
2698
+ }
2699
+
2700
+ #cacheWorkingMessageAccent(
2701
+ key: WorkingMessageAccentCacheKey,
2702
+ value: WorkingMessageAccent | undefined,
2703
+ ): WorkingMessageAccent | undefined {
2704
+ this.#workingMessageAccentCacheKey = key;
2705
+ this.#workingMessageAccentCacheValue = value;
2706
+ this.#workingMessageAccentCacheHasValue = true;
2707
+ return value;
2708
+ }
2709
+
2649
2710
  #getWorkingMessageAccent(): WorkingMessageAccent | undefined {
2650
- const accentEnabled = !isSettingsInitialized() || settings.get("statusLine.sessionAccent") !== false;
2651
- const sessionName = accentEnabled ? this.sessionManager.getSessionName() : undefined;
2652
- if (!sessionName) return undefined;
2653
- const hex = getSessionAccentHex(sessionName, theme.accentSurfaceLuminance);
2711
+ const key = this.#buildWorkingMessageAccentCacheKey();
2712
+ if (
2713
+ this.#workingMessageAccentCacheHasValue &&
2714
+ this.#workingMessageAccentCacheKey &&
2715
+ this.#workingMessageAccentCacheKeyEquals(key, this.#workingMessageAccentCacheKey)
2716
+ ) {
2717
+ return this.#workingMessageAccentCacheValue;
2718
+ }
2719
+ if (!key.sessionAccentEnabled || !key.sessionName) {
2720
+ return this.#cacheWorkingMessageAccent(key, undefined);
2721
+ }
2722
+ const hex = getSessionAccentHex(key.sessionName, key.accentSurfaceLuminance);
2654
2723
  const main = getSessionAccentAnsi(hex);
2655
2724
  const dim = getSessionAccentAnsi(adjustHsv(hex, { s: 0.55, v: 0.65 }));
2656
- return main && dim ? { main, dim } : undefined;
2725
+ return this.#cacheWorkingMessageAccent(key, main && dim ? { main, dim } : undefined);
2657
2726
  }
2658
2727
 
2659
2728
  ensureLoadingAnimation(): void {
2660
2729
  if (!this.loadingAnimation) {
2730
+ this.#clearWorkingMessageAccentCache();
2661
2731
  this.statusContainer.clear();
2662
2732
  const messageColorFn = ((message: string) =>
2663
2733
  renderWorkingMessage(message, this.#getWorkingMessageAccent())) as LoaderMessageColorFn & {
2664
- animated: true;
2734
+ animated?: true;
2665
2735
  };
2666
- messageColorFn.animated = true;
2736
+ // Shimmer drives the 30fps redraw; when it is disabled the working
2737
+ // message is static, so leave `animated` unset and let the loader use
2738
+ // the spinner-only ~12.5fps cadence instead of repainting a frozen line.
2739
+ if (shimmerEnabled()) messageColorFn.animated = true;
2667
2740
  this.loadingAnimation = new Loader(
2668
2741
  this.ui,
2669
2742
  spinner => {
@@ -2680,6 +2753,16 @@ export class InteractiveMode implements InteractiveModeContext {
2680
2753
  this.applyPendingWorkingMessage();
2681
2754
  }
2682
2755
 
2756
+ #stopLoadingAnimation(clearStatusContainer: boolean): void {
2757
+ if (!this.loadingAnimation) return;
2758
+ this.loadingAnimation.stop();
2759
+ this.loadingAnimation = undefined;
2760
+ this.#clearWorkingMessageAccentCache();
2761
+ if (clearStatusContainer) {
2762
+ this.statusContainer.clear();
2763
+ }
2764
+ }
2765
+
2683
2766
  setWorkingMessage(message?: string): void {
2684
2767
  if (message === undefined) {
2685
2768
  this.#pendingWorkingMessage = undefined;
@@ -2707,6 +2790,10 @@ export class InteractiveMode implements InteractiveModeContext {
2707
2790
  this.setWorkingMessage(message);
2708
2791
  }
2709
2792
 
2793
+ notifyInterrupting(): void {
2794
+ this.#eventController.notifyInterrupting();
2795
+ }
2796
+
2710
2797
  showNewVersionNotification(newVersion: string): void {
2711
2798
  this.#uiHelpers.showNewVersionNotification(newVersion);
2712
2799
  }
@@ -4,7 +4,7 @@ import { highlightWorkflow } from "./workflow";
4
4
 
5
5
  /**
6
6
  * Gradient-highlight every magic keyword ("ultrathink", "orchestrate",
7
- * "workflow") that appears as standalone prose, skipping any occurrence inside a
7
+ * "workflowz") that appears as standalone prose, skipping any occurrence inside a
8
8
  * code block, inline code span, or XML/HTML section. Each highlighter paints its
9
9
  * own keyword with its own gradient, so chaining is order-independent — the
10
10
  * earlier passes only inject zero-width SGR escapes (no backticks or angle
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Markdown structure awareness for the magic-keyword affordances
3
- * ("ultrathink"/"orchestrate"/"workflow").
3
+ * ("ultrathink"/"orchestrate"/"workflowz").
4
4
  *
5
5
  * Keyword detection and editor/transcript highlighting must fire only on prose
6
6
  * the user is actually addressing to the model — never on a word that happens to
@@ -1,14 +1,20 @@
1
1
  import { isSettingsInitialized, settings } from "../../config/settings";
2
2
  import type { Theme, ThemeColor } from "./theme";
3
3
 
4
+ // ─── Animation velocity ──────────────────────────────────────────────────────
5
+ // Band/head travel speed in border cells per second. Driving position by a fixed
6
+ // velocity — instead of dividing a fixed sweep duration by the (length-derived)
7
+ // period — makes smoothness independent of message length: at the loader's
8
+ // default 30fps redraw cadence the band advances ≤1 cell per frame for any
9
+ // string, so it never visibly steps. Sweep/round-trip durations now scale with
10
+ // length. Keep ≤ the animated redraw fps (loader RENDER_INTERVAL_MS = 1000/30).
11
+ const SHIMMER_SPEED_CELLS_PER_S = 30;
12
+
4
13
  // ─── Classic sweep tunables ──────────────────────────────────────────────────
5
14
  const CLASSIC_PADDING = 10;
6
- const CLASSIC_SWEEP_MS = 1400;
7
15
  const CLASSIC_BAND_HALF_WIDTH = 6;
8
16
 
9
17
  // ─── KITT scanner tunables ───────────────────────────────────────────────────
10
- // 1.5s round trip ≈ classic 1982 K.I.T.T. scanner cadence (~0.75s per direction).
11
- const KITT_CYCLE_MS = 1500;
12
18
  const KITT_HEAD_HALF = 0.6;
13
19
  const KITT_TRAIL_LEN = 7;
14
20
 
@@ -103,9 +109,10 @@ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette
103
109
  /** Smooth cosine bump sweeping left → right with edge padding. */
104
110
  function classicIntensity(time: number, index: number, length: number): number {
105
111
  const period = length + CLASSIC_PADDING * 2;
106
- // Fractional position — kept un-floored so the band glides at the host's
107
- // frame rate instead of stepping discretely.
108
- const pos = ((time % CLASSIC_SWEEP_MS) / CLASSIC_SWEEP_MS) * period;
112
+ // Fixed-velocity, un-floored band position: advancing at a constant
113
+ // cells/second (not period / fixed-sweep) keeps the per-frame step ≤1 cell at
114
+ // the default cadence for any length, so long messages are no steppier.
115
+ const pos = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % period;
109
116
  const dist = Math.abs(index + CLASSIC_PADDING - pos);
110
117
  if (dist >= CLASSIC_BAND_HALF_WIDTH) return 0;
111
118
  return 0.5 * (1 + Math.cos((Math.PI * dist) / CLASSIC_BAND_HALF_WIDTH));
@@ -119,9 +126,13 @@ function classicIntensity(time: number, index: number, length: number): number {
119
126
  function kittIntensity(time: number, index: number, length: number): number {
120
127
  const range = length - 1;
121
128
  if (range <= 0) return 1;
122
- const phase = (time % KITT_CYCLE_MS) / KITT_CYCLE_MS;
123
- const goingRight = phase < 0.5;
124
- const head = goingRight ? phase * 2 * range : (1 - phase) * 2 * range;
129
+ // Fixed head velocity: a triangle ping-pong over a 2*range round trip at a
130
+ // constant cells/second, so the bright head advances ≤1 cell per frame at the
131
+ // default cadence regardless of bar length. Round-trip duration scales with length.
132
+ const cycleCells = 2 * range;
133
+ const sweep = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % cycleCells;
134
+ const goingRight = sweep < range;
135
+ const head = goingRight ? sweep : cycleCells - sweep;
125
136
  const delta = index - head;
126
137
  const abs = delta < 0 ? -delta : delta;
127
138
  if (abs <= KITT_HEAD_HALF) return 1;
@@ -182,6 +182,9 @@ export interface InteractiveModeContext {
182
182
  flushPendingModelSwitch(): Promise<void>;
183
183
  setWorkingMessage(message?: string): void;
184
184
  applyPendingWorkingMessage(): void;
185
+ /** Acknowledge a user interrupt (Esc) by switching the loader to an
186
+ * "Interrupting…" label until the agent turn unwinds. */
187
+ notifyInterrupting(): void;
185
188
  ensureLoadingAnimation(): void;
186
189
  startPendingSubmission(input: {
187
190
  text: string;
@@ -3,25 +3,25 @@ import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-h
3
3
  import { keywordInProse } from "./markdown-prose";
4
4
 
5
5
  /**
6
- * "workflow" keyword support.
6
+ * "workflowz" keyword support.
7
7
  *
8
8
  * Typing the standalone word in the input editor paints it with a warm
9
9
  * amber→green gradient ({@link highlightWorkflow}); submitting a message that
10
10
  * mentions it appends a hidden {@link WORKFLOW_NOTICE} that steers the model to
11
11
  * author a deterministic multi-subagent workflow in eval cells (agent/parallel/
12
12
  * pipeline). Matching is whitespace-delimited and case-sensitive (lowercase
13
- * only) — "workflow"/"workflows" trigger, but "workflowed", "Workflow", and
14
- * "workflow.ts" never do.
13
+ * only) — "workflowz" triggers, but "workflowzed", "Workflowz", and
14
+ * "workflowz.ts" never do.
15
15
  */
16
16
 
17
- // Detection: lowercase keyword (singular or plural) flanked by whitespace or a string edge. Non-global so `.test` stays stateless.
18
- const WORKFLOW_WORD = /(?<!\S)workflows?(?!\S)/;
17
+ // Detection: lowercase keyword flanked by whitespace or a string edge. Non-global so `.test` stays stateless.
18
+ const WORKFLOW_WORD = /(?<!\S)workflowz(?!\S)/;
19
19
 
20
- /** Hidden system notice appended after a user message that mentions "workflow". */
20
+ /** Hidden system notice appended after a user message that mentions "workflowz". */
21
21
  export const WORKFLOW_NOTICE: string = workflowNotice.trim();
22
22
 
23
23
  /**
24
- * Whether `text` contains the standalone keyword "workflow"/"workflows"
24
+ * Whether `text` contains the standalone keyword "workflowz"
25
25
  * (lowercase, whitespace-delimited) in prose — never inside a code block, inline
26
26
  * code span, or XML/HTML section.
27
27
  */
@@ -30,13 +30,13 @@ export function containsWorkflow(text: string): boolean {
30
30
  }
31
31
 
32
32
  /**
33
- * Highlight every standalone "workflow"/"workflows" in `text` for editor display
33
+ * Highlight every standalone "workflowz" in `text` for editor display
34
34
  * with a warm amber→green gradient (hue 30..150), visually distinct from
35
35
  * ultrathink's rainbow and orchestrate's teal→violet.
36
36
  */
37
37
  export const highlightWorkflow: KeywordHighlighter = createGradientHighlighter({
38
- probe: /workflow/,
39
- highlight: /(?<!\S)workflows?(?!\S)/g,
38
+ probe: /workflowz/,
39
+ highlight: /(?<!\S)workflowz(?!\S)/g,
40
40
  stops: 14,
41
41
  hue: t => 30 + t * 120,
42
42
  });
@@ -1,5 +1,5 @@
1
1
  <system-notice>
2
- The user's message above contains the **workflow** keyword: drive this task as a deterministic multi-subagent workflow. Author the orchestration as Python in the `eval` tool and fan out subagents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough.
2
+ The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Author the orchestration as Python in the `eval` tool and fan out subagents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough.
3
3
 
4
4
  <when>
5
5
  Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline FIRST (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes, each a well-scoped `eval` call you can chain across turns:
@@ -31,6 +31,15 @@ Executes bash command in shell session for terminal operations like git, bun, ca
31
31
  - `async: true` only defers **reporting** of the result — it does NOT disable, extend, or detach the timeout. A daemon started with `async: true` is still killed when `timeout` elapses, regardless of how long the agent waits before reading the result.
32
32
  - For long-running daemons (dev servers, watchers): either pass an explicit large `timeout` (up to `3600`), or fully detach the process from this shell using `nohup … &` / `setsid … &` / `disown` so it survives independent of the bash call's lifecycle.
33
33
  {{/if}}
34
+ {{#if autoBackgroundEnabled}}
35
+
36
+ ## Auto-background
37
+
38
+ - A foreground (non-`async`) call that has not completed within **{{autoBackgroundThresholdSeconds}}s** is automatically converted into a background job and returns a `Background job <id> started: …` notice with the buffered output so far. The command keeps running; the final result is delivered as a follow-up tool call when it completes.
39
+ - This is NOT a failure or a re-queue. Treat the notice as "still running, will report back" — do not retry the same command, and do not wait synchronously for it.
40
+ - Auto-backgrounding does NOT extend `timeout`: the job is still killed at the original deadline.
41
+ - If you need the result inline (e.g. piping into another command), raise `timeout` above the expected duration so it finishes before the threshold matters{{#if asyncEnabled}}, or set `async: true` up front so the contract is explicit{{/if}}.
42
+ {{/if}}
34
43
 
35
44
  # Output minimizer
36
45
 
@@ -26,7 +26,7 @@ Drives real Chromium tab; full puppeteer access via JS execution.
26
26
  - `tab.waitForResponse(pattern, { timeout? })` — pattern substring, `RegExp`, or `(response) => boolean`. Returns raw puppeteer `HTTPResponse` (call `.text()` / `.json()` / `.status()` / `.headers()` on it).
27
27
  - `tab.evaluate(fn, …args)` — sugar for `page.evaluate` with abort signal already wired. Use this instead of dropping to `page.evaluate` for ad-hoc DOM reads.
28
28
  - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — captures screenshot and **auto-attaches to tool output for you to view** (unless `silent: true`). `save` is **strictly optional**: OMIT when you just want to look at page — downscaled image shown regardless, full-res capture written to temp file automatically. Pass `save` (a path) ONLY when deliberately need to keep full-res copy on disk for later use; `browser.screenshotDir` does same for every shot. NEVER invent `save` path for throwaway/temporal screenshot.
29
- - `tab.extract(format = "markdown")` — Readability-extracted page content.
29
+ - `tab.extract(format = "markdown")` — returns Readability-extracted page content as a string (`"markdown"` or `"text"`). Throws if the page yields no readable content.
30
30
  - Selectors accept CSS plus puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. Playwright-style `p-aria/[name="…"]`, `p-text/…` normalized.
31
31
  - Default `tab.observe()` over `tab.screenshot()` for page state. Screenshot only when visual appearance matters.
32
32
  </instruction>
@@ -46,8 +46,9 @@ tool.<name>(args) → unknown
46
46
  Invoke any session tool by name. `args` is the tool's parameter object.
47
47
  llm(prompt, model?="default", system?=None, schema?=None) → str | dict
48
48
  Oneshot, stateless LLM call (no history, no tools). `model` picks a tier: "smol" (fast), "default" (this session's model), "slow" (most capable). Pass `system` for a system prompt. Pass a JSON-Schema `schema` to force structured output and get the parsed object back; otherwise returns the completion text.
49
- agent(prompt, agent_type?="task", model?=None, context?=None, label?=None, schema?=None) → str | dict
49
+ {{#if spawns}}agent(prompt, agent_type?="task", model?=None, context?=None, label?=None, schema?=None) → str | dict
50
50
  Run a subagent and return its final output. Defaults to the bundled "task" agent; pass `agent_type`/`agentType` for another discovered agent. Pass a JSON-Schema `schema` to force structured output and get the parsed object back.
51
+ {{/if}}
51
52
  parallel(thunks) → list
52
53
  Run thunks (callables) through a bounded pool, preserving input order. The pool is as wide as a `task` tool batch (tracks the `task.maxConcurrency` setting), so fan out as wide as the work divides — don't pre-shrink it. Barrier: returns once all finish; a thunk that throws propagates.
53
54
  pipeline(items, ...stages) → list
@@ -18,8 +18,8 @@ Append `:<sel>` to `path`. The bare path falls back to the default mode.
18
18
  - `:50` / `:50-` — read from line 50 onward.
19
19
  - `:50-200` — lines 50–200 inclusive.
20
20
  - `:50+150` — 150 lines starting at line 50.
21
- - `:20+1` — exactly one line.
22
- - `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged).
21
+ - `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines).
22
+ - `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact bounds with no context padding.
23
23
  - `:raw` — verbatim text; no anchors, no summary, no line prefixes.
24
24
  - `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.
25
25
  - `:conflicts` — one-line-per-block index of every unresolved git merge conflict.
package/src/sdk.ts CHANGED
@@ -41,7 +41,9 @@ import { createApiKeyResolver } from "./config/api-key-resolver";
41
41
  import { shouldEnableAppendOnlyContext } from "./config/append-only-context-mode";
42
42
  import { ModelRegistry } from "./config/model-registry";
43
43
  import {
44
+ defaultModelPerProvider,
44
45
  formatModelString,
46
+ getModelMatchPreferences,
45
47
  parseModelPattern,
46
48
  parseModelString,
47
49
  resolveAllowedModels,
@@ -709,6 +711,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
709
711
  parameters: tool.parameters,
710
712
  hidden: tool.hidden,
711
713
  deferrable: tool.deferrable,
714
+ approval: typeof tool.approval === "function" ? tool.approval.bind(tool) : tool.approval,
712
715
  mcpServerName: tool.mcpServerName,
713
716
  mcpToolName: tool.mcpToolName,
714
717
  execute: (toolCallId, params, signal, onUpdate, ctx) =>
@@ -1030,9 +1033,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1030
1033
  const hasServiceTierEntry = existingBranch.some(entry => entry.type === "service_tier_change");
1031
1034
 
1032
1035
  const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
1033
- const modelMatchPreferences = {
1034
- usageOrder: settings.getStorage()?.getModelUsageOrder(),
1035
- };
1036
+ const modelMatchPreferences = getModelMatchPreferences(settings);
1036
1037
  const allowedModels = await logger.time("resolveAllowedModels", () =>
1037
1038
  resolveAllowedModels(modelRegistry, settings, modelMatchPreferences),
1038
1039
  );
@@ -1553,9 +1554,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1553
1554
  // Resolve deferred --model pattern now that extension models are registered.
1554
1555
  if (!model && options.modelPattern) {
1555
1556
  const availableModels = modelRegistry.getAll();
1556
- const matchPreferences = {
1557
- usageOrder: settings.getStorage()?.getModelUsageOrder(),
1558
- };
1557
+ const matchPreferences = getModelMatchPreferences(settings);
1559
1558
  const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences, {
1560
1559
  modelRegistry,
1561
1560
  });
@@ -1574,12 +1573,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1574
1573
  // Re-resolve the allowed set: extension factories above may have
1575
1574
  // registered providers/models that weren't visible at startup.
1576
1575
  const fallbackCandidates = await resolveAllowedModels(modelRegistry, settings, modelMatchPreferences);
1577
- for (const candidate of fallbackCandidates) {
1578
- if (await hasModelApiKey(candidate)) {
1579
- model = candidate;
1576
+ // Prefer each provider's configured default model
1577
+ // (DEFAULT_MODEL_PER_PROVIDER) over raw catalog order. Without this the
1578
+ // first-run fallback picks whatever model sorts first in models.json for
1579
+ // the winning provider (e.g. anthropic's claude-3-5-sonnet-20240620)
1580
+ // instead of the intended provider default (claude-sonnet-4-6). Mirrors
1581
+ // findInitialModel's precedence.
1582
+ for (const [provider, defaultId] of Object.entries(defaultModelPerProvider)) {
1583
+ const preferred = fallbackCandidates.find(
1584
+ candidate => candidate.provider === provider && candidate.id === defaultId,
1585
+ );
1586
+ if (preferred && (await hasModelApiKey(preferred))) {
1587
+ model = preferred;
1580
1588
  break;
1581
1589
  }
1582
1590
  }
1591
+ // Otherwise, first available model with a valid API key.
1592
+ if (!model) {
1593
+ for (const candidate of fallbackCandidates) {
1594
+ if (await hasModelApiKey(candidate)) {
1595
+ model = candidate;
1596
+ break;
1597
+ }
1598
+ }
1599
+ }
1583
1600
  if (model) {
1584
1601
  if (modelFallbackMessage) {
1585
1602
  modelFallbackMessage += `. Using ${model.provider}/${model.id}`;