@oh-my-pi/pi-coding-agent 14.7.0 → 14.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +12 -12
  3. package/src/cli/grep-cli.ts +1 -1
  4. package/src/config/model-equivalence.ts +1 -0
  5. package/src/config/model-registry.ts +108 -22
  6. package/src/config/settings-schema.ts +46 -1
  7. package/src/config/settings.ts +71 -1
  8. package/src/dap/client.ts +1 -0
  9. package/src/discovery/builtin.ts +34 -9
  10. package/src/discovery/helpers.ts +4 -3
  11. package/src/edit/index.ts +1 -0
  12. package/src/edit/modes/hashline.ts +212 -63
  13. package/src/eval/py/gateway-coordinator.ts +2 -3
  14. package/src/eval/py/runtime.ts +1 -0
  15. package/src/internal-urls/docs-index.generated.ts +2 -2
  16. package/src/lsp/index.ts +2 -0
  17. package/src/main.ts +10 -15
  18. package/src/mcp/discoverable-tool-metadata.ts +24 -202
  19. package/src/modes/components/extensions/extension-dashboard.ts +26 -2
  20. package/src/modes/components/extensions/state-manager.ts +41 -0
  21. package/src/modes/controllers/selector-controller.ts +3 -0
  22. package/src/modes/interactive-mode.ts +45 -13
  23. package/src/prompts/system/plan-mode-active.md +7 -3
  24. package/src/prompts/system/plan-mode-approved.md +5 -0
  25. package/src/prompts/tools/search-tool-bm25.md +14 -14
  26. package/src/prompts/tools/todo-write.md +1 -0
  27. package/src/sdk.ts +69 -8
  28. package/src/session/agent-session.ts +177 -1
  29. package/src/slash-commands/builtin-registry.ts +13 -2
  30. package/src/task/index.ts +2 -0
  31. package/src/task/isolation-backend.ts +22 -0
  32. package/src/tool-discovery/tool-index.ts +377 -0
  33. package/src/tools/ask.ts +2 -0
  34. package/src/tools/ast-edit.ts +2 -0
  35. package/src/tools/ast-grep.ts +2 -0
  36. package/src/tools/bash.ts +1 -0
  37. package/src/tools/browser.ts +2 -0
  38. package/src/tools/calculator.ts +2 -0
  39. package/src/tools/checkpoint.ts +4 -0
  40. package/src/tools/debug.ts +2 -0
  41. package/src/tools/eval.ts +2 -0
  42. package/src/tools/find.ts +2 -0
  43. package/src/tools/gh.ts +2 -0
  44. package/src/tools/hindsight-recall.ts +2 -0
  45. package/src/tools/hindsight-reflect.ts +2 -0
  46. package/src/tools/hindsight-retain.ts +2 -0
  47. package/src/tools/index.ts +74 -14
  48. package/src/tools/inspect-image.ts +2 -0
  49. package/src/tools/irc.ts +2 -1
  50. package/src/tools/job.ts +2 -1
  51. package/src/tools/notebook.ts +2 -0
  52. package/src/tools/read.ts +7 -1
  53. package/src/tools/recipe/index.ts +2 -0
  54. package/src/tools/render-mermaid.ts +2 -0
  55. package/src/tools/search-tool-bm25.ts +128 -42
  56. package/src/tools/search.ts +2 -0
  57. package/src/tools/ssh.ts +2 -0
  58. package/src/tools/todo-write.ts +2 -1
  59. package/src/tools/write.ts +2 -0
  60. package/src/web/search/index.ts +2 -0
  61. package/src/web/search/providers/searxng.ts +8 -0
package/src/main.ts CHANGED
@@ -5,13 +5,20 @@
5
5
  * createAgentSession() options. The SDK does the heavy lifting.
6
6
  */
7
7
 
8
- import { realpathSync } from "node:fs";
9
8
  import * as fs from "node:fs/promises";
10
9
  import * as os from "node:os";
11
10
  import * as path from "node:path";
12
11
  import { createInterface } from "node:readline/promises";
13
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
14
- import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from "@oh-my-pi/pi-utils";
13
+ import {
14
+ $env,
15
+ getProjectDir,
16
+ logger,
17
+ normalizePathForComparison,
18
+ postmortem,
19
+ setProjectDir,
20
+ VERSION,
21
+ } from "@oh-my-pi/pi-utils";
15
22
  import chalk from "chalk";
16
23
  import type { Args } from "./cli/args";
17
24
  import { processFileArguments } from "./cli/file-processor";
@@ -216,15 +223,6 @@ async function runInteractiveMode(
216
223
  }
217
224
  }
218
225
 
219
- function normalizePathForComparison(value: string): string {
220
- const resolved = path.resolve(value);
221
- let realPath = resolved;
222
- try {
223
- realPath = realpathSync(resolved);
224
- } catch {}
225
- return process.platform === "win32" ? realPath.toLowerCase() : realPath;
226
- }
227
-
228
226
  async function promptForkSession(session: SessionInfo): Promise<boolean> {
229
227
  if (!process.stdin.isTTY) {
230
228
  return false;
@@ -353,10 +351,7 @@ async function maybeAutoChdir(parsed: Args): Promise<void> {
353
351
  return;
354
352
  }
355
353
 
356
- const normalizePath = (value: string) => {
357
- const resolved = realpathSync(path.resolve(value));
358
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
359
- };
354
+ const normalizePath = normalizePathForComparison;
360
355
 
361
356
  const cwd = normalizePath(getProjectDir());
362
357
  const normalizedHome = normalizePath(home);
@@ -1,202 +1,24 @@
1
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
-
3
- export interface DiscoverableMCPTool {
4
- name: string;
5
- label: string;
6
- description: string;
7
- serverName?: string;
8
- mcpToolName?: string;
9
- schemaKeys: string[];
10
- }
11
-
12
- export interface DiscoverableMCPToolServerSummary {
13
- name: string;
14
- toolCount: number;
15
- }
16
-
17
- export interface DiscoverableMCPToolSummary {
18
- servers: DiscoverableMCPToolServerSummary[];
19
- toolCount: number;
20
- }
21
-
22
- export function formatDiscoverableMCPToolServerSummary(server: DiscoverableMCPToolServerSummary): string {
23
- const toolLabel = server.toolCount === 1 ? "tool" : "tools";
24
- return `${server.name} (${server.toolCount} ${toolLabel})`;
25
- }
26
-
27
- export interface DiscoverableMCPSearchDocument {
28
- tool: DiscoverableMCPTool;
29
- termFrequencies: Map<string, number>;
30
- length: number;
31
- }
32
-
33
- export interface DiscoverableMCPSearchIndex {
34
- documents: DiscoverableMCPSearchDocument[];
35
- averageLength: number;
36
- documentFrequencies: Map<string, number>;
37
- }
38
-
39
- export interface DiscoverableMCPSearchResult {
40
- tool: DiscoverableMCPTool;
41
- score: number;
42
- }
43
-
44
- const BM25_K1 = 1.2;
45
- const BM25_B = 0.75;
46
- const FIELD_WEIGHTS = {
47
- name: 6,
48
- label: 4,
49
- serverName: 2,
50
- mcpToolName: 4,
51
- description: 2,
52
- schemaKey: 1,
53
- } as const;
54
-
55
- export function isMCPToolName(name: string): boolean {
56
- return name.startsWith("mcp__");
57
- }
58
-
59
- function getSchemaPropertyKeys(parameters: unknown): string[] {
60
- if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) return [];
61
- const properties = (parameters as { properties?: unknown }).properties;
62
- if (!properties || typeof properties !== "object" || Array.isArray(properties)) return [];
63
- return Object.keys(properties as Record<string, unknown>).sort();
64
- }
65
-
66
- function tokenize(value: string): string[] {
67
- return value
68
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
69
- .replace(/[^a-zA-Z0-9]+/g, " ")
70
- .toLowerCase()
71
- .trim()
72
- .split(/\s+/)
73
- .filter(token => token.length > 0);
74
- }
75
-
76
- function addWeightedTokens(termFrequencies: Map<string, number>, value: string | undefined, weight: number): void {
77
- if (!value) return;
78
- for (const token of tokenize(value)) {
79
- termFrequencies.set(token, (termFrequencies.get(token) ?? 0) + weight);
80
- }
81
- }
82
-
83
- function buildSearchDocument(tool: DiscoverableMCPTool): DiscoverableMCPSearchDocument {
84
- const termFrequencies = new Map<string, number>();
85
- addWeightedTokens(termFrequencies, tool.name, FIELD_WEIGHTS.name);
86
- addWeightedTokens(termFrequencies, tool.label, FIELD_WEIGHTS.label);
87
- addWeightedTokens(termFrequencies, tool.serverName, FIELD_WEIGHTS.serverName);
88
- addWeightedTokens(termFrequencies, tool.mcpToolName, FIELD_WEIGHTS.mcpToolName);
89
- addWeightedTokens(termFrequencies, tool.description, FIELD_WEIGHTS.description);
90
- for (const schemaKey of tool.schemaKeys) {
91
- addWeightedTokens(termFrequencies, schemaKey, FIELD_WEIGHTS.schemaKey);
92
- }
93
- const length = Array.from(termFrequencies.values()).reduce((sum, value) => sum + value, 0);
94
- return { tool, termFrequencies, length };
95
- }
96
-
97
- export function getDiscoverableMCPTool(tool: AgentTool): DiscoverableMCPTool | null {
98
- if (!isMCPToolName(tool.name)) return null;
99
- const toolRecord = tool as AgentTool & {
100
- label?: string;
101
- description?: string;
102
- mcpServerName?: string;
103
- mcpToolName?: string;
104
- parameters?: unknown;
105
- };
106
- return {
107
- name: tool.name,
108
- label: typeof toolRecord.label === "string" ? toolRecord.label : tool.name,
109
- description: typeof toolRecord.description === "string" ? toolRecord.description : "",
110
- serverName: typeof toolRecord.mcpServerName === "string" ? toolRecord.mcpServerName : undefined,
111
- mcpToolName: typeof toolRecord.mcpToolName === "string" ? toolRecord.mcpToolName : undefined,
112
- schemaKeys: getSchemaPropertyKeys(toolRecord.parameters),
113
- };
114
- }
115
-
116
- export function collectDiscoverableMCPTools(tools: Iterable<AgentTool>): DiscoverableMCPTool[] {
117
- const discoverable: DiscoverableMCPTool[] = [];
118
- for (const tool of tools) {
119
- const metadata = getDiscoverableMCPTool(tool);
120
- if (metadata) {
121
- discoverable.push(metadata);
122
- }
123
- }
124
- return discoverable;
125
- }
126
-
127
- export function selectDiscoverableMCPToolNamesByServer(
128
- tools: Iterable<DiscoverableMCPTool>,
129
- serverNames: ReadonlySet<string>,
130
- ): string[] {
131
- if (serverNames.size === 0) return [];
132
- return Array.from(tools)
133
- .filter(tool => tool.serverName !== undefined && serverNames.has(tool.serverName))
134
- .map(tool => tool.name);
135
- }
136
-
137
- export function summarizeDiscoverableMCPTools(tools: DiscoverableMCPTool[]): DiscoverableMCPToolSummary {
138
- const serverToolCounts = new Map<string, number>();
139
- for (const tool of tools) {
140
- if (!tool.serverName) continue;
141
- serverToolCounts.set(tool.serverName, (serverToolCounts.get(tool.serverName) ?? 0) + 1);
142
- }
143
- const servers = Array.from(serverToolCounts.entries())
144
- .sort(([left], [right]) => left.localeCompare(right))
145
- .map(([name, toolCount]) => ({ name, toolCount }));
146
- return {
147
- servers,
148
- toolCount: tools.length,
149
- };
150
- }
151
-
152
- export function buildDiscoverableMCPSearchIndex(tools: Iterable<DiscoverableMCPTool>): DiscoverableMCPSearchIndex {
153
- const documents = Array.from(tools, buildSearchDocument);
154
- const averageLength = documents.reduce((sum, document) => sum + document.length, 0) / documents.length || 1;
155
- const documentFrequencies = new Map<string, number>();
156
- for (const document of documents) {
157
- for (const token of new Set(document.termFrequencies.keys())) {
158
- documentFrequencies.set(token, (documentFrequencies.get(token) ?? 0) + 1);
159
- }
160
- }
161
- return {
162
- documents,
163
- averageLength,
164
- documentFrequencies,
165
- };
166
- }
167
-
168
- export function searchDiscoverableMCPTools(
169
- index: DiscoverableMCPSearchIndex,
170
- query: string,
171
- limit: number,
172
- ): DiscoverableMCPSearchResult[] {
173
- const queryTokens = tokenize(query);
174
- if (queryTokens.length === 0) {
175
- throw new Error("Query must contain at least one letter or number.");
176
- }
177
- if (index.documents.length === 0) {
178
- return [];
179
- }
180
-
181
- const queryTermCounts = new Map<string, number>();
182
- for (const token of queryTokens) {
183
- queryTermCounts.set(token, (queryTermCounts.get(token) ?? 0) + 1);
184
- }
185
-
186
- return index.documents
187
- .map(document => {
188
- let score = 0;
189
- for (const [token, queryTermCount] of queryTermCounts) {
190
- const termFrequency = document.termFrequencies.get(token) ?? 0;
191
- if (termFrequency === 0) continue;
192
- const documentFrequency = index.documentFrequencies.get(token) ?? 0;
193
- const idf = Math.log(1 + (index.documents.length - documentFrequency + 0.5) / (documentFrequency + 0.5));
194
- const normalization = BM25_K1 * (1 - BM25_B + BM25_B * (document.length / index.averageLength));
195
- score += queryTermCount * idf * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + normalization));
196
- }
197
- return { tool: document.tool, score };
198
- })
199
- .filter(result => result.score > 0)
200
- .sort((left, right) => right.score - left.score || left.tool.name.localeCompare(right.tool.name))
201
- .slice(0, limit);
202
- }
1
+ /**
2
+ * Back-compat re-export layer.
3
+ * All types and functions have moved to src/tool-discovery/tool-index.ts.
4
+ * This file exists solely so existing imports continue to compile without changes.
5
+ */
6
+ export type {
7
+ DiscoverableMCPSearchDocument,
8
+ DiscoverableMCPSearchIndex,
9
+ DiscoverableMCPSearchResult,
10
+ DiscoverableMCPTool,
11
+ DiscoverableMCPToolServerSummary,
12
+ DiscoverableMCPToolSummary,
13
+ } from "../tool-discovery/tool-index";
14
+
15
+ export {
16
+ buildDiscoverableMCPSearchIndex,
17
+ collectDiscoverableMCPTools,
18
+ formatDiscoverableMCPToolServerSummary,
19
+ getDiscoverableMCPTool,
20
+ isMCPToolName,
21
+ searchDiscoverableMCPTools,
22
+ selectDiscoverableMCPToolNamesByServer,
23
+ summarizeDiscoverableMCPTools,
24
+ } from "../tool-discovery/tool-index";
@@ -27,15 +27,24 @@ import { theme } from "../../../modes/theme/theme";
27
27
  import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
28
28
  import { ExtensionList } from "./extension-list";
29
29
  import { InspectorPanel } from "./inspector-panel";
30
- import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager";
30
+ import {
31
+ applyDisabledExtensionsToState,
32
+ applyFilter,
33
+ createInitialState,
34
+ filterByProvider,
35
+ refreshState,
36
+ toggleProvider,
37
+ } from "./state-manager";
31
38
  import type { DashboardState } from "./types";
32
39
 
33
40
  export class ExtensionDashboard extends Container {
34
41
  #state!: DashboardState;
35
42
  #mainList!: ExtensionList;
36
43
  #inspector!: InspectorPanel;
44
+ #refreshToken = 0;
37
45
 
38
46
  onClose?: () => void;
47
+ onRequestRender?: () => void;
39
48
 
40
49
  private constructor(
41
50
  private readonly cwd: string,
@@ -181,16 +190,20 @@ export class ExtensionDashboard extends Container {
181
190
  }
182
191
  }
183
192
 
193
+ this.#applyDisabledExtensions(disabled);
184
194
  void this.#refreshFromState();
185
195
  }
186
196
 
187
197
  async #refreshFromState(): Promise<void> {
198
+ const refreshToken = ++this.#refreshToken;
188
199
  // Remember current tab ID before refresh
189
200
  const currentTabId = this.#state.tabs[this.#state.activeTabIndex]?.id;
190
201
 
191
202
  const sm = this.settings ?? Settings.instance;
192
203
  const disabledIds = sm ? ((sm.get("disabledExtensions") as string[]) ?? []) : [];
193
- this.#state = await refreshState(this.#state, this.cwd, disabledIds);
204
+ const nextState = await refreshState(this.#state, this.cwd, disabledIds);
205
+ if (refreshToken !== this.#refreshToken) return;
206
+ this.#state = nextState;
194
207
 
195
208
  // Find the same tab in the new (re-sorted) list
196
209
  if (currentTabId) {
@@ -208,6 +221,17 @@ export class ExtensionDashboard extends Container {
208
221
  }
209
222
 
210
223
  this.#buildLayout();
224
+ this.onRequestRender?.();
225
+ }
226
+
227
+ #applyDisabledExtensions(disabledIds: string[]): void {
228
+ this.#state = applyDisabledExtensionsToState(this.#state, disabledIds);
229
+ this.#mainList.setExtensions(this.#state.searchFiltered);
230
+ if (this.#state.selected) {
231
+ this.#inspector.setExtension(this.#state.selected);
232
+ }
233
+ this.#buildLayout();
234
+ this.onRequestRender?.();
211
235
  }
212
236
 
213
237
  #switchTab(direction: 1 | -1): void {
@@ -509,6 +509,47 @@ export function filterByProvider(extensions: Extension[], providerId: string): E
509
509
  return extensions.filter(ext => ext.source.provider === providerId);
510
510
  }
511
511
 
512
+ function isShadowedExtension(ext: Extension): boolean {
513
+ if (ext.shadowedBy) return true;
514
+ return Boolean((ext.raw as { _shadowed?: boolean } | null | undefined)?._shadowed);
515
+ }
516
+
517
+ /**
518
+ * Apply setting-backed item disable overrides to an existing dashboard state.
519
+ * This gives the UI immediate feedback while the full capability refresh runs.
520
+ */
521
+ export function applyDisabledExtensionsToState(state: DashboardState, disabledIds: string[]): DashboardState {
522
+ const disabled = new Set(disabledIds);
523
+ const updateExtension = (ext: Extension): Extension => {
524
+ if (disabled.has(ext.id)) {
525
+ if (ext.state === "disabled" && ext.disabledReason === "item-disabled") return ext;
526
+ return { ...ext, state: "disabled", disabledReason: "item-disabled" };
527
+ }
528
+
529
+ if (ext.state !== "disabled" || ext.disabledReason !== "item-disabled") return ext;
530
+ if (!isProviderEnabled(ext.source.provider)) {
531
+ return { ...ext, state: "disabled", disabledReason: "provider-disabled" };
532
+ }
533
+
534
+ if (isShadowedExtension(ext)) {
535
+ const shadowed: Extension = { ...ext, state: "shadowed", disabledReason: "shadowed" };
536
+ return shadowed;
537
+ }
538
+
539
+ const enabled: Extension = { ...ext, state: "active" };
540
+ delete enabled.disabledReason;
541
+ return enabled;
542
+ };
543
+
544
+ return {
545
+ ...state,
546
+ extensions: state.extensions.map(updateExtension),
547
+ tabFiltered: state.tabFiltered.map(updateExtension),
548
+ searchFiltered: state.searchFiltered.map(updateExtension),
549
+ selected: state.selected ? updateExtension(state.selected) : null,
550
+ };
551
+ }
552
+
512
553
  /**
513
554
  * Create initial dashboard state.
514
555
  */
@@ -182,6 +182,9 @@ export class SelectorController {
182
182
  done();
183
183
  this.ctx.ui.requestRender();
184
184
  };
185
+ dashboard.onRequestRender = () => {
186
+ this.ctx.ui.requestRender();
187
+ };
185
188
  return { component: dashboard, focus: dashboard };
186
189
  });
187
190
  }
@@ -663,10 +663,23 @@ export class InteractiveMode implements InteractiveModeContext {
663
663
  }
664
664
 
665
665
  finishPendingSubmission(input: SubmittedUserInput): void {
666
- if (this.#pendingSubmittedInput === input) {
666
+ const wasPendingSubmission = this.#pendingSubmittedInput === input;
667
+ const pendingSubmissionDispose = this.#pendingSubmissionDispose;
668
+ if (wasPendingSubmission) {
667
669
  this.#pendingSubmittedInput = undefined;
668
670
  this.#pendingSubmissionDispose = undefined;
669
671
  }
672
+
673
+ if (wasPendingSubmission && !this.session.isStreaming && !this.streamingComponent) {
674
+ this.optimisticUserMessageSignature = undefined;
675
+ pendingSubmissionDispose?.();
676
+ this.#pendingWorkingMessage = undefined;
677
+ if (this.loadingAnimation) {
678
+ this.loadingAnimation.stop();
679
+ this.loadingAnimation = undefined;
680
+ this.statusContainer.clear();
681
+ }
682
+ }
670
683
  }
671
684
 
672
685
  #computeEditorMaxHeight(): number {
@@ -857,6 +870,14 @@ export class InteractiveMode implements InteractiveModeContext {
857
870
  /** Restore mode state from session entries on resume (e.g. plan mode). */
858
871
  async #restoreModeFromSession(): Promise<void> {
859
872
  const sessionContext = this.sessionManager.buildSessionContext();
873
+ if (!this.session.settings.get("plan.enabled")) {
874
+ // Clear stale plan/plan_paused mode so re-enabling the setting
875
+ // later doesn't unexpectedly restore an old plan session.
876
+ if (sessionContext.mode === "plan" || sessionContext.mode === "plan_paused") {
877
+ this.sessionManager.appendModeChange("none");
878
+ }
879
+ return;
880
+ }
860
881
  if (sessionContext.mode === "plan") {
861
882
  const planFilePath = sessionContext.modeData?.planFilePath as string | undefined;
862
883
  await this.#enterPlanMode({ planFilePath });
@@ -1060,7 +1081,7 @@ export class InteractiveMode implements InteractiveModeContext {
1060
1081
 
1061
1082
  async #approvePlan(
1062
1083
  planContent: string,
1063
- options: { planFilePath: string; finalPlanFilePath: string },
1084
+ options: { planFilePath: string; finalPlanFilePath: string; preserveContext?: boolean },
1064
1085
  ): Promise<void> {
1065
1086
  await renameApprovedPlanFile({
1066
1087
  planFilePath: options.planFilePath,
@@ -1070,14 +1091,16 @@ export class InteractiveMode implements InteractiveModeContext {
1070
1091
  });
1071
1092
  const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
1072
1093
  await this.#exitPlanMode({ silent: true, paused: false });
1073
- await this.handleClearCommand();
1074
- // The new session has a fresh local:// root — persist the approved plan there
1075
- // so `local://<title>.md` resolves correctly in the execution session.
1076
- const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
1077
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1078
- getSessionId: () => this.sessionManager.getSessionId(),
1079
- });
1080
- await Bun.write(newLocalPath, planContent);
1094
+ if (!options.preserveContext) {
1095
+ await this.handleClearCommand();
1096
+ // The new session has a fresh local:// root persist the approved plan there
1097
+ // so `local://<title>.md` resolves correctly in the execution session.
1098
+ const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
1099
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1100
+ getSessionId: () => this.sessionManager.getSessionId(),
1101
+ });
1102
+ await Bun.write(newLocalPath, planContent);
1103
+ }
1081
1104
  if (previousTools.length > 0) {
1082
1105
  await this.session.setActiveToolsByName(previousTools);
1083
1106
  }
@@ -1086,6 +1109,7 @@ export class InteractiveMode implements InteractiveModeContext {
1086
1109
  const planModePrompt = prompt.render(planModeApprovedPrompt, {
1087
1110
  planContent,
1088
1111
  finalPlanFilePath: options.finalPlanFilePath,
1112
+ contextPreserved: options.preserveContext === true,
1089
1113
  });
1090
1114
  await this.session.prompt(planModePrompt, { synthetic: true });
1091
1115
  }
@@ -1100,6 +1124,10 @@ export class InteractiveMode implements InteractiveModeContext {
1100
1124
  await this.#exitPlanMode({ paused: true });
1101
1125
  return;
1102
1126
  }
1127
+ if (!this.session.settings.get("plan.enabled")) {
1128
+ this.showWarning("Plan mode is disabled. Enable it in settings (plan.enabled).");
1129
+ return;
1130
+ }
1103
1131
  await this.#enterPlanMode();
1104
1132
  if (initialPrompt && this.onInputCallback) {
1105
1133
  this.onInputCallback(this.startPendingSubmission({ text: initialPrompt }));
@@ -1129,14 +1157,14 @@ export class InteractiveMode implements InteractiveModeContext {
1129
1157
  this.#renderPlanPreview(planContent);
1130
1158
  const choice = await this.showHookSelector(
1131
1159
  "Plan mode - next step",
1132
- ["Approve and execute", "Refine plan", "Stay in plan mode"],
1160
+ ["Approve and execute", "Approve and keep context", "Refine plan", "Stay in plan mode"],
1133
1161
  {
1134
1162
  helpText: this.#getPlanReviewHelpText(),
1135
1163
  onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
1136
1164
  },
1137
1165
  );
1138
1166
 
1139
- if (choice === "Approve and execute") {
1167
+ if (choice === "Approve and execute" || choice === "Approve and keep context") {
1140
1168
  const finalPlanFilePath = details.finalPlanFilePath || planFilePath;
1141
1169
  try {
1142
1170
  const latestPlanContent = await this.#readPlanFile(planFilePath);
@@ -1144,7 +1172,11 @@ export class InteractiveMode implements InteractiveModeContext {
1144
1172
  this.showError(`Plan file not found at ${planFilePath}`);
1145
1173
  return;
1146
1174
  }
1147
- await this.#approvePlan(latestPlanContent, { planFilePath, finalPlanFilePath });
1175
+ await this.#approvePlan(latestPlanContent, {
1176
+ planFilePath,
1177
+ finalPlanFilePath,
1178
+ preserveContext: choice === "Approve and keep context",
1179
+ });
1148
1180
  } catch (error) {
1149
1181
  this.showError(
1150
1182
  `Failed to finalize approved plan: ${error instanceof Error ? error.message : String(error)}`,
@@ -6,7 +6,7 @@ You **MUST NOT**:
6
6
  - Run state-changing commands (git commit, npm install, etc.)
7
7
  - Make any system changes
8
8
 
9
- To implement: call `{{exitToolName}}` → user approves new session starts with full write access to execute the plan.
9
+ To implement: call `{{exitToolName}}` → user approves an execution option full write access is restored to execute the plan.
10
10
  You **MUST NOT** ask the user to exit plan mode for you; you **MUST** call `{{exitToolName}}` yourself.
11
11
  </critical>
12
12
 
@@ -21,7 +21,11 @@ You **MUST** create a plan at `{{planFilePath}}`.
21
21
  You **MUST** use `{{editToolName}}` for incremental updates; use `{{writeToolName}}` only for create/full replace.
22
22
 
23
23
  <caution>
24
- Plan execution runs in fresh context (session cleared). You **MUST** make the plan file self-contained: include requirements, decisions, key findings, remaining todos needed to continue without prior session history.
24
+ The approval selector includes:
25
+ - **Approve and execute**: starts execution in fresh context (session cleared).
26
+ - **Approve and keep context**: starts execution in this session, preserving exploration history.
27
+
28
+ You **MUST** still make the plan file self-contained: include requirements, decisions, key findings, and remaining todos needed to continue without prior session history.
25
29
  </caution>
26
30
 
27
31
  {{#if reentry}}
@@ -100,7 +104,7 @@ You **MUST** ask questions throughout. You **MUST NOT** make large assumptions a
100
104
  <critical>
101
105
  Your turn ends ONLY by:
102
106
  1. Using `{{askToolName}}` to gather information, OR
103
- 2. Calling `{{exitToolName}}` when ready — this triggers user approval, then a new implementation session with full tool access
107
+ 2. Calling `{{exitToolName}}` when ready — this triggers user approval, then implementation with full tool access
104
108
 
105
109
  You **MUST NOT** ask plan approval via text or `{{askToolName}}`; you **MUST** use `{{exitToolName}}`.
106
110
  You **MUST** keep going until complete.
@@ -3,6 +3,11 @@ Plan approved. You **MUST** execute it now.
3
3
  </critical>
4
4
 
5
5
  Finalized plan artifact: `{{finalPlanFilePath}}`
6
+ {{#if contextPreserved}}
7
+ Context was preserved for execution. Use the existing conversation history when it is useful, and treat the finalized plan as the source of truth if it conflicts with earlier exploration.
8
+ {{else}}
9
+ Execution may be running in fresh context. Treat the finalized plan as the source of truth.
10
+ {{/if}}
6
11
 
7
12
  ## Plan
8
13
 
@@ -1,34 +1,34 @@
1
- Search hidden MCP tool metadata when MCP tool discovery is enabled.
1
+ Search hidden tool metadata to discover and activate tools.
2
2
 
3
- Use this tool to discover MCP tools that are loaded into the session but not exposed to the model by default.
3
+ Use this tool when you need a capability that is not currently available in your active tool set. It searches all discoverable tools — including MCP tools and built-in tools that are hidden to save tokens.
4
4
 
5
5
  {{#if hasDiscoverableMCPServers}}Discoverable MCP servers in this session: {{#list discoverableMCPServerSummaries join=", "}}{{this}}{{/list}}.{{/if}}
6
- {{#if discoverableMCPToolCount}}Total discoverable MCP tools loaded: {{discoverableMCPToolCount}}.{{/if}}
6
+ {{#if discoverableMCPToolCount}}Total discoverable tools available: {{discoverableMCPToolCount}}.{{/if}}
7
7
  Input:
8
8
  - `query` — required natural-language or keyword query
9
9
  - `limit` — optional maximum number of tools to return and activate (default `8`)
10
10
 
11
11
  Behavior:
12
- - Searches hidden MCP tool metadata using BM25-style relevance ranking
13
- - Matches against MCP tool name, server name, description, and input schema keys
14
- - Activates the top matching MCP tools for the rest of the current session
15
- - Repeated searches add to the active MCP tool set; they do not remove earlier selections
16
- - Newly activated MCP tools become available before the next model call in the same overall turn
12
+ - Searches hidden tool metadata using BM25-style relevance ranking
13
+ - Matches against tool name, label, server name, description/summary, and input schema keys
14
+ - Activates the top matching tools for the rest of the current session
15
+ - Repeated searches add to the active tool set; they do not remove earlier selections
16
+ - Newly activated tools become available before the next model call in the same overall turn
17
17
 
18
18
  Notes:
19
19
  - If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools.
20
- - `query` is matched against MCP tool metadata fields:
20
+ - `query` is matched against tool metadata fields:
21
21
  - `name`
22
22
  - `label`
23
- - `server_name`
24
- - `mcp_tool_name`
25
- - `description`
23
+ - `server_name` (MCP tools)
24
+ - `mcp_tool_name` (MCP tools)
25
+ - `description` / `summary`
26
26
  - input schema property keys (`schema_keys`)
27
27
 
28
- This is not repository search, file search, or code search. Use it only for MCP tool discovery.
28
+ This is not repository search, file search, or code search. Use it only for tool discovery.
29
29
 
30
30
  Returns JSON with:
31
31
  - `query`
32
- - `activated_tools` — MCP tools activated by this search call
32
+ - `activated_tools` — tools activated by this search call
33
33
  - `match_count` — number of ranked matches returned by the search
34
34
  - `total_tools`
@@ -1,5 +1,6 @@
1
1
  Manages a phased task list. Pass `ops`: a flat array of operations.
2
2
  The next pending task is auto-promoted to `in_progress` after each completion.
3
+ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, and `note`. `pending` is a task status, not an `op`; leave not-yet-started tasks implicit in `init`/`append` lists.
3
4
 
4
5
  ## Operations
5
6