@oh-my-pi/pi-coding-agent 3.15.0 → 3.20.0

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 (193) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-editor.ts +1 -0
  110. package/src/modes/interactive/components/hook-selector.ts +3 -3
  111. package/src/modes/interactive/components/model-selector.ts +7 -6
  112. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  113. package/src/modes/interactive/components/settings-defs.ts +55 -6
  114. package/src/modes/interactive/components/status-line/separators.ts +4 -4
  115. package/src/modes/interactive/components/status-line.ts +45 -35
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +644 -113
  118. package/src/modes/interactive/theme/defaults/alabaster.json +99 -0
  119. package/src/modes/interactive/theme/defaults/amethyst.json +103 -0
  120. package/src/modes/interactive/theme/defaults/anthracite.json +100 -0
  121. package/src/modes/interactive/theme/defaults/basalt.json +90 -0
  122. package/src/modes/interactive/theme/defaults/birch.json +101 -0
  123. package/src/modes/interactive/theme/defaults/dark-abyss.json +97 -0
  124. package/src/modes/interactive/theme/defaults/dark-aurora.json +94 -0
  125. package/src/modes/interactive/theme/defaults/dark-cavern.json +97 -0
  126. package/src/modes/interactive/theme/defaults/dark-copper.json +94 -0
  127. package/src/modes/interactive/theme/defaults/dark-cosmos.json +96 -0
  128. package/src/modes/interactive/theme/defaults/dark-eclipse.json +97 -0
  129. package/src/modes/interactive/theme/defaults/dark-ember.json +94 -0
  130. package/src/modes/interactive/theme/defaults/dark-equinox.json +96 -0
  131. package/src/modes/interactive/theme/defaults/dark-lavender.json +94 -0
  132. package/src/modes/interactive/theme/defaults/dark-lunar.json +95 -0
  133. package/src/modes/interactive/theme/defaults/dark-midnight.json +94 -0
  134. package/src/modes/interactive/theme/defaults/dark-nebula.json +96 -0
  135. package/src/modes/interactive/theme/defaults/dark-rainforest.json +97 -0
  136. package/src/modes/interactive/theme/defaults/dark-reef.json +97 -0
  137. package/src/modes/interactive/theme/defaults/dark-sakura.json +94 -0
  138. package/src/modes/interactive/theme/defaults/dark-slate.json +94 -0
  139. package/src/modes/interactive/theme/defaults/dark-solstice.json +96 -0
  140. package/src/modes/interactive/theme/defaults/dark-starfall.json +97 -0
  141. package/src/modes/interactive/theme/defaults/dark-swamp.json +96 -0
  142. package/src/modes/interactive/theme/defaults/dark-taiga.json +97 -0
  143. package/src/modes/interactive/theme/defaults/dark-terminal.json +94 -0
  144. package/src/modes/interactive/theme/defaults/dark-tundra.json +97 -0
  145. package/src/modes/interactive/theme/defaults/dark-twilight.json +97 -0
  146. package/src/modes/interactive/theme/defaults/dark-volcanic.json +97 -0
  147. package/src/modes/interactive/theme/defaults/graphite.json +99 -0
  148. package/src/modes/interactive/theme/defaults/index.ts +128 -0
  149. package/src/modes/interactive/theme/defaults/light-aurora-day.json +97 -0
  150. package/src/modes/interactive/theme/defaults/light-canyon.json +97 -0
  151. package/src/modes/interactive/theme/defaults/light-cirrus.json +96 -0
  152. package/src/modes/interactive/theme/defaults/light-coral.json +94 -0
  153. package/src/modes/interactive/theme/defaults/light-dawn.json +96 -0
  154. package/src/modes/interactive/theme/defaults/light-dunes.json +97 -0
  155. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +94 -0
  156. package/src/modes/interactive/theme/defaults/light-frost.json +94 -0
  157. package/src/modes/interactive/theme/defaults/light-glacier.json +97 -0
  158. package/src/modes/interactive/theme/defaults/light-haze.json +96 -0
  159. package/src/modes/interactive/theme/defaults/light-honeycomb.json +94 -0
  160. package/src/modes/interactive/theme/defaults/light-lagoon.json +97 -0
  161. package/src/modes/interactive/theme/defaults/light-lavender.json +94 -0
  162. package/src/modes/interactive/theme/defaults/light-meadow.json +97 -0
  163. package/src/modes/interactive/theme/defaults/light-mint.json +94 -0
  164. package/src/modes/interactive/theme/defaults/light-opal.json +97 -0
  165. package/src/modes/interactive/theme/defaults/light-orchard.json +97 -0
  166. package/src/modes/interactive/theme/defaults/light-paper.json +94 -0
  167. package/src/modes/interactive/theme/defaults/light-prism.json +96 -0
  168. package/src/modes/interactive/theme/defaults/light-sand.json +94 -0
  169. package/src/modes/interactive/theme/defaults/light-savanna.json +97 -0
  170. package/src/modes/interactive/theme/defaults/light-soleil.json +96 -0
  171. package/src/modes/interactive/theme/defaults/light-wetland.json +97 -0
  172. package/src/modes/interactive/theme/defaults/light-zenith.json +95 -0
  173. package/src/modes/interactive/theme/defaults/limestone.json +100 -0
  174. package/src/modes/interactive/theme/defaults/mahogany.json +104 -0
  175. package/src/modes/interactive/theme/defaults/marble.json +99 -0
  176. package/src/modes/interactive/theme/defaults/obsidian.json +90 -0
  177. package/src/modes/interactive/theme/defaults/onyx.json +90 -0
  178. package/src/modes/interactive/theme/defaults/pearl.json +99 -0
  179. package/src/modes/interactive/theme/defaults/porcelain.json +90 -0
  180. package/src/modes/interactive/theme/defaults/quartz.json +102 -0
  181. package/src/modes/interactive/theme/defaults/sandstone.json +101 -0
  182. package/src/modes/interactive/theme/defaults/titanium.json +89 -0
  183. package/src/modes/print-mode.ts +14 -72
  184. package/src/modes/rpc/rpc-client.ts +23 -9
  185. package/src/modes/rpc/rpc-mode.ts +137 -125
  186. package/src/modes/rpc/rpc-types.ts +46 -24
  187. package/src/prompts/task.md +1 -0
  188. package/src/prompts/tools/gemini-image.md +4 -0
  189. package/src/prompts/tools/git.md +9 -0
  190. package/src/prompts/voice-summary.md +12 -0
  191. package/src/utils/image-convert.ts +26 -0
  192. package/src/utils/image-resize.ts +215 -0
  193. package/src/utils/shell-snapshot.ts +22 -20
@@ -13,11 +13,12 @@
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
15
 
16
- import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
16
+ import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
17
  import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@oh-my-pi/pi-ai";
18
18
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
19
19
  import type { Rule } from "../capability/rule";
20
20
  import { getAuthPath } from "../config";
21
+ import { theme } from "../modes/interactive/theme/theme";
21
22
  import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
22
23
  import {
23
24
  type CompactionResult,
@@ -29,11 +30,11 @@ import {
29
30
  shouldCompact,
30
31
  } from "./compaction/index";
31
32
  import type { LoadedCustomCommand } from "./custom-commands/index";
32
- import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index";
33
33
  import { exportSessionToHtml } from "./export-html/index";
34
- import { extractFileMentions, generateFileMentionMessages } from "./file-mentions";
35
34
  import type {
36
- HookRunner,
35
+ ExtensionCommandContext,
36
+ ExtensionRunner,
37
+ ExtensionUIContext,
37
38
  SessionBeforeBranchResult,
38
39
  SessionBeforeCompactResult,
39
40
  SessionBeforeSwitchResult,
@@ -41,13 +42,15 @@ import type {
41
42
  TreePreparation,
42
43
  TurnEndEvent,
43
44
  TurnStartEvent,
44
- } from "./hooks/index";
45
- import { logger } from "./logger";
46
- import type { BashExecutionMessage, HookMessage } from "./messages";
45
+ } from "./extensions";
46
+ import { extractFileMentions, generateFileMentionMessages } from "./file-mentions";
47
+ import type { HookCommandContext } from "./hooks/types";
48
+ import type { BashExecutionMessage, CustomMessage } from "./messages";
47
49
  import type { ModelRegistry } from "./model-registry";
50
+ import { parseModelString } from "./model-resolver";
51
+ import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates";
48
52
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
49
53
  import type { SettingsManager, SkillsSettings } from "./settings-manager";
50
- import { expandSlashCommand, type FileSlashCommand, parseCommandArgs } from "./slash-commands";
51
54
  import type { TtsrManager } from "./ttsr";
52
55
 
53
56
  /** Session-specific events that extend the core AgentEvent */
@@ -72,27 +75,31 @@ export interface AgentSessionConfig {
72
75
  settingsManager: SettingsManager;
73
76
  /** Models to cycle through with Ctrl+P (from --models flag) */
74
77
  scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
75
- /** File-based slash commands for expansion */
76
- fileCommands?: FileSlashCommand[];
77
- /** Hook runner (created in main.ts with wrapped tools) */
78
- hookRunner?: HookRunner;
79
- /** Custom tools for session lifecycle events */
80
- customTools?: LoadedCustomTool[];
78
+ /** Prompt templates for expansion */
79
+ promptTemplates?: PromptTemplate[];
80
+ /** Extension runner (created in main.ts with wrapped tools) */
81
+ extensionRunner?: ExtensionRunner;
81
82
  /** Custom commands (TypeScript slash commands) */
82
83
  customCommands?: LoadedCustomCommand[];
83
84
  skillsSettings?: Required<SkillsSettings>;
84
85
  /** Model registry for API key resolution and model discovery */
85
86
  modelRegistry: ModelRegistry;
87
+ /** Tool registry for LSP and settings */
88
+ toolRegistry?: Map<string, AgentTool>;
89
+ /** System prompt builder that can consider tool availability */
90
+ rebuildSystemPrompt?: (toolNames: string[]) => string;
86
91
  /** TTSR manager for time-traveling stream rules */
87
92
  ttsrManager?: TtsrManager;
88
93
  }
89
94
 
90
95
  /** Options for AgentSession.prompt() */
91
96
  export interface PromptOptions {
92
- /** Whether to expand file-based slash commands (default: true) */
93
- expandSlashCommands?: boolean;
97
+ /** Whether to expand file-based prompt templates (default: true) */
98
+ expandPromptTemplates?: boolean;
94
99
  /** Image attachments */
95
100
  images?: ImageContent[];
101
+ /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */
102
+ streamingBehavior?: "steer" | "followUp";
96
103
  }
97
104
 
98
105
  /** Result from cycleModel() */
@@ -103,6 +110,13 @@ export interface ModelCycleResult {
103
110
  isScoped: boolean;
104
111
  }
105
112
 
113
+ /** Result from cycleRoleModels() */
114
+ export interface RoleModelCycleResult {
115
+ model: Model<any>;
116
+ thinkingLevel: ThinkingLevel;
117
+ role: string;
118
+ }
119
+
106
120
  /** Session statistics for /session command */
107
121
  export interface SessionStats {
108
122
  sessionFile: string | undefined;
@@ -133,6 +147,23 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "hi
133
147
  /** Thinking levels including xhigh (for supported models) */
134
148
  const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
135
149
 
150
+ const noOpUIContext: ExtensionUIContext = {
151
+ select: async () => undefined,
152
+ confirm: async () => false,
153
+ input: async () => undefined,
154
+ notify: () => {},
155
+ setStatus: () => {},
156
+ setWidget: () => {},
157
+ setTitle: () => {},
158
+ custom: async () => undefined as never,
159
+ setEditorText: () => {},
160
+ getEditorText: () => "",
161
+ editor: async () => undefined,
162
+ get theme() {
163
+ return theme;
164
+ },
165
+ };
166
+
136
167
  // ============================================================================
137
168
  // AgentSession Class
138
169
  // ============================================================================
@@ -143,14 +174,18 @@ export class AgentSession {
143
174
  readonly settingsManager: SettingsManager;
144
175
 
145
176
  private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
146
- private _fileCommands: FileSlashCommand[];
177
+ private _promptTemplates: PromptTemplate[];
147
178
 
148
179
  // Event subscription state
149
180
  private _unsubscribeAgent?: () => void;
150
181
  private _eventListeners: AgentSessionEventListener[] = [];
151
182
 
152
- // Message queue state
153
- private _queuedMessages: string[] = [];
183
+ /** Tracks pending steering messages for UI display. Removed when delivered. */
184
+ private _steeringMessages: string[] = [];
185
+ /** Tracks pending follow-up messages for UI display. Removed when delivered. */
186
+ private _followUpMessages: string[] = [];
187
+ /** Messages queued to be included with the next user prompt as context ("asides"). */
188
+ private _pendingNextTurnMessages: CustomMessage[] = [];
154
189
 
155
190
  // Compaction state
156
191
  private _compactionAbortController: AbortController | undefined = undefined;
@@ -169,13 +204,10 @@ export class AgentSession {
169
204
  private _bashAbortController: AbortController | undefined = undefined;
170
205
  private _pendingBashMessages: BashExecutionMessage[] = [];
171
206
 
172
- // Hook system
173
- private _hookRunner: HookRunner | undefined = undefined;
207
+ // Extension system
208
+ private _extensionRunner: ExtensionRunner | undefined = undefined;
174
209
  private _turnIndex = 0;
175
210
 
176
- // Custom tools for session lifecycle
177
- private _customTools: LoadedCustomTool[] = [];
178
-
179
211
  // Custom commands (TypeScript slash commands)
180
212
  private _customCommands: LoadedCustomCommand[] = [];
181
213
 
@@ -184,6 +216,11 @@ export class AgentSession {
184
216
  // Model registry for API key resolution
185
217
  private _modelRegistry: ModelRegistry;
186
218
 
219
+ // Tool registry and prompt builder for extensions
220
+ private _toolRegistry: Map<string, AgentTool>;
221
+ private _rebuildSystemPrompt: ((toolNames: string[]) => string) | undefined;
222
+ private _baseSystemPrompt: string;
223
+
187
224
  // TTSR manager for time-traveling stream rules
188
225
  private _ttsrManager: TtsrManager | undefined = undefined;
189
226
  private _pendingTtsrInjections: Rule[] = [];
@@ -194,12 +231,14 @@ export class AgentSession {
194
231
  this.sessionManager = config.sessionManager;
195
232
  this.settingsManager = config.settingsManager;
196
233
  this._scopedModels = config.scopedModels ?? [];
197
- this._fileCommands = config.fileCommands ?? [];
198
- this._hookRunner = config.hookRunner;
199
- this._customTools = config.customTools ?? [];
234
+ this._promptTemplates = config.promptTemplates ?? [];
235
+ this._extensionRunner = config.extensionRunner;
200
236
  this._customCommands = config.customCommands ?? [];
201
237
  this._skillsSettings = config.skillsSettings;
202
238
  this._modelRegistry = config.modelRegistry;
239
+ this._toolRegistry = config.toolRegistry ?? new Map();
240
+ this._rebuildSystemPrompt = config.rebuildSystemPrompt;
241
+ this._baseSystemPrompt = this.agent.state.systemPrompt;
203
242
  this._ttsrManager = config.ttsrManager;
204
243
 
205
244
  // Always subscribe to agent events for internal handling
@@ -240,22 +279,27 @@ export class AgentSession {
240
279
 
241
280
  /** Internal handler for agent events - shared by subscribe and reconnect */
242
281
  private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
243
- // When a user message starts, check if it's from the queue and remove it BEFORE emitting
282
+ // When a user message starts, check if it's from either queue and remove it BEFORE emitting
244
283
  // This ensures the UI sees the updated queue state
245
- if (event.type === "message_start" && event.message.role === "user" && this._queuedMessages.length > 0) {
246
- // Extract text content from the message
284
+ if (event.type === "message_start" && event.message.role === "user") {
247
285
  const messageText = this._getUserMessageText(event.message);
248
- if (messageText && this._queuedMessages.includes(messageText)) {
249
- // Remove the first occurrence of this message from the queue
250
- const index = this._queuedMessages.indexOf(messageText);
251
- if (index !== -1) {
252
- this._queuedMessages.splice(index, 1);
286
+ if (messageText) {
287
+ // Check steering queue first
288
+ const steeringIndex = this._steeringMessages.indexOf(messageText);
289
+ if (steeringIndex !== -1) {
290
+ this._steeringMessages.splice(steeringIndex, 1);
291
+ } else {
292
+ // Check follow-up queue
293
+ const followUpIndex = this._followUpMessages.indexOf(messageText);
294
+ if (followUpIndex !== -1) {
295
+ this._followUpMessages.splice(followUpIndex, 1);
296
+ }
253
297
  }
254
298
  }
255
299
  }
256
300
 
257
- // Emit to hooks first
258
- await this._emitHookEvent(event);
301
+ // Emit to extensions first
302
+ await this._emitExtensionEvent(event);
259
303
 
260
304
  // Notify all listeners
261
305
  this._emit(event);
@@ -316,8 +360,8 @@ export class AgentSession {
316
360
 
317
361
  // Handle session persistence
318
362
  if (event.type === "message_end") {
319
- // Check if this is a hook message
320
- if (event.message.role === "hookMessage") {
363
+ // Check if this is a hook/custom message
364
+ if (event.message.role === "hookMessage" || event.message.role === "custom") {
321
365
  // Persist as CustomMessageEntry
322
366
  this.sessionManager.appendCustomMessageEntry(
323
367
  event.message.customType,
@@ -412,22 +456,22 @@ export class AgentSession {
412
456
  return undefined;
413
457
  }
414
458
 
415
- /** Emit hook events based on agent events */
416
- private async _emitHookEvent(event: AgentEvent): Promise<void> {
417
- if (!this._hookRunner) return;
459
+ /** Emit extension events based on agent events */
460
+ private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
461
+ if (!this._extensionRunner) return;
418
462
 
419
463
  if (event.type === "agent_start") {
420
464
  this._turnIndex = 0;
421
- await this._hookRunner.emit({ type: "agent_start" });
465
+ await this._extensionRunner.emit({ type: "agent_start" });
422
466
  } else if (event.type === "agent_end") {
423
- await this._hookRunner.emit({ type: "agent_end", messages: event.messages });
467
+ await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
424
468
  } else if (event.type === "turn_start") {
425
469
  const hookEvent: TurnStartEvent = {
426
470
  type: "turn_start",
427
471
  turnIndex: this._turnIndex,
428
472
  timestamp: Date.now(),
429
473
  };
430
- await this._hookRunner.emit(hookEvent);
474
+ await this._extensionRunner.emit(hookEvent);
431
475
  } else if (event.type === "turn_end") {
432
476
  const hookEvent: TurnEndEvent = {
433
477
  type: "turn_end",
@@ -435,7 +479,7 @@ export class AgentSession {
435
479
  message: event.message,
436
480
  toolResults: event.toolResults,
437
481
  };
438
- await this._hookRunner.emit(hookEvent);
482
+ await this._extensionRunner.emit(hookEvent);
439
483
  this._turnIndex++;
440
484
  }
441
485
  }
@@ -512,6 +556,53 @@ export class AgentSession {
512
556
  return this.agent.state.isStreaming;
513
557
  }
514
558
 
559
+ /**
560
+ * Get the names of currently active tools.
561
+ * Returns the names of tools currently set on the agent.
562
+ */
563
+ getActiveToolNames(): string[] {
564
+ return this.agent.state.tools.map((t) => t.name);
565
+ }
566
+
567
+ /**
568
+ * Get a tool by name from the registry.
569
+ */
570
+ getToolByName(name: string): AgentTool | undefined {
571
+ return this._toolRegistry.get(name);
572
+ }
573
+
574
+ /**
575
+ * Get all configured tool names (built-in via --tools or default, plus custom tools).
576
+ */
577
+ getAllToolNames(): string[] {
578
+ return Array.from(this._toolRegistry.keys());
579
+ }
580
+
581
+ /**
582
+ * Set active tools by name.
583
+ * Only tools in the registry can be enabled. Unknown tool names are ignored.
584
+ * Also rebuilds the system prompt to reflect the new tool set.
585
+ * Changes take effect on the next agent turn.
586
+ */
587
+ setActiveToolsByName(toolNames: string[]): void {
588
+ const tools: AgentTool[] = [];
589
+ const validToolNames: string[] = [];
590
+ for (const name of toolNames) {
591
+ const tool = this._toolRegistry.get(name);
592
+ if (tool) {
593
+ tools.push(tool);
594
+ validToolNames.push(name);
595
+ }
596
+ }
597
+ this.agent.setTools(tools);
598
+
599
+ // Rebuild base system prompt with new tool set
600
+ if (this._rebuildSystemPrompt) {
601
+ this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
602
+ this.agent.setSystemPrompt(this._baseSystemPrompt);
603
+ }
604
+ }
605
+
515
606
  /** Whether auto-compaction is currently running */
516
607
  get isCompacting(): boolean {
517
608
  return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined;
@@ -522,9 +613,14 @@ export class AgentSession {
522
613
  return this.agent.state.messages;
523
614
  }
524
615
 
525
- /** Current queue mode */
526
- get queueMode(): "all" | "one-at-a-time" {
527
- return this.agent.getQueueMode();
616
+ /** Current steering mode */
617
+ get steeringMode(): "all" | "one-at-a-time" {
618
+ return this.agent.getSteeringMode();
619
+ }
620
+
621
+ /** Current follow-up mode */
622
+ get followUpMode(): "all" | "one-at-a-time" {
623
+ return this.agent.getFollowUpMode();
528
624
  }
529
625
 
530
626
  /** Current interrupt mode */
@@ -547,9 +643,9 @@ export class AgentSession {
547
643
  return this._scopedModels;
548
644
  }
549
645
 
550
- /** File-based slash commands */
551
- get fileCommands(): ReadonlyArray<FileSlashCommand> {
552
- return this._fileCommands;
646
+ /** Prompt templates */
647
+ get promptTemplates(): ReadonlyArray<PromptTemplate> {
648
+ return this._promptTemplates;
553
649
  }
554
650
 
555
651
  /** Custom commands (TypeScript slash commands) */
@@ -563,22 +659,20 @@ export class AgentSession {
563
659
 
564
660
  /**
565
661
  * Send a prompt to the agent.
566
- * - Validates model and API key before sending
567
- * - Handles hook commands (registered via pi.registerCommand)
568
- * - Expands file-based slash commands by default
569
- * @throws Error if no model selected or no API key available
662
+ * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
663
+ * - Expands file-based prompt templates by default
664
+ * - During streaming, queues via steer() or followUp() based on streamingBehavior option
665
+ * - Validates model and API key before sending (when not streaming)
666
+ * @throws Error if streaming and no streamingBehavior specified
667
+ * @throws Error if no model selected or no API key available (when not streaming)
570
668
  */
571
669
  async prompt(text: string, options?: PromptOptions): Promise<void> {
572
- // Flush any pending bash messages before the new prompt
573
- this._flushPendingBashMessages();
670
+ const expandPromptTemplates = options?.expandPromptTemplates ?? true;
574
671
 
575
- const expandCommands = options?.expandSlashCommands ?? true;
576
-
577
- // Handle hook commands first (if enabled and text is a slash command)
578
- if (expandCommands && text.startsWith("/")) {
579
- const handled = await this._tryExecuteHookCommand(text);
672
+ // Handle extension commands first (execute immediately, even during streaming)
673
+ if (expandPromptTemplates && text.startsWith("/")) {
674
+ const handled = await this._tryExecuteExtensionCommand(text);
580
675
  if (handled) {
581
- // Hook command executed, no prompt to send
582
676
  return;
583
677
  }
584
678
 
@@ -586,14 +680,33 @@ export class AgentSession {
586
680
  const customResult = await this._tryExecuteCustomCommand(text);
587
681
  if (customResult !== null) {
588
682
  if (customResult === "") {
589
- // Command handled, nothing to send
590
683
  return;
591
684
  }
592
- // Command returned a prompt - use it instead of the original text
593
685
  text = customResult;
594
686
  }
595
687
  }
596
688
 
689
+ // Expand file-based prompt templates if requested
690
+ const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text;
691
+
692
+ // If streaming, queue via steer() or followUp() based on option
693
+ if (this.isStreaming) {
694
+ if (!options?.streamingBehavior) {
695
+ throw new Error(
696
+ "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
697
+ );
698
+ }
699
+ if (options.streamingBehavior === "followUp") {
700
+ await this._queueFollowUp(expandedText);
701
+ } else {
702
+ await this._queueSteer(expandedText);
703
+ }
704
+ return;
705
+ }
706
+
707
+ // Flush any pending bash messages before the new prompt
708
+ this._flushPendingBashMessages();
709
+
597
710
  // Validate model
598
711
  if (!this.model) {
599
712
  throw new Error(
@@ -618,10 +731,7 @@ export class AgentSession {
618
731
  await this._checkCompaction(lastAssistant, false);
619
732
  }
620
733
 
621
- // Expand file-based slash commands if requested
622
- const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text;
623
-
624
- // Build messages array (hook message if any, then user message)
734
+ // Build messages array (custom messages if any, then user message)
625
735
  const messages: AgentMessage[] = [];
626
736
 
627
737
  // Add user message
@@ -635,6 +745,12 @@ export class AgentSession {
635
745
  timestamp: Date.now(),
636
746
  });
637
747
 
748
+ // Inject any pending "nextTurn" messages as context alongside the user message
749
+ for (const msg of this._pendingNextTurnMessages) {
750
+ messages.push(msg);
751
+ }
752
+ this._pendingNextTurnMessages = [];
753
+
638
754
  // Auto-read @filepath mentions
639
755
  const fileMentions = extractFileMentions(expandedText);
640
756
  if (fileMentions.length > 0) {
@@ -642,18 +758,26 @@ export class AgentSession {
642
758
  messages.push(...fileMentionMessages);
643
759
  }
644
760
 
645
- // Emit before_agent_start hook event
646
- if (this._hookRunner) {
647
- const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images);
648
- if (result?.message) {
649
- messages.push({
650
- role: "hookMessage",
651
- customType: result.message.customType,
652
- content: result.message.content,
653
- display: result.message.display,
654
- details: result.message.details,
655
- timestamp: Date.now(),
656
- });
761
+ // Emit before_agent_start extension event
762
+ if (this._extensionRunner) {
763
+ const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images);
764
+ if (result?.messages) {
765
+ for (const msg of result.messages) {
766
+ messages.push({
767
+ role: "custom",
768
+ customType: msg.customType,
769
+ content: msg.content,
770
+ display: msg.display,
771
+ details: msg.details,
772
+ timestamp: Date.now(),
773
+ });
774
+ }
775
+ }
776
+
777
+ if (result?.systemPromptAppend) {
778
+ this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`);
779
+ } else {
780
+ this.agent.setSystemPrompt(this._baseSystemPrompt);
657
781
  }
658
782
  }
659
783
 
@@ -662,29 +786,29 @@ export class AgentSession {
662
786
  }
663
787
 
664
788
  /**
665
- * Try to execute a hook command. Returns true if command was found and executed.
789
+ * Try to execute an extension command. Returns true if command was found and executed.
666
790
  */
667
- private async _tryExecuteHookCommand(text: string): Promise<boolean> {
668
- if (!this._hookRunner) return false;
791
+ private async _tryExecuteExtensionCommand(text: string): Promise<boolean> {
792
+ if (!this._extensionRunner) return false;
669
793
 
670
794
  // Parse command name and args
671
795
  const spaceIndex = text.indexOf(" ");
672
796
  const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
673
797
  const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
674
798
 
675
- const command = this._hookRunner.getCommand(commandName);
799
+ const command = this._extensionRunner.getCommand(commandName);
676
800
  if (!command) return false;
677
801
 
678
- // Get command context from hook runner (includes session control methods)
679
- const ctx = this._hookRunner.createCommandContext();
802
+ // Get command context from extension runner (includes session control methods)
803
+ const ctx = this._extensionRunner.createCommandContext();
680
804
 
681
805
  try {
682
806
  await command.handler(args, ctx);
683
807
  return true;
684
808
  } catch (err) {
685
- // Emit error via hook runner
686
- this._hookRunner.emitError({
687
- hookPath: `command:${commandName}`,
809
+ // Emit error via extension runner
810
+ this._extensionRunner.emitError({
811
+ extensionPath: `command:${commandName}`,
688
812
  event: "command",
689
813
  error: err instanceof Error ? err.message : String(err),
690
814
  });
@@ -692,13 +816,52 @@ export class AgentSession {
692
816
  }
693
817
  }
694
818
 
819
+ private _createCommandContext(): ExtensionCommandContext {
820
+ if (this._extensionRunner) {
821
+ return this._extensionRunner.createCommandContext();
822
+ }
823
+
824
+ return {
825
+ ui: noOpUIContext,
826
+ hasUI: false,
827
+ cwd: this.sessionManager.getCwd(),
828
+ sessionManager: this.sessionManager,
829
+ modelRegistry: this._modelRegistry,
830
+ model: this.model ?? undefined,
831
+ isIdle: () => !this.isStreaming,
832
+ abort: () => {
833
+ void this.abort();
834
+ },
835
+ hasPendingMessages: () => this.queuedMessageCount > 0,
836
+ hasQueuedMessages: () => this.queuedMessageCount > 0,
837
+ waitForIdle: () => this.agent.waitForIdle(),
838
+ newSession: async (options) => {
839
+ const success = await this.newSession({ parentSession: options?.parentSession });
840
+ if (!success) {
841
+ return { cancelled: true };
842
+ }
843
+ if (options?.setup) {
844
+ await options.setup(this.sessionManager);
845
+ }
846
+ return { cancelled: false };
847
+ },
848
+ branch: async (entryId) => {
849
+ const result = await this.branch(entryId);
850
+ return { cancelled: result.cancelled };
851
+ },
852
+ navigateTree: async (targetId, options) => {
853
+ const result = await this.navigateTree(targetId, { summarize: options?.summarize });
854
+ return { cancelled: result.cancelled };
855
+ },
856
+ };
857
+ }
858
+
695
859
  /**
696
860
  * Try to execute a custom command. Returns the prompt string if found, null otherwise.
697
861
  * If the command returns void, returns empty string to indicate it was handled.
698
862
  */
699
863
  private async _tryExecuteCustomCommand(text: string): Promise<string | null> {
700
864
  if (this._customCommands.length === 0) return null;
701
- if (!this._hookRunner) return null; // Need hook runner for command context
702
865
 
703
866
  // Parse command name and args
704
867
  const spaceIndex = text.indexOf(" ");
@@ -709,8 +872,12 @@ export class AgentSession {
709
872
  const loaded = this._customCommands.find((c) => c.command.name === commandName);
710
873
  if (!loaded) return null;
711
874
 
712
- // Get command context from hook runner (includes session control methods)
713
- const ctx = this._hookRunner.createCommandContext();
875
+ // Get command context from extension runner (includes session control methods)
876
+ const baseCtx = this._createCommandContext();
877
+ const ctx = {
878
+ ...baseCtx,
879
+ hasQueuedMessages: baseCtx.hasPendingMessages,
880
+ } as HookCommandContext;
714
881
 
715
882
  try {
716
883
  const args = parseCommandArgs(argsString);
@@ -719,23 +886,51 @@ export class AgentSession {
719
886
  // If void/undefined, command handled everything
720
887
  return result ?? "";
721
888
  } catch (err) {
722
- // Emit error via hook runner
723
- this._hookRunner.emitError({
724
- hookPath: `custom-command:${commandName}`,
725
- event: "command",
726
- error: err instanceof Error ? err.message : String(err),
727
- });
889
+ // Emit error via extension runner
890
+ if (this._extensionRunner) {
891
+ this._extensionRunner.emitError({
892
+ extensionPath: `custom-command:${commandName}`,
893
+ event: "command",
894
+ error: err instanceof Error ? err.message : String(err),
895
+ });
896
+ } else {
897
+ const message = err instanceof Error ? err.message : String(err);
898
+ console.error(`Custom command "${commandName}" failed: ${message}`);
899
+ }
728
900
  return ""; // Command was handled (with error)
729
901
  }
730
902
  }
731
903
 
732
904
  /**
733
- * Queue a message to be sent after the current response completes.
734
- * Use when agent is currently streaming.
905
+ * Queue a steering message to interrupt the agent mid-run.
906
+ */
907
+ async steer(text: string): Promise<void> {
908
+ if (text.startsWith("/")) {
909
+ this._throwIfExtensionCommand(text);
910
+ }
911
+
912
+ const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
913
+ await this._queueSteer(expandedText);
914
+ }
915
+
916
+ /**
917
+ * Queue a follow-up message to process after the agent would otherwise stop.
735
918
  */
736
- async queueMessage(text: string): Promise<void> {
737
- this._queuedMessages.push(text);
738
- await this.agent.queueMessage({
919
+ async followUp(text: string): Promise<void> {
920
+ if (text.startsWith("/")) {
921
+ this._throwIfExtensionCommand(text);
922
+ }
923
+
924
+ const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
925
+ await this._queueFollowUp(expandedText);
926
+ }
927
+
928
+ /**
929
+ * Internal: Queue a steering message (already expanded, no extension command check).
930
+ */
931
+ private async _queueSteer(text: string): Promise<void> {
932
+ this._steeringMessages.push(text);
933
+ this.agent.steer({
739
934
  role: "user",
740
935
  content: [{ type: "text", text }],
741
936
  timestamp: Date.now(),
@@ -743,65 +938,103 @@ export class AgentSession {
743
938
  }
744
939
 
745
940
  /**
746
- * Send a hook message to the session. Creates a CustomMessageEntry.
941
+ * Internal: Queue a follow-up message (already expanded, no extension command check).
942
+ */
943
+ private async _queueFollowUp(text: string): Promise<void> {
944
+ this._followUpMessages.push(text);
945
+ this.agent.followUp({
946
+ role: "user",
947
+ content: [{ type: "text", text }],
948
+ timestamp: Date.now(),
949
+ });
950
+ }
951
+
952
+ /**
953
+ * Throw an error if the text is an extension command.
954
+ */
955
+ private _throwIfExtensionCommand(text: string): void {
956
+ if (!this._extensionRunner) return;
957
+
958
+ const spaceIndex = text.indexOf(" ");
959
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
960
+ const command = this._extensionRunner.getCommand(commandName);
961
+
962
+ if (command) {
963
+ throw new Error(
964
+ `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
965
+ );
966
+ }
967
+ }
968
+
969
+ /**
970
+ * Send a custom message to the session. Creates a CustomMessageEntry.
747
971
  *
748
972
  * Handles three cases:
749
- * - Streaming: queues message, processed when loop pulls from queue
973
+ * - Streaming: queue as steer/follow-up or store for next turn
750
974
  * - Not streaming + triggerTurn: appends to state/session, starts new turn
751
975
  * - Not streaming + no trigger: appends to state/session, no turn
752
- *
753
- * @param message Hook message with customType, content, display, details
754
- * @param triggerTurn If true and not streaming, triggers a new LLM turn
755
976
  */
756
- async sendHookMessage<T = unknown>(
757
- message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
758
- triggerTurn?: boolean,
977
+ async sendCustomMessage<T = unknown>(
978
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
979
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
759
980
  ): Promise<void> {
760
- const appMessage = {
761
- role: "hookMessage" as const,
981
+ const appMessage: CustomMessage<T> = {
982
+ role: "custom",
762
983
  customType: message.customType,
763
984
  content: message.content,
764
985
  display: message.display,
765
986
  details: message.details,
766
987
  timestamp: Date.now(),
767
- } satisfies HookMessage<T>;
988
+ };
768
989
  if (this.isStreaming) {
769
- // Queue for processing by agent loop
770
- await this.agent.queueMessage(appMessage);
771
- } else if (triggerTurn) {
772
- // Send as prompt - agent loop will emit message events
990
+ if (options?.deliverAs === "nextTurn") {
991
+ this._pendingNextTurnMessages.push(appMessage);
992
+ return;
993
+ }
994
+
995
+ if (options?.deliverAs === "followUp") {
996
+ this.agent.followUp(appMessage);
997
+ } else {
998
+ this.agent.steer(appMessage);
999
+ }
1000
+ return;
1001
+ }
1002
+
1003
+ if (options?.triggerTurn) {
773
1004
  await this.agent.prompt(appMessage);
774
- } else {
775
- // Just append to agent state and session, no turn
776
- this.agent.appendMessage(appMessage);
777
- this.sessionManager.appendCustomMessageEntry(
778
- message.customType,
779
- message.content,
780
- message.display,
781
- message.details,
782
- );
1005
+ return;
783
1006
  }
1007
+
1008
+ this.agent.appendMessage(appMessage);
1009
+ this.sessionManager.appendCustomMessageEntry(
1010
+ message.customType,
1011
+ message.content,
1012
+ message.display,
1013
+ message.details,
1014
+ );
784
1015
  }
785
1016
 
786
1017
  /**
787
1018
  * Clear queued messages and return them.
788
1019
  * Useful for restoring to editor when user aborts.
789
1020
  */
790
- clearQueue(): string[] {
791
- const queued = [...this._queuedMessages];
792
- this._queuedMessages = [];
793
- this.agent.clearMessageQueue();
794
- return queued;
1021
+ clearQueue(): { steering: string[]; followUp: string[] } {
1022
+ const steering = [...this._steeringMessages];
1023
+ const followUp = [...this._followUpMessages];
1024
+ this._steeringMessages = [];
1025
+ this._followUpMessages = [];
1026
+ this.agent.clearAllQueues();
1027
+ return { steering, followUp };
795
1028
  }
796
1029
 
797
- /** Number of messages currently queued */
1030
+ /** Number of pending messages (includes both steering and follow-up) */
798
1031
  get queuedMessageCount(): number {
799
- return this._queuedMessages.length;
1032
+ return this._steeringMessages.length + this._followUpMessages.length;
800
1033
  }
801
1034
 
802
- /** Get queued messages (read-only) */
803
- getQueuedMessages(): readonly string[] {
804
- return this._queuedMessages;
1035
+ /** Get pending messages (read-only) */
1036
+ getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
1037
+ return { steering: this._steeringMessages, followUp: this._followUpMessages };
805
1038
  }
806
1039
 
807
1040
  get skillsSettings(): Required<SkillsSettings> | undefined {
@@ -828,8 +1061,8 @@ export class AgentSession {
828
1061
  const previousSessionFile = this.sessionFile;
829
1062
 
830
1063
  // Emit session_before_switch event with reason "new" (can be cancelled)
831
- if (this._hookRunner?.hasHandlers("session_before_switch")) {
832
- const result = (await this._hookRunner.emit({
1064
+ if (this._extensionRunner?.hasHandlers("session_before_switch")) {
1065
+ const result = (await this._extensionRunner.emit({
833
1066
  type: "session_before_switch",
834
1067
  reason: "new",
835
1068
  })) as SessionBeforeSwitchResult | undefined;
@@ -844,20 +1077,20 @@ export class AgentSession {
844
1077
  this.agent.reset();
845
1078
  await this.sessionManager.flush();
846
1079
  this.sessionManager.newSession(options);
847
- this._queuedMessages = [];
1080
+ this._steeringMessages = [];
1081
+ this._followUpMessages = [];
1082
+ this._pendingNextTurnMessages = [];
848
1083
  this._reconnectToAgent();
849
1084
 
850
1085
  // Emit session_switch event with reason "new" to hooks
851
- if (this._hookRunner) {
852
- await this._hookRunner.emit({
1086
+ if (this._extensionRunner) {
1087
+ await this._extensionRunner.emit({
853
1088
  type: "session_switch",
854
1089
  reason: "new",
855
1090
  previousSessionFile,
856
1091
  });
857
1092
  }
858
1093
 
859
- // Emit session event to custom tools
860
- await this.emitCustomToolSessionEvent("switch", previousSessionFile);
861
1094
  return true;
862
1095
  }
863
1096
 
@@ -897,6 +1130,55 @@ export class AgentSession {
897
1130
  return this._cycleAvailableModel(direction);
898
1131
  }
899
1132
 
1133
+ /**
1134
+ * Cycle through configured role models in a fixed order.
1135
+ * Skips missing roles and deduplicates models.
1136
+ */
1137
+ async cycleRoleModels(roleOrder: string[]): Promise<RoleModelCycleResult | undefined> {
1138
+ const availableModels = this._modelRegistry.getAvailable();
1139
+ if (availableModels.length === 0) return undefined;
1140
+
1141
+ const currentModel = this.model;
1142
+ if (!currentModel) return undefined;
1143
+ const roleModels: Array<{ role: string; model: Model<any> }> = [];
1144
+ const seen = new Set<string>();
1145
+
1146
+ for (const role of roleOrder) {
1147
+ const roleModelStr =
1148
+ role === "default"
1149
+ ? (this.settingsManager.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
1150
+ : this.settingsManager.getModelRole(role);
1151
+ if (!roleModelStr) continue;
1152
+
1153
+ const parsed = parseModelString(roleModelStr);
1154
+ let match: Model<any> | undefined;
1155
+ if (parsed) {
1156
+ match = availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
1157
+ }
1158
+ if (!match) {
1159
+ match = availableModels.find((m) => m.id.toLowerCase() === roleModelStr.toLowerCase());
1160
+ }
1161
+ if (!match) continue;
1162
+
1163
+ const key = `${match.provider}/${match.id}`;
1164
+ if (seen.has(key)) continue;
1165
+ seen.add(key);
1166
+ roleModels.push({ role, model: match });
1167
+ }
1168
+
1169
+ if (roleModels.length <= 1) return undefined;
1170
+
1171
+ let currentIndex = roleModels.findIndex((entry) => modelsAreEqual(entry.model, currentModel));
1172
+ if (currentIndex === -1) currentIndex = 0;
1173
+
1174
+ const nextIndex = (currentIndex + 1) % roleModels.length;
1175
+ const next = roleModels[nextIndex];
1176
+
1177
+ await this.setModel(next.model, next.role);
1178
+
1179
+ return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
1180
+ }
1181
+
900
1182
  private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
901
1183
  if (this._scopedModels.length <= 1) return undefined;
902
1184
 
@@ -926,7 +1208,7 @@ export class AgentSession {
926
1208
  }
927
1209
 
928
1210
  private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
929
- const availableModels = await this._modelRegistry.getAvailable();
1211
+ const availableModels = this._modelRegistry.getAvailable();
930
1212
  if (availableModels.length <= 1) return undefined;
931
1213
 
932
1214
  const currentModel = this.model;
@@ -955,7 +1237,7 @@ export class AgentSession {
955
1237
  /**
956
1238
  * Get all available models with valid API keys.
957
1239
  */
958
- async getAvailableModels(): Promise<Model<any>[]> {
1240
+ getAvailableModels(): Model<any>[] {
959
1241
  return this._modelRegistry.getAvailable();
960
1242
  }
961
1243
 
@@ -1018,16 +1300,25 @@ export class AgentSession {
1018
1300
  }
1019
1301
 
1020
1302
  // =========================================================================
1021
- // Queue Mode Management
1303
+ // Message Queue Mode Management
1022
1304
  // =========================================================================
1023
1305
 
1024
1306
  /**
1025
- * Set message queue mode.
1307
+ * Set steering mode.
1308
+ * Saves to settings.
1309
+ */
1310
+ setSteeringMode(mode: "all" | "one-at-a-time"): void {
1311
+ this.agent.setSteeringMode(mode);
1312
+ this.settingsManager.setSteeringMode(mode);
1313
+ }
1314
+
1315
+ /**
1316
+ * Set follow-up mode.
1026
1317
  * Saves to settings.
1027
1318
  */
1028
- setQueueMode(mode: "all" | "one-at-a-time"): void {
1029
- this.agent.setQueueMode(mode);
1030
- this.settingsManager.setQueueMode(mode);
1319
+ setFollowUpMode(mode: "all" | "one-at-a-time"): void {
1320
+ this.agent.setFollowUpMode(mode);
1321
+ this.settingsManager.setFollowUpMode(mode);
1031
1322
  }
1032
1323
 
1033
1324
  /**
@@ -1077,10 +1368,10 @@ export class AgentSession {
1077
1368
  }
1078
1369
 
1079
1370
  let hookCompaction: CompactionResult | undefined;
1080
- let fromHook = false;
1371
+ let fromExtension = false;
1081
1372
 
1082
- if (this._hookRunner?.hasHandlers("session_before_compact")) {
1083
- const result = (await this._hookRunner.emit({
1373
+ if (this._extensionRunner?.hasHandlers("session_before_compact")) {
1374
+ const result = (await this._extensionRunner.emit({
1084
1375
  type: "session_before_compact",
1085
1376
  preparation,
1086
1377
  branchEntries: pathEntries,
@@ -1094,7 +1385,7 @@ export class AgentSession {
1094
1385
 
1095
1386
  if (result?.compaction) {
1096
1387
  hookCompaction = result.compaction;
1097
- fromHook = true;
1388
+ fromExtension = true;
1098
1389
  }
1099
1390
  }
1100
1391
 
@@ -1104,7 +1395,7 @@ export class AgentSession {
1104
1395
  let details: unknown;
1105
1396
 
1106
1397
  if (hookCompaction) {
1107
- // Hook provided compaction content
1398
+ // Extension provided compaction content
1108
1399
  summary = hookCompaction.summary;
1109
1400
  firstKeptEntryId = hookCompaction.firstKeptEntryId;
1110
1401
  tokensBefore = hookCompaction.tokensBefore;
@@ -1128,7 +1419,7 @@ export class AgentSession {
1128
1419
  throw new Error("Compaction cancelled");
1129
1420
  }
1130
1421
 
1131
- this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
1422
+ this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
1132
1423
  const newEntries = this.sessionManager.getEntries();
1133
1424
  const sessionContext = this.sessionManager.buildSessionContext();
1134
1425
  this.agent.replaceMessages(sessionContext.messages);
@@ -1138,11 +1429,11 @@ export class AgentSession {
1138
1429
  | CompactionEntry
1139
1430
  | undefined;
1140
1431
 
1141
- if (this._hookRunner && savedCompactionEntry) {
1142
- await this._hookRunner.emit({
1432
+ if (this._extensionRunner && savedCompactionEntry) {
1433
+ await this._extensionRunner.emit({
1143
1434
  type: "session_compact",
1144
1435
  compactionEntry: savedCompactionEntry,
1145
- fromHook,
1436
+ fromExtension,
1146
1437
  });
1147
1438
  }
1148
1439
 
@@ -1249,10 +1540,10 @@ export class AgentSession {
1249
1540
  }
1250
1541
 
1251
1542
  let hookCompaction: CompactionResult | undefined;
1252
- let fromHook = false;
1543
+ let fromExtension = false;
1253
1544
 
1254
- if (this._hookRunner?.hasHandlers("session_before_compact")) {
1255
- const hookResult = (await this._hookRunner.emit({
1545
+ if (this._extensionRunner?.hasHandlers("session_before_compact")) {
1546
+ const hookResult = (await this._extensionRunner.emit({
1256
1547
  type: "session_before_compact",
1257
1548
  preparation,
1258
1549
  branchEntries: pathEntries,
@@ -1267,7 +1558,7 @@ export class AgentSession {
1267
1558
 
1268
1559
  if (hookResult?.compaction) {
1269
1560
  hookCompaction = hookResult.compaction;
1270
- fromHook = true;
1561
+ fromExtension = true;
1271
1562
  }
1272
1563
  }
1273
1564
 
@@ -1277,7 +1568,7 @@ export class AgentSession {
1277
1568
  let details: unknown;
1278
1569
 
1279
1570
  if (hookCompaction) {
1280
- // Hook provided compaction content
1571
+ // Extension provided compaction content
1281
1572
  summary = hookCompaction.summary;
1282
1573
  firstKeptEntryId = hookCompaction.firstKeptEntryId;
1283
1574
  tokensBefore = hookCompaction.tokensBefore;
@@ -1302,7 +1593,7 @@ export class AgentSession {
1302
1593
  return;
1303
1594
  }
1304
1595
 
1305
- this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
1596
+ this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
1306
1597
  const newEntries = this.sessionManager.getEntries();
1307
1598
  const sessionContext = this.sessionManager.buildSessionContext();
1308
1599
  this.agent.replaceMessages(sessionContext.messages);
@@ -1312,11 +1603,11 @@ export class AgentSession {
1312
1603
  | CompactionEntry
1313
1604
  | undefined;
1314
1605
 
1315
- if (this._hookRunner && savedCompactionEntry) {
1316
- await this._hookRunner.emit({
1606
+ if (this._extensionRunner && savedCompactionEntry) {
1607
+ await this._extensionRunner.emit({
1317
1608
  type: "session_compact",
1318
1609
  compactionEntry: savedCompactionEntry,
1319
- fromHook,
1610
+ fromExtension,
1320
1611
  });
1321
1612
  }
1322
1613
 
@@ -1533,8 +1824,13 @@ export class AgentSession {
1533
1824
  * Adds result to agent context and session.
1534
1825
  * @param command The bash command to execute
1535
1826
  * @param onChunk Optional streaming callback for output
1827
+ * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
1536
1828
  */
1537
- async executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult> {
1829
+ async executeBash(
1830
+ command: string,
1831
+ onChunk?: (chunk: string) => void,
1832
+ options?: { excludeFromContext?: boolean },
1833
+ ): Promise<BashResult> {
1538
1834
  this._bashAbortController = new AbortController();
1539
1835
 
1540
1836
  try {
@@ -1553,6 +1849,7 @@ export class AgentSession {
1553
1849
  truncated: result.truncated,
1554
1850
  fullOutputPath: result.fullOutputPath,
1555
1851
  timestamp: Date.now(),
1852
+ excludeFromContext: options?.excludeFromContext,
1556
1853
  };
1557
1854
 
1558
1855
  // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
@@ -1622,8 +1919,8 @@ export class AgentSession {
1622
1919
  const previousSessionFile = this.sessionManager.getSessionFile();
1623
1920
 
1624
1921
  // Emit session_before_switch event (can be cancelled)
1625
- if (this._hookRunner?.hasHandlers("session_before_switch")) {
1626
- const result = (await this._hookRunner.emit({
1922
+ if (this._extensionRunner?.hasHandlers("session_before_switch")) {
1923
+ const result = (await this._extensionRunner.emit({
1627
1924
  type: "session_before_switch",
1628
1925
  reason: "resume",
1629
1926
  targetSessionFile: sessionPath,
@@ -1636,7 +1933,9 @@ export class AgentSession {
1636
1933
 
1637
1934
  this._disconnectFromAgent();
1638
1935
  await this.abort();
1639
- this._queuedMessages = [];
1936
+ this._steeringMessages = [];
1937
+ this._followUpMessages = [];
1938
+ this._pendingNextTurnMessages = [];
1640
1939
 
1641
1940
  // Flush pending writes before switching
1642
1941
  await this.sessionManager.flush();
@@ -1648,17 +1947,14 @@ export class AgentSession {
1648
1947
  const sessionContext = this.sessionManager.buildSessionContext();
1649
1948
 
1650
1949
  // Emit session_switch event to hooks
1651
- if (this._hookRunner) {
1652
- await this._hookRunner.emit({
1950
+ if (this._extensionRunner) {
1951
+ await this._extensionRunner.emit({
1653
1952
  type: "session_switch",
1654
1953
  reason: "resume",
1655
1954
  previousSessionFile,
1656
1955
  });
1657
1956
  }
1658
1957
 
1659
- // Emit session event to custom tools
1660
- await this.emitCustomToolSessionEvent("switch", previousSessionFile);
1661
-
1662
1958
  this.agent.replaceMessages(sessionContext.messages);
1663
1959
 
1664
1960
  // Restore model if saved
@@ -1668,7 +1964,7 @@ export class AgentSession {
1668
1964
  if (slashIdx > 0) {
1669
1965
  const provider = defaultModelStr.slice(0, slashIdx);
1670
1966
  const modelId = defaultModelStr.slice(slashIdx + 1);
1671
- const availableModels = await this._modelRegistry.getAvailable();
1967
+ const availableModels = this._modelRegistry.getAvailable();
1672
1968
  const match = availableModels.find((m) => m.provider === provider && m.id === modelId);
1673
1969
  if (match) {
1674
1970
  this.agent.setModel(match);
@@ -1707,8 +2003,8 @@ export class AgentSession {
1707
2003
  let skipConversationRestore = false;
1708
2004
 
1709
2005
  // Emit session_before_branch event (can be cancelled)
1710
- if (this._hookRunner?.hasHandlers("session_before_branch")) {
1711
- const result = (await this._hookRunner.emit({
2006
+ if (this._extensionRunner?.hasHandlers("session_before_branch")) {
2007
+ const result = (await this._extensionRunner.emit({
1712
2008
  type: "session_before_branch",
1713
2009
  entryId,
1714
2010
  })) as SessionBeforeBranchResult | undefined;
@@ -1719,6 +2015,9 @@ export class AgentSession {
1719
2015
  skipConversationRestore = result?.skipConversationRestore ?? false;
1720
2016
  }
1721
2017
 
2018
+ // Clear pending messages (bound to old session state)
2019
+ this._pendingNextTurnMessages = [];
2020
+
1722
2021
  // Flush pending writes before branching
1723
2022
  await this.sessionManager.flush();
1724
2023
 
@@ -1732,16 +2031,13 @@ export class AgentSession {
1732
2031
  const sessionContext = this.sessionManager.buildSessionContext();
1733
2032
 
1734
2033
  // Emit session_branch event to hooks (after branch completes)
1735
- if (this._hookRunner) {
1736
- await this._hookRunner.emit({
2034
+ if (this._extensionRunner) {
2035
+ await this._extensionRunner.emit({
1737
2036
  type: "session_branch",
1738
2037
  previousSessionFile,
1739
2038
  });
1740
2039
  }
1741
2040
 
1742
- // Emit session event to custom tools (with reason "branch")
1743
- await this.emitCustomToolSessionEvent("branch", previousSessionFile);
1744
-
1745
2041
  if (!skipConversationRestore) {
1746
2042
  this.agent.replaceMessages(sessionContext.messages);
1747
2043
  }
@@ -1802,11 +2098,11 @@ export class AgentSession {
1802
2098
  // Set up abort controller for summarization
1803
2099
  this._branchSummaryAbortController = new AbortController();
1804
2100
  let hookSummary: { summary: string; details?: unknown } | undefined;
1805
- let fromHook = false;
2101
+ let fromExtension = false;
1806
2102
 
1807
2103
  // Emit session_before_tree event
1808
- if (this._hookRunner?.hasHandlers("session_before_tree")) {
1809
- const result = (await this._hookRunner.emit({
2104
+ if (this._extensionRunner?.hasHandlers("session_before_tree")) {
2105
+ const result = (await this._extensionRunner.emit({
1810
2106
  type: "session_before_tree",
1811
2107
  preparation,
1812
2108
  signal: this._branchSummaryAbortController.signal,
@@ -1818,7 +2114,7 @@ export class AgentSession {
1818
2114
 
1819
2115
  if (result?.summary && options.summarize) {
1820
2116
  hookSummary = result.summary;
1821
- fromHook = true;
2117
+ fromExtension = true;
1822
2118
  }
1823
2119
  }
1824
2120
 
@@ -1884,7 +2180,7 @@ export class AgentSession {
1884
2180
  let summaryEntry: BranchSummaryEntry | undefined;
1885
2181
  if (summaryText) {
1886
2182
  // Create summary at target position (can be null for root)
1887
- const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook);
2183
+ const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
1888
2184
  summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
1889
2185
  } else if (newLeafId === null) {
1890
2186
  // No summary, navigating to root - reset leaf
@@ -1899,19 +2195,16 @@ export class AgentSession {
1899
2195
  this.agent.replaceMessages(sessionContext.messages);
1900
2196
 
1901
2197
  // Emit session_tree event
1902
- if (this._hookRunner) {
1903
- await this._hookRunner.emit({
2198
+ if (this._extensionRunner) {
2199
+ await this._extensionRunner.emit({
1904
2200
  type: "session_tree",
1905
2201
  newLeafId: this.sessionManager.getLeafId(),
1906
2202
  oldLeafId,
1907
2203
  summaryEntry,
1908
- fromHook: summaryText ? fromHook : undefined,
2204
+ fromExtension: summaryText ? fromExtension : undefined,
1909
2205
  });
1910
2206
  }
1911
2207
 
1912
- // Emit to custom tools
1913
- await this.emitCustomToolSessionEvent("tree", this.sessionFile);
1914
-
1915
2208
  this._branchSummaryAbortController = undefined;
1916
2209
  return { editorText, cancelled: false, summaryEntry };
1917
2210
  }
@@ -2119,60 +2412,30 @@ export class AgentSession {
2119
2412
  }
2120
2413
 
2121
2414
  // =========================================================================
2122
- // Hook System
2415
+ // Extension System
2123
2416
  // =========================================================================
2124
2417
 
2125
2418
  /**
2126
- * Check if hooks have handlers for a specific event type.
2419
+ * Check if extensions have handlers for a specific event type.
2127
2420
  */
2128
- hasHookHandlers(eventType: string): boolean {
2129
- return this._hookRunner?.hasHandlers(eventType) ?? false;
2421
+ hasExtensionHandlers(eventType: string): boolean {
2422
+ return this._extensionRunner?.hasHandlers(eventType) ?? false;
2130
2423
  }
2131
2424
 
2132
2425
  /**
2133
- * Get the hook runner (for setting UI context and error handlers).
2426
+ * Get the extension runner (for setting UI context and error handlers).
2134
2427
  */
2135
- get hookRunner(): HookRunner | undefined {
2136
- return this._hookRunner;
2428
+ get extensionRunner(): ExtensionRunner | undefined {
2429
+ return this._extensionRunner;
2137
2430
  }
2138
2431
 
2139
2432
  /**
2140
- * Get custom tools (for setting UI context in modes).
2433
+ * Emit a custom tool session event (backwards compatibility for older callers).
2141
2434
  */
2142
- get customTools(): LoadedCustomTool[] {
2143
- return this._customTools;
2144
- }
2145
-
2146
- /**
2147
- * Emit session event to all custom tools.
2148
- * Called on session switch, branch, tree navigation, and shutdown.
2149
- */
2150
- async emitCustomToolSessionEvent(
2151
- reason: CustomToolSessionEvent["reason"],
2152
- previousSessionFile?: string | undefined,
2153
- ): Promise<void> {
2154
- if (!this._customTools) return;
2155
-
2156
- const event: CustomToolSessionEvent = { reason, previousSessionFile };
2157
- const ctx: CustomToolContext = {
2158
- sessionManager: this.sessionManager,
2159
- modelRegistry: this._modelRegistry,
2160
- model: this.agent.state.model,
2161
- isIdle: () => !this.isStreaming,
2162
- hasQueuedMessages: () => this.queuedMessageCount > 0,
2163
- abort: () => {
2164
- this.abort();
2165
- },
2166
- };
2167
-
2168
- for (const { tool } of this._customTools) {
2169
- if (tool.onSession) {
2170
- try {
2171
- await tool.onSession(event, ctx);
2172
- } catch (err) {
2173
- logger.warn("Tool onSession error", { error: String(err) });
2174
- }
2175
- }
2176
- }
2435
+ async emitCustomToolSessionEvent(reason: "start" | "switch" | "branch" | "tree" | "shutdown"): Promise<void> {
2436
+ if (!this._extensionRunner) return;
2437
+ if (reason !== "shutdown") return;
2438
+ if (!this._extensionRunner.hasHandlers("session_shutdown")) return;
2439
+ await this._extensionRunner.emit({ type: "session_shutdown" });
2177
2440
  }
2178
2441
  }