@oh-my-pi/pi-coding-agent 16.4.2 → 16.4.3

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 (122) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cli.js +17514 -17799
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/commit/agentic/agent.d.ts +1 -0
  5. package/dist/types/config/settings-schema.d.ts +28 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
  7. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  8. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  9. package/dist/types/modes/components/status-line/component.d.ts +3 -0
  10. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  11. package/dist/types/modes/interactive-mode.d.ts +8 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/session/agent-session.d.ts +16 -0
  14. package/dist/types/task/executor.d.ts +31 -0
  15. package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  17. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  18. package/dist/types/tools/browser/run-output.d.ts +25 -0
  19. package/dist/types/tools/browser/tab-worker.d.ts +16 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/vibe.d.ts +161 -0
  22. package/dist/types/utils/changelog.d.ts +35 -1
  23. package/dist/types/vibe/runtime.d.ts +124 -0
  24. package/dist/types/vibe/state.d.ts +4 -0
  25. package/dist/types/web/search/provider.d.ts +2 -0
  26. package/dist/types/web/search/providers/bing.d.ts +14 -0
  27. package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
  28. package/dist/types/web/search/providers/browser-page.d.ts +32 -0
  29. package/dist/types/web/search/providers/ecosia.d.ts +14 -0
  30. package/dist/types/web/search/providers/google.d.ts +13 -0
  31. package/dist/types/web/search/providers/mojeek.d.ts +14 -0
  32. package/dist/types/web/search/providers/public.d.ts +37 -0
  33. package/dist/types/web/search/providers/startpage.d.ts +14 -0
  34. package/dist/types/web/search/providers/yahoo.d.ts +14 -0
  35. package/dist/types/web/search/types.d.ts +28 -0
  36. package/package.json +15 -18
  37. package/scripts/build-binary.ts +56 -73
  38. package/scripts/bundle-dist.ts +36 -40
  39. package/scripts/compile-binary.ts +68 -0
  40. package/scripts/generate-docs-index.ts +3 -93
  41. package/scripts/legacy-pi-virtual-module.ts +192 -0
  42. package/src/advisor/__tests__/advisor.test.ts +13 -0
  43. package/src/advisor/__tests__/config.test.ts +36 -0
  44. package/src/advisor/config.ts +11 -8
  45. package/src/commit/agentic/agent.ts +4 -0
  46. package/src/commit/agentic/index.ts +46 -21
  47. package/src/edit/index.ts +10 -76
  48. package/src/exec/non-interactive-env.ts +0 -1
  49. package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
  50. package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
  51. package/src/internal-urls/docs-index.ts +2 -1
  52. package/src/internal-urls/skill-protocol.ts +1 -1
  53. package/src/lsp/deferred-diagnostics.ts +66 -0
  54. package/src/main.ts +13 -13
  55. package/src/mcp/transports/stdio.test.ts +45 -1
  56. package/src/mcp/transports/stdio.ts +6 -3
  57. package/src/modes/acp/acp-agent.ts +65 -1
  58. package/src/modes/acp/acp-event-mapper.ts +5 -0
  59. package/src/modes/acp/acp-mode.ts +11 -0
  60. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  61. package/src/modes/components/advisor-config.ts +15 -7
  62. package/src/modes/components/move-overlay.ts +2 -3
  63. package/src/modes/components/status-line/component.ts +6 -0
  64. package/src/modes/components/status-line/segments.ts +6 -0
  65. package/src/modes/components/status-line/types.ts +3 -0
  66. package/src/modes/controllers/command-controller.ts +8 -10
  67. package/src/modes/interactive-mode.ts +117 -1
  68. package/src/modes/types.ts +2 -0
  69. package/src/prompts/system/eager-task.md +2 -2
  70. package/src/prompts/system/system-prompt.md +9 -2
  71. package/src/prompts/system/vibe-mode-active.md +23 -0
  72. package/src/prompts/tools/browser.md +3 -3
  73. package/src/prompts/tools/grep.md +1 -1
  74. package/src/prompts/tools/vibe-kill.md +3 -0
  75. package/src/prompts/tools/vibe-list.md +3 -0
  76. package/src/prompts/tools/vibe-send.md +9 -0
  77. package/src/prompts/tools/vibe-spawn.md +10 -0
  78. package/src/prompts/tools/vibe-turn-result.md +19 -0
  79. package/src/prompts/tools/vibe-wait.md +8 -0
  80. package/src/sdk.ts +5 -0
  81. package/src/session/agent-session.ts +103 -13
  82. package/src/session/snapcompact-inline.ts +3 -19
  83. package/src/slash-commands/builtin-registry.ts +24 -8
  84. package/src/task/agents.ts +0 -2
  85. package/src/task/executor.ts +105 -0
  86. package/src/tools/__tests__/vibe-render.test.ts +210 -0
  87. package/src/tools/bash-skill-urls.ts +1 -1
  88. package/src/tools/browser/cmux/cmux-tab.ts +46 -48
  89. package/src/tools/browser/cmux/rpc.ts +50 -0
  90. package/src/tools/browser/run-output.ts +76 -0
  91. package/src/tools/browser/tab-worker.ts +264 -129
  92. package/src/tools/glob.ts +20 -6
  93. package/src/tools/index.ts +1 -0
  94. package/src/tools/read.ts +17 -9
  95. package/src/tools/renderers.ts +6 -0
  96. package/src/tools/vibe.ts +608 -0
  97. package/src/tools/write.ts +15 -2
  98. package/src/utils/changelog.ts +106 -9
  99. package/src/utils/git.ts +0 -1
  100. package/src/utils/title-generator.ts +70 -7
  101. package/src/vibe/runtime.ts +710 -0
  102. package/src/vibe/state.ts +4 -0
  103. package/src/web/search/index.ts +14 -6
  104. package/src/web/search/provider.ts +37 -1
  105. package/src/web/search/providers/bing.ts +197 -0
  106. package/src/web/search/providers/browser-headers.ts +92 -0
  107. package/src/web/search/providers/browser-page.ts +123 -0
  108. package/src/web/search/providers/duckduckgo.ts +13 -34
  109. package/src/web/search/providers/ecosia.ts +178 -0
  110. package/src/web/search/providers/google.ts +193 -0
  111. package/src/web/search/providers/mojeek.ts +206 -0
  112. package/src/web/search/providers/public.ts +201 -0
  113. package/src/web/search/providers/startpage.ts +213 -0
  114. package/src/web/search/providers/yahoo.ts +179 -0
  115. package/src/web/search/types.ts +35 -0
  116. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
  117. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
  118. package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
  119. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
  120. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
  121. package/src/internal-urls/docs-index.generated.txt +0 -2
  122. package/src/prompts/agents/plan.md +0 -47
@@ -92,7 +92,7 @@ import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" wit
92
92
  import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
93
93
  type: "text",
94
94
  };
95
- import type { AgentRegistry } from "../registry/agent-registry";
95
+ import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
96
96
  import {
97
97
  type AgentSession,
98
98
  type AgentSessionEvent,
@@ -125,6 +125,7 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor";
125
125
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
126
126
  import { messageHasDisplayableThinking } from "../utils/thinking-display";
127
127
  import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
128
+ import { VibeSessionRegistry } from "../vibe/runtime";
128
129
  import type { AssistantMessageComponent } from "./components/assistant-message";
129
130
  import type { BashExecutionComponent } from "./components/bash-execution";
130
131
  import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
@@ -434,6 +435,7 @@ export class InteractiveMode implements InteractiveModeContext {
434
435
  planModePaused = false;
435
436
  goalModeEnabled = false;
436
437
  goalModePaused = false;
438
+ vibeModeEnabled = false;
437
439
  planModePlanFilePath: string | undefined = undefined;
438
440
  loopModeEnabled = false;
439
441
  loopPrompt: string | undefined = undefined;
@@ -528,6 +530,7 @@ export class InteractiveMode implements InteractiveModeContext {
528
530
  readonly #changelogMarkdown: string | undefined;
529
531
  #planModePreviousTools: string[] | undefined;
530
532
  #goalModePreviousTools: string[] | undefined;
533
+ #vibeModePreviousTools: string[] | undefined;
531
534
  #goalContinuationTimer: NodeJS.Timeout | undefined;
532
535
  #goalTurnHadToolCalls = false;
533
536
  #goalContinuationTurnInFlight = false;
@@ -1943,6 +1946,11 @@ export class InteractiveMode implements InteractiveModeContext {
1943
1946
  this.ui.requestRender();
1944
1947
  }
1945
1948
 
1949
+ #updateVibeModeStatus(): void {
1950
+ this.statusLine.setVibeModeStatus(this.vibeModeEnabled ? { enabled: true } : undefined);
1951
+ this.ui.requestRender();
1952
+ }
1953
+
1946
1954
  #updateGoalModeStatus(): void {
1947
1955
  const status =
1948
1956
  this.goalModeEnabled || this.goalModePaused
@@ -2111,6 +2119,18 @@ export class InteractiveMode implements InteractiveModeContext {
2111
2119
  this.#cancelGoalContinuation();
2112
2120
  this.#updateGoalModeStatus();
2113
2121
  }
2122
+
2123
+ if (this.vibeModeEnabled) {
2124
+ await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
2125
+ this.session.setVibeModeState(undefined);
2126
+ this.vibeModeEnabled = false;
2127
+ this.#vibeModePreviousTools = undefined;
2128
+ await VibeSessionRegistry.global().killAll(
2129
+ this.session.getAgentId() ?? MAIN_AGENT_ID,
2130
+ this.session.asyncJobManager,
2131
+ );
2132
+ this.#updateVibeModeStatus();
2133
+ }
2114
2134
  }
2115
2135
 
2116
2136
  /** Reconcile mode state from session entries on resume/switch. */
@@ -2150,6 +2170,10 @@ export class InteractiveMode implements InteractiveModeContext {
2150
2170
  return;
2151
2171
  }
2152
2172
  this.session.goalRuntime.clearAccounting();
2173
+ if (sessionContext.mode === "vibe") {
2174
+ await this.#enterVibeMode();
2175
+ return;
2176
+ }
2153
2177
  if (!this.session.settings.get("plan.enabled")) {
2154
2178
  // Clear stale plan/plan_paused mode so re-enabling the setting
2155
2179
  // later doesn't unexpectedly restore an old plan session.
@@ -2176,6 +2200,10 @@ export class InteractiveMode implements InteractiveModeContext {
2176
2200
  this.showWarning("Exit goal mode first.");
2177
2201
  return;
2178
2202
  }
2203
+ if (this.vibeModeEnabled) {
2204
+ this.showWarning("Exit vibe mode first.");
2205
+ return;
2206
+ }
2179
2207
 
2180
2208
  this.planModePaused = false;
2181
2209
 
@@ -2343,6 +2371,10 @@ export class InteractiveMode implements InteractiveModeContext {
2343
2371
  this.showWarning("Exit plan mode first.");
2344
2372
  return;
2345
2373
  }
2374
+ if (this.vibeModeEnabled) {
2375
+ this.showWarning("Exit vibe mode first.");
2376
+ return;
2377
+ }
2346
2378
  const previousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
2347
2379
  const goalTools = [...new Set([...previousTools, "goal"])];
2348
2380
  this.#goalModePreviousTools = previousTools;
@@ -2847,6 +2879,10 @@ export class InteractiveMode implements InteractiveModeContext {
2847
2879
  this.showWarning("Exit goal mode first.");
2848
2880
  return;
2849
2881
  }
2882
+ if (this.vibeModeEnabled) {
2883
+ this.showWarning("Exit vibe mode first.");
2884
+ return;
2885
+ }
2850
2886
  if (this.planModeEnabled) {
2851
2887
  const planFilePath = this.planModePlanFilePath ?? (await this.#getPlanFilePath());
2852
2888
  if (await this.#hasPlanModeDraftContent(planFilePath)) {
@@ -2882,6 +2918,82 @@ export class InteractiveMode implements InteractiveModeContext {
2882
2918
  }
2883
2919
  }
2884
2920
 
2921
+ /**
2922
+ * `/vibe` toggle. Entering installs the ephemeral vibe tools, strips the
2923
+ * active toolset down to `read` plus those tools, and injects the director
2924
+ * context. Exiting unregisters them, restores the previous toolset, and kills
2925
+ * every worker session so workers cannot outlive the mode that directs them.
2926
+ */
2927
+ async handleVibeModeCommand(initialPrompt?: string): Promise<void> {
2928
+ if (this.vibeModeEnabled) {
2929
+ await this.#exitVibeMode();
2930
+ return;
2931
+ }
2932
+ if (this.planModeEnabled || this.planModePaused) {
2933
+ this.showWarning("Exit plan mode first.");
2934
+ return;
2935
+ }
2936
+ if (this.goalModeEnabled || this.goalModePaused) {
2937
+ this.showWarning("Exit goal mode first.");
2938
+ return;
2939
+ }
2940
+ await this.#enterVibeMode();
2941
+ if (initialPrompt && this.onInputCallback) {
2942
+ this.onInputCallback(this.startPendingSubmission({ text: initialPrompt }));
2943
+ }
2944
+ }
2945
+
2946
+ async #enterVibeMode(): Promise<void> {
2947
+ if (this.vibeModeEnabled) {
2948
+ return;
2949
+ }
2950
+ if (this.planModeEnabled || this.planModePaused) {
2951
+ this.showWarning("Exit plan mode first.");
2952
+ return;
2953
+ }
2954
+ if (this.goalModeEnabled || this.goalModePaused) {
2955
+ this.showWarning("Exit goal mode first.");
2956
+ return;
2957
+ }
2958
+
2959
+ const previousTools = this.session.getActiveToolNames();
2960
+ await this.session.activateVibeTools(["read"]);
2961
+ this.#vibeModePreviousTools = previousTools;
2962
+ this.vibeModeEnabled = true;
2963
+ // Suppress cache-miss marker on the next turn: vibe mode changes the
2964
+ // injected context, which predictably invalidates the cache.
2965
+ this.lastAssistantUsage = undefined;
2966
+ this.session.setVibeModeState({ enabled: true });
2967
+ if (this.session.isStreaming) {
2968
+ await this.session.sendVibeModeContext({ deliverAs: "steer" });
2969
+ }
2970
+ this.#updateVibeModeStatus();
2971
+ this.sessionManager.appendModeChange("vibe");
2972
+ this.showStatus("Vibe mode enabled. You direct fast/good worker sessions; toolset is read + vibe tools.");
2973
+ }
2974
+
2975
+ async #exitVibeMode(): Promise<void> {
2976
+ if (!this.vibeModeEnabled) {
2977
+ return;
2978
+ }
2979
+ await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
2980
+ this.session.setVibeModeState(undefined);
2981
+ this.vibeModeEnabled = false;
2982
+ this.#vibeModePreviousTools = undefined;
2983
+ this.lastAssistantUsage = undefined;
2984
+ const killed = await VibeSessionRegistry.global().killAll(
2985
+ this.session.getAgentId() ?? MAIN_AGENT_ID,
2986
+ this.session.asyncJobManager,
2987
+ );
2988
+ this.#updateVibeModeStatus();
2989
+ this.sessionManager.appendModeChange("none");
2990
+ this.showStatus(
2991
+ killed > 0
2992
+ ? `Vibe mode disabled. Killed ${killed} worker session${killed === 1 ? "" : "s"}.`
2993
+ : "Vibe mode disabled.",
2994
+ );
2995
+ }
2996
+
2885
2997
  async #handleGoalBudgetCommand(rawBudget: string): Promise<void> {
2886
2998
  const state = this.session.getGoalModeState();
2887
2999
  if (!this.goalModeEnabled || !state?.enabled) {
@@ -2914,6 +3026,10 @@ export class InteractiveMode implements InteractiveModeContext {
2914
3026
  this.showWarning("Exit plan mode first.");
2915
3027
  return;
2916
3028
  }
3029
+ if (this.vibeModeEnabled) {
3030
+ this.showWarning("Exit vibe mode first.");
3031
+ return;
3032
+ }
2917
3033
  if (!this.session.settings.get("goal.enabled")) {
2918
3034
  this.showWarning("Goal mode is disabled. Enable it in settings (goal.enabled).");
2919
3035
  return;
@@ -153,6 +153,7 @@ export interface InteractiveModeContext {
153
153
  toolOutputExpanded: boolean;
154
154
  todoExpanded: boolean;
155
155
  planModeEnabled: boolean;
156
+ vibeModeEnabled: boolean;
156
157
  goalModeEnabled: boolean;
157
158
  goalModePaused: boolean;
158
159
  loopModeEnabled: boolean;
@@ -394,6 +395,7 @@ export interface InteractiveModeContext {
394
395
  openExternalEditor(): void;
395
396
  registerExtensionShortcuts(): void;
396
397
  handlePlanModeCommand(initialPrompt?: string): Promise<void>;
398
+ handleVibeModeCommand(initialPrompt?: string): Promise<void>;
397
399
  handleGoalModeCommand(rest?: string): Promise<void>;
398
400
  handleGuidedGoalCommand(rest?: string): Promise<void>;
399
401
  handleLoopCommand(args?: string): Promise<string | undefined>;
@@ -1,7 +1,7 @@
1
1
  <system-reminder>
2
2
  Task delegation is enabled — subagents are the default for this request.
3
3
 
4
- Explore and settle the approach FIRST. Once the design is settled, you MUST fan the work out to `{{toolRefs.task}}` subagents instead of implementing it yourself.{{#if taskBatch}} Batch independent slices into ONE parallel `{{toolRefs.task}}` call; never serialize work that can run concurrently.{{/if}}
4
+ Explore and settle the approach FIRST — scoping, top-level decomposition, and cross-slice contracts are YOUR job; NEVER spawn a subagent to produce the overall plan (per-slice design travels with its executor). Once the design is settled, you MUST fan the work out to `{{toolRefs.task}}` subagents instead of implementing it yourself.{{#if taskBatch}} Batch independent slices into ONE parallel `{{toolRefs.task}}` call; never serialize work that can run concurrently.{{/if}}
5
5
 
6
- Work alone only for: a single-file edit under ~30 lines, a direct answer requiring no code changes, or a command the user explicitly asked you to run.
6
+ Work alone for: a single-file edit under ~30 lines, a direct answer requiring no code changes, a command the user explicitly asked you to run, or when only ONE runnable slice exists — a lone subagent is a lossy handoff, not parallelism.
7
7
  </system-reminder>
@@ -163,12 +163,19 @@ Everything else—multi-file changes, refactors, new features, tests, investigat
163
163
  - Use `{{toolRefs.task}}` to map unknown code instead of reading file after file yourself.
164
164
  - NEVER abandon phases under scope pressure—delegate, don't shrink.
165
165
  - Default to parallel for complex changes. Delegate via `{{toolRefs.task}}` for non-importing file edits, multi-subsystem investigation, and decomposable work.
166
- - **Maximize parallelism:** Break work into the widest possible {{#if taskBatch}}array of `tasks[]`{{else}}set of parallel `task` calls{{/if}}. NEVER serialize work that can run concurrently. Tasks touching different files or independent refactors should run in parallel; agents resolve their own file collisions live.
166
+ {{/if}}
167
+
168
+ ## Delegation gates:
169
+ - **Scope before you spawn.** YOU read the request, map the work, and name the independent slices. Delegation is NEVER the first move on a fresh request — unless the user already enumerated 2+ self-contained runnable slices, in which case dispatch them immediately in one batch.
170
+ - **NEVER outsource the top-level plan.** Scoping the request, the overall decomposition, and cross-slice contracts (formats, schemas, interfaces) are YOUR job. A generic "plan"/"design" subagent as step one starts blank, knows less than you, runs alone, and adds a full round-trip for ZERO parallelism — the canonical dumb spawn. Delegating design WITHIN a slice is fine: each executor details its own slice, and once the top-level split is settled you MAY fan out per-subsystem sub-planning in parallel. (Competing plans or independent reviews the user explicitly asked for are also legitimate.)
171
+ - **Spawn-one-then-wait is a bug.** A lone subagent you sit idle behind is you doing the work with extra latency plus a lossy handoff — do it inline. A single spawn is fine ONLY when you immediately continue another independent slice yourself, or it is a read-only scout keeping bulk exploration out of your context.
172
+ - **Width = real independence.** Fan out exactly as wide as the work genuinely decomposes{{#if taskBatch}}, batched into one `tasks[]` array{{else}}, as parallel calls in one message{{/if}}. NEVER serialize slices that can run concurrently; NEVER pad the batch with invented slices to look parallel.
173
+ - **Prerequisites run inline.** A step every slice depends on (shared schema, core interface, scaffold) has by definition nothing to run beside it — do it yourself, then fan out. "Parallelize" means parallel EXECUTION of the independent slices, not routing sequential steps through agents.
174
+ - **You own the user's intent.** Subagents never see this conversation. Interpreting the request and taste calls stay with you; each assignment carries every requirement its slice needs.
167
175
  {{#when MAX_CONCURRENCY ">" 0}}
168
176
  - **Concurrency cap:** At most {{pluralize MAX_CONCURRENCY "subagent" "subagents"}} run at once in this session — anything beyond that just queues, so a {{#if taskBatch}}`tasks[]` batch{{else}}set of parallel `task` calls{{/if}} larger than {{MAX_CONCURRENCY}} only delays results. Keep the fan-out at or under the cap.
169
177
  {{/when}}
170
178
  - **Sequence only when necessary:** The only reason to run A before B is if B strictly requires A's output to function (e.g., a core API contract or schema migration). {{#if taskIrcEnabled}}If the missing piece is small, run them in parallel and have B ask A via `irc`!{{/if}}
171
- {{/if}}
172
179
  {{/has}}
173
180
 
174
181
  EXECUTION WORKFLOW
@@ -0,0 +1,23 @@
1
+ <vibe-mode>
2
+ Vibe mode is ON. You are the DIRECTOR. You do not edit, run, grep, or build anything yourself — your hands are off the keyboard. You drive two kinds of worker CLIs, each a full coding agent with every normal tool, and you verify their work by reading files.
3
+
4
+ Your entire toolset: `read`, `vibe_spawn`, `vibe_send`, `vibe_wait`, `vibe_kill`, `vibe_list`.
5
+
6
+ # The two CLIs you drive
7
+
8
+ - `fast` — low-latency model. Mechanical, well-specified work: renames, small fixes, boilerplate, data collection, running tests and reporting output.
9
+ - `good` — strong model. Hard work: design, tricky debugging, multi-file refactors, anything needing judgment.
10
+
11
+ Sessions are persistent conversations, like terminals you keep open. A session remembers everything you told it and everything it did. Spawn once per workstream, then keep talking to the SAME session — never respawn for a follow-up on the same workstream.
12
+
13
+ # How to direct
14
+
15
+ 1. Split the request into independent workstreams. One session per workstream; keep each session on its own workstream to build useful context.
16
+ 2. `vibe_spawn` with a complete, self-contained brief: files, constraints, acceptance criteria. Workers start blank — they never see this conversation.
17
+ 3. Sends and spawns return immediately; results arrive on their own when a worker finishes its turn. Keep directing other sessions meanwhile; call `vibe_wait` only when you cannot proceed without a result.
18
+ 4. When a turn result arrives, judge it: `read` the touched files to verify claims before building on them. Follow up with `vibe_send` — corrections, next step, or a review request.
19
+ 5. Route by difficulty: draft with `fast`, escalate to `good` when `fast` stalls or the problem needs judgment; have `good` design and `fast` execute the mechanical parts.
20
+ 6. `vibe_kill` a session that is stuck or whose workstream is done; `vibe_list` when you lose track of the roster.
21
+
22
+ Run sessions concurrently — one `fast` and one `good` on different workstreams is the normal shape. You stay responsible for the final outcome: verify with `read`, do not take a worker's word for it.
23
+ </vibe-mode>
@@ -17,9 +17,9 @@ Drives real Chromium tab; full puppeteer access via JS.
17
17
  - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot: `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Ids stable until next observe/goto.
18
18
  - `tab.ariaSnapshot(selector?, { depth?, boxes? })` — Playwright-format ARIA-tree YAML (nested roles + accessible names + `/url`/`/placeholder`), scoped to `selector` or the whole document. Every node carries a `[ref=eN]` id; `[cursor=pointer]` flags clickables. Captures dense, hierarchical structure/text that `observe()`'s flat list flattens away. Refs renumber from e1 each call and stay valid until the next `ariaSnapshot()`.
19
19
  - `tab.ref("e5")` — `[ref=eN]` from the last ariaSnapshot → element handle with the common action methods (`.click()`, `.type()`, `.fill()`, `.hover()`, `.evaluate()`, …); the primary way to act on a ref. For convenience `aria-ref=e5` also works inline in `tab.click`/`type`/`fill`/`waitFor`/`scrollIntoView` (e.g. `tab.click("aria-ref=e5")`).
20
- - `tab.id(n)` — id from last observe → `ElementHandle` (`.click()`, `.type()`, …).
20
+ - `tab.id(n)` — id from last observe → element handle with the same action methods (`.click()`, `.type()`, `.fill()`, …).
21
21
  - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)`.
22
- - `tab.waitFor(selector, { timeout? })` / `tab.waitForSelector(selector, { timeout?, visible?, hidden? })` — wait until attached (optionally visible/hidden); returns the `ElementHandle`.
22
+ - `tab.waitFor(selector, { timeout? })` / `tab.waitForSelector(selector, { timeout?, visible?, hidden? })` — wait until attached (optionally visible/hidden); returns an action-method handle.
23
23
  - `tab.drag(from, to)` — endpoints: selector (center-to-center) or `{ x, y }` viewport point (canvases, sliders).
24
24
  - `tab.scrollIntoView(selector)` — center in viewport; before clicking off-screen elements.
25
25
  - `tab.select(selector, …values)` — set `<select>` option(s); returns selection. `tab.fill` NEVER works for selects.
@@ -30,7 +30,7 @@ Drives real Chromium tab; full puppeteer access via JS.
30
30
  - `tab.evaluate(fn, …args)` — `page.evaluate` for ad-hoc DOM reads.
31
31
  - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture + attach for viewing (`silent: true` skips). Pass `save` only when a later step needs the file.
32
32
  - `tab.extract(format = "markdown")` — readable page content (`"markdown"` | `"text"`); throws when nothing readable.
33
- - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.<op> timed out` error, never the whole-cell timeout.
33
+ - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.<op>` error carrying a match-count diagnosis, never the whole-cell timeout; a selector matching nothing fails in ~2s (pass an explicit `{ timeout }` to `waitFor`/`waitForSelector` to wait out slow-appearing elements).
34
34
  </instruction>
35
35
 
36
36
  <critical>
@@ -1,7 +1,7 @@
1
1
  Greps files using regex.
2
2
 
3
3
  <instruction>
4
- - Rust regex (RE2-style): alternation is `foo|bar`, not GNU BRE-style `foo\|bar`; Rust word boundaries like `\bword\b` are supported. Use line anchors or post-filters instead of lookaround/backreferences.
4
+ - Supports Rust regex and PCRE2 syntax.
5
5
  - `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Use `selector` only for line-number filtering, never path/root selection (`"/"` belongs in `path`).
6
6
  - Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
7
7
  - Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
@@ -0,0 +1,3 @@
1
+ Terminates a worker session: aborts its in-flight turn (if any) and discards the session. Its conversation cannot be continued afterwards — the transcript stays readable at `history://<id>`.
2
+
3
+ Kill sessions that are stuck, looping, or whose workstream is complete. Freeing dead weight keeps the roster legible.
@@ -0,0 +1,3 @@
1
+ Shows your worker-session roster: id, CLI flavor (`fast`/`good`), state (`starting`/`running`/`idle`/`dead`), model, turn count, queued messages, and a one-line gist of each session's latest activity.
2
+
3
+ Use it to reorient: which sessions exist, who is busy, who is idle and ready for the next instruction.
@@ -0,0 +1,9 @@
1
+ Sends a message to one of your worker sessions (by id from `vibe_spawn` / `vibe_list`). The session keeps its full conversation history — refer to earlier work naturally ("now do the same for the other module").
2
+
3
+ Returns immediately with an ack telling you how the message landed:
4
+
5
+ - `turn` — the worker was idle; a new turn started. Its result self-delivers when done.
6
+ - `steered` — the worker was mid-turn; your message was injected into the running turn as live steering.
7
+ - `queued` — the worker was mid-turn and not steerable right now; your message runs as the next turn automatically.
8
+
9
+ Use it for follow-ups, corrections, scope changes, and review requests. Never re-explain prior context — the session already has it.
@@ -0,0 +1,10 @@
1
+ Starts a persistent worker session — a full coding agent (edit, bash, grep, everything) that you drive by conversation. Pick the CLI flavor per task:
2
+
3
+ - `fast`: low-latency model for mechanical, well-specified work (renames, boilerplate, running tests, data collection).
4
+ - `good`: strong model for hard work (design, debugging, multi-file changes, judgment calls).
5
+
6
+ `prompt` is the session's first instruction. The worker starts with NO context beyond it — include files, constraints, and acceptance criteria. `name` (optional) labels the session; otherwise one is generated.
7
+
8
+ Returns immediately with the session id; the turn's result (activity trace + the worker's response) is delivered to you automatically when the worker finishes. Do not wait unless you are blocked — keep directing other sessions.
9
+
10
+ The session persists after the turn: it remembers the whole conversation. Continue it with `vibe_send`; never spawn a second session for a follow-up on the same workstream.
@@ -0,0 +1,19 @@
1
+ <vibe-turn session="{{id}}" cli="{{cli}}" turn="{{turn}}" status="{{status}}" duration="{{duration}}"{{#if model}} model="{{model}}"{{/if}}>
2
+ <activity tool-calls="{{toolCount}}" requests="{{requests}}">
3
+ {{#each trace}}
4
+ - {{this}}
5
+ {{/each}}
6
+ {{#if traceOverflow}}
7
+ - … {{traceOverflow}} earlier tool call(s) not shown
8
+ {{/if}}
9
+ </activity>
10
+ <response{{#if responseTruncated}} truncated="true" full-output="agent://{{id}}"{{/if}}>
11
+ {{response}}
12
+ </response>
13
+ {{#if error}}
14
+ <error>{{error}}</error>
15
+ {{/if}}
16
+ {{#if alive}}
17
+ Session `{{id}}` is idle and retains this conversation — continue it with vibe_send. Transcript: history://{{id}}
18
+ {{/if}}
19
+ </vibe-turn>
@@ -0,0 +1,8 @@
1
+ Blocks until ONE watched session finishes its current turn, the timeout elapses, or you are interrupted — not until all finish. Re-issue to keep waiting.
2
+
3
+ Turn results normally deliver themselves; you NEVER need this to receive output. Use it only when you are completely blocked and cannot direct any other session.
4
+
5
+ - `sessions` — ids to watch. Omit to watch every session with a turn in flight.
6
+ - `timeout` — seconds to wait (default 30).
7
+
8
+ A finished turn's full result (activity trace + response) is returned here and will not be re-delivered separately.
package/src/sdk.ts CHANGED
@@ -161,6 +161,7 @@ import {
161
161
  BUILTIN_TOOLS,
162
162
  computeEssentialBuiltinNames,
163
163
  createTools,
164
+ createVibeTools,
164
165
  type DeferredDiagnosticsEntry,
165
166
  discoverStartupLspServers,
166
167
  EditTool,
@@ -2883,6 +2884,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2883
2884
  skillsSettings: settings.getGroup("skills"),
2884
2885
  modelRegistry,
2885
2886
  toolRegistry,
2887
+ createVibeTools:
2888
+ (options.taskDepth ?? 0) === 0 && !options.parentTaskPrefix
2889
+ ? () => createVibeTools(toolSession)
2890
+ : undefined,
2886
2891
  builtInToolNames: builtInRegistryToolNames,
2887
2892
  transformContext,
2888
2893
  transformProviderContext,
@@ -277,6 +277,7 @@ import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redir
277
277
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
278
278
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
279
279
  import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
280
+ import vibeModeActivePrompt from "../prompts/system/vibe-mode-active.md" with { type: "text" };
280
281
  import { AgentRegistry } from "../registry/agent-registry";
281
282
  import {
282
283
  deobfuscateAssistantContent,
@@ -330,6 +331,7 @@ import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallba
330
331
  import { formatLocalCalendarDate } from "../utils/local-date";
331
332
  import { generateSessionTitle } from "../utils/title-generator";
332
333
  import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
334
+ import type { VibeModeState } from "../vibe/state";
333
335
  import type { AuthStorage } from "./auth-storage";
334
336
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
335
337
  import {
@@ -712,6 +714,8 @@ export interface AgentSessionConfig {
712
714
  modelRegistry: ModelRegistry;
713
715
  /** Tool registry for LSP and settings */
714
716
  toolRegistry?: Map<string, AgentTool>;
717
+ /** Creates the tools registered only while `/vibe` mode is active. */
718
+ createVibeTools?: () => AgentTool[];
715
719
  /** Tool names whose current registry entry is still the built-in implementation. */
716
720
  builtInToolNames?: Iterable<string>;
717
721
  /** Update tool-session predicates that render guidance from the live active tool set. */
@@ -1608,6 +1612,7 @@ export class AgentSession {
1608
1612
  #advisorPrimaryTurnsCompleted = 0;
1609
1613
  #advisorInterruptImmuneTurnStart: number | undefined;
1610
1614
  #planModeState: PlanModeState | undefined;
1615
+ #vibeModeState: VibeModeState | undefined;
1611
1616
  #goalModeState: GoalModeState | undefined;
1612
1617
  #goalRuntime: GoalRuntime;
1613
1618
  #advisorEnabled = false;
@@ -1744,6 +1749,8 @@ export class AgentSession {
1744
1749
 
1745
1750
  // Tool registry and prompt builder for extensions
1746
1751
  #toolRegistry: Map<string, AgentTool>;
1752
+ #createVibeTools: (() => AgentTool[]) | undefined;
1753
+ #installedVibeToolNames = new Set<string>();
1747
1754
  #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
1748
1755
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
1749
1756
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
@@ -2107,6 +2114,7 @@ export class AgentSession {
2107
2114
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
2108
2115
  this.#validateRetryFallbackChains();
2109
2116
  this.#toolRegistry = config.toolRegistry ?? new Map();
2117
+ this.#createVibeTools = config.createVibeTools;
2110
2118
  this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
2111
2119
  this.#requestedToolNames = config.requestedToolNames;
2112
2120
  this.#transformContext = config.transformContext ?? (messages => messages);
@@ -2501,7 +2509,7 @@ export class AgentSession {
2501
2509
  if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions);
2502
2510
  if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim());
2503
2511
 
2504
- const names = config.tools?.length ? new Set(config.tools) : ADVISOR_DEFAULT_TOOL_NAMES;
2512
+ const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
2505
2513
  const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
2506
2514
 
2507
2515
  const primaryProviderSessionId = this.sessionId;
@@ -6113,6 +6121,49 @@ export class AgentSession {
6113
6121
  return Array.from(this.#toolRegistry.keys());
6114
6122
  }
6115
6123
 
6124
+ #wrapRuntimeTool(tool: AgentTool): AgentTool {
6125
+ const wrapped = wrapToolWithMetaNotice(tool);
6126
+ return this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped;
6127
+ }
6128
+
6129
+ /**
6130
+ * Registers the ephemeral vibe tools and activates them alongside `baseToolNames`.
6131
+ *
6132
+ * @throws When this session cannot create vibe tools or the factory returns duplicate names.
6133
+ */
6134
+ async activateVibeTools(baseToolNames: string[]): Promise<void> {
6135
+ const createVibeTools = this.#createVibeTools;
6136
+ if (!createVibeTools) {
6137
+ throw new Error("Vibe tools are unavailable in this session.");
6138
+ }
6139
+
6140
+ const tools = createVibeTools();
6141
+ const vibeToolNames = tools.map(tool => tool.name);
6142
+ if (new Set(vibeToolNames).size !== vibeToolNames.length) {
6143
+ throw new Error("Vibe tool names must be unique.");
6144
+ }
6145
+
6146
+ for (const tool of tools) {
6147
+ if (this.#toolRegistry.has(tool.name)) continue;
6148
+ this.#toolRegistry.set(tool.name, this.#wrapRuntimeTool(tool));
6149
+ this.#builtInToolNames.add(tool.name);
6150
+ this.#installedVibeToolNames.add(tool.name);
6151
+ }
6152
+
6153
+ await this.#applyActiveToolsByName([...new Set([...baseToolNames, ...vibeToolNames])]);
6154
+ }
6155
+
6156
+ /** Removes tools installed by {@link activateVibeTools} and activates `nextToolNames`. */
6157
+ async deactivateVibeTools(nextToolNames: string[]): Promise<void> {
6158
+ for (const name of this.#installedVibeToolNames) {
6159
+ this.#toolRegistry.delete(name);
6160
+ this.#builtInToolNames.delete(name);
6161
+ this.#selectedDiscoveredToolNames.delete(name);
6162
+ }
6163
+ this.#installedVibeToolNames.clear();
6164
+ await this.#applyActiveToolsByName(nextToolNames);
6165
+ }
6166
+
6116
6167
  #getEditModeSession() {
6117
6168
  return {
6118
6169
  settings: this.settings,
@@ -7071,6 +7122,14 @@ export class AgentSession {
7071
7122
  this.#goalModeState = state;
7072
7123
  }
7073
7124
 
7125
+ getVibeModeState(): VibeModeState | undefined {
7126
+ return this.#vibeModeState;
7127
+ }
7128
+
7129
+ setVibeModeState(state: VibeModeState | undefined): void {
7130
+ this.#vibeModeState = state;
7131
+ }
7132
+
7074
7133
  get goalRuntime(): GoalRuntime {
7075
7134
  return this.#goalRuntime;
7076
7135
  }
@@ -7203,6 +7262,21 @@ export class AgentSession {
7203
7262
  );
7204
7263
  }
7205
7264
 
7265
+ async sendVibeModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
7266
+ const message = this.#buildVibeModeMessage();
7267
+ if (!message) return;
7268
+ await this.sendCustomMessage(
7269
+ {
7270
+ customType: message.customType,
7271
+ content: message.content,
7272
+ display: message.display,
7273
+ details: message.details,
7274
+ attribution: message.attribution,
7275
+ },
7276
+ options ? { deliverAs: options.deliverAs } : undefined,
7277
+ );
7278
+ }
7279
+
7206
7280
  resolveRoleModel(role: string): Model | undefined {
7207
7281
  return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model;
7208
7282
  }
@@ -7331,6 +7405,18 @@ export class AgentSession {
7331
7405
  };
7332
7406
  }
7333
7407
 
7408
+ #buildVibeModeMessage(): CustomMessage | null {
7409
+ if (!this.#vibeModeState?.enabled) return null;
7410
+ return {
7411
+ role: "custom",
7412
+ customType: "vibe-mode-context",
7413
+ content: prompt.render(vibeModeActivePrompt),
7414
+ display: false,
7415
+ attribution: "agent",
7416
+ timestamp: Date.now(),
7417
+ };
7418
+ }
7419
+
7334
7420
  #sanitizeGoalTodoText(text: string): string {
7335
7421
  return escapeXmlText(text)
7336
7422
  .replace(/\r\n/g, "\\n")
@@ -7768,6 +7854,10 @@ export class AgentSession {
7768
7854
  if (goalModeMessage) {
7769
7855
  messages.push(goalModeMessage);
7770
7856
  }
7857
+ const vibeModeMessage = this.#buildVibeModeMessage();
7858
+ if (vibeModeMessage) {
7859
+ messages.push(vibeModeMessage);
7860
+ }
7771
7861
  if (options?.prependMessages) {
7772
7862
  messages.push(...options.prependMessages);
7773
7863
  }
@@ -10874,21 +10964,21 @@ export class AgentSession {
10874
10964
 
10875
10965
  this.#emptyStopRetryCount++;
10876
10966
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
10877
- logger.warn("Assistant returned empty stop after retry cap", {
10878
- attempts: this.#emptyStopRetryCount - 1,
10967
+ const attempts = this.#emptyStopRetryCount - 1;
10968
+ const finalError = "Assistant returned empty stop after retry cap";
10969
+ logger.warn(finalError, {
10970
+ attempts,
10879
10971
  model: assistantMessage.model,
10880
10972
  provider: assistantMessage.provider,
10881
10973
  });
10882
- if (this.#retryAttempt > 0) {
10883
- await this.#emitSessionEvent({
10884
- type: "auto_retry_end",
10885
- success: false,
10886
- attempt: this.#retryAttempt,
10887
- finalError: "Assistant returned empty stop after retry cap",
10888
- });
10889
- this.#clearPendingRecoveredRetryErrors();
10890
- this.#retryAttempt = 0;
10891
- }
10974
+ await this.#emitSessionEvent({
10975
+ type: "auto_retry_end",
10976
+ success: false,
10977
+ attempt: this.#retryAttempt > 0 ? this.#retryAttempt : attempts,
10978
+ finalError,
10979
+ });
10980
+ this.#clearPendingRecoveredRetryErrors();
10981
+ this.#retryAttempt = 0;
10892
10982
  this.#resolveRetry();
10893
10983
  // Tool-use orphans corrupt Anthropic message history (tool_result without
10894
10984
  // matching tool_use). Always remove them even when the retry cap is hit.
@@ -16,7 +16,6 @@
16
16
 
17
17
  import { countTokens } from "@oh-my-pi/pi-agent-core";
18
18
  import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
19
- import { isPersonalGitHubCopilotBaseUrl } from "@oh-my-pi/pi-catalog/wire/github-copilot";
20
19
  import * as snapcompact from "@oh-my-pi/snapcompact";
21
20
  import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
22
21
  import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
@@ -78,19 +77,6 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
78
77
  return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
79
78
  }
80
79
 
81
- /**
82
- * The model is vision-capable for the endpoint we're actually about to hit.
83
- * GitHub Copilot business and enterprise hosts respond `400 vision is not
84
- * supported` on image inputs (issue #3387), so even if a stale cached spec
85
- * still advertises `["text","image"]` we MUST not rasterize transcripts when
86
- * the resolved `baseUrl` is non-personal.
87
- */
88
- function canSendImages(model: Model): boolean {
89
- if (!model.input.includes("image")) return false;
90
- if (model.provider === "github-copilot" && !isPersonalGitHubCopilotBaseUrl(model.baseUrl)) return false;
91
- return true;
92
- }
93
-
94
80
  interface SystemPromptImageTarget {
95
81
  scope: Exclude<SnapcompactSystemPromptMode, "none">;
96
82
  text: string;
@@ -291,7 +277,7 @@ export function estimateInlineSavings(input: {
291
277
  messages: readonly InlineMessageView[];
292
278
  }): SnapcompactSavingsEstimate {
293
279
  const { options, model } = input;
294
- if (!model || !canSendImages(model)) {
280
+ if (!model?.input.includes("image")) {
295
281
  return { visionCapable: false, savedTokens: 0 };
296
282
  }
297
283
 
@@ -430,10 +416,8 @@ export class SnapcompactInlineTransformer {
430
416
 
431
417
  async transform(context: Context, model: Model): Promise<Context> {
432
418
  // Vision gate: providers silently DROP images on text-only models —
433
- // rendering would lose the content entirely. Also short-circuits when the
434
- // resolved endpoint rejects vision regardless of the model's input list
435
- // (issue #3387: Copilot business endpoint).
436
- if (!canSendImages(model)) return context;
419
+ // rendering would lose the content entirely.
420
+ if (!model.input.includes("image")) return context;
437
421
 
438
422
  const shape = snapcompact.resolveShape(model, this.options.shape);
439
423
  const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);