lsd-pi 1.3.7 → 1.3.10

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 (92) hide show
  1. package/README.md +82 -0
  2. package/dist/resources/extensions/mcp-client/index.js +230 -54
  3. package/dist/resources/extensions/mcp-client/mcp-manager-component.js +220 -0
  4. package/dist/resources/extensions/slash-commands/plan.js +72 -18
  5. package/dist/resources/extensions/subagent/agents.js +7 -0
  6. package/dist/resources/extensions/subagent/index.js +25 -8
  7. package/dist/resources/extensions/subagent/model-resolution.js +1 -0
  8. package/dist/resources/extensions/usage/index.js +34 -2
  9. package/dist/resources/extensions/voice/index.js +1 -0
  10. package/dist/resources/extensions/voice/push-to-talk.js +2 -0
  11. package/package.json +1 -1
  12. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts +2 -0
  13. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts.map +1 -0
  14. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js +72 -0
  15. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js.map +1 -0
  16. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +4 -0
  17. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  18. package/packages/pi-coding-agent/dist/core/agent-session.js +29 -2
  19. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  20. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  21. package/packages/pi-coding-agent/dist/core/extensions/runner.js +1 -0
  22. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  23. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  24. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/tool-priority.js +1 -1
  27. package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  29. package/packages/pi-coding-agent/dist/main.js +1 -0
  30. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +104 -2
  32. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +39 -2
  34. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  35. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +135 -18
  36. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  37. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +2 -0
  38. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  39. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -1
  40. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  41. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +21 -2
  42. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
  43. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +147 -9
  44. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
  45. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +51 -13
  46. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +112 -18
  49. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +1 -0
  52. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +4 -0
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
  57. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +34 -4
  59. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  60. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  61. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +3 -0
  62. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  63. package/packages/pi-coding-agent/package.json +1 -1
  64. package/packages/pi-coding-agent/src/core/agent-session.context-usage.test.ts +87 -0
  65. package/packages/pi-coding-agent/src/core/agent-session.ts +40 -2
  66. package/packages/pi-coding-agent/src/core/extensions/runner.ts +1 -0
  67. package/packages/pi-coding-agent/src/core/extensions/types.ts +3 -0
  68. package/packages/pi-coding-agent/src/core/tool-priority.ts +1 -1
  69. package/packages/pi-coding-agent/src/main.ts +1 -0
  70. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +129 -2
  71. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +158 -18
  72. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +18 -1
  73. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +164 -10
  74. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +60 -13
  75. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +123 -20
  76. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +1 -0
  77. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  78. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +34 -4
  79. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +4 -0
  80. package/pkg/package.json +1 -1
  81. package/src/resources/extensions/mcp-client/index.ts +259 -58
  82. package/src/resources/extensions/mcp-client/mcp-manager-component.ts +256 -0
  83. package/src/resources/extensions/mcp-client/tests/mcp-manager-component.test.ts +141 -0
  84. package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +32 -0
  85. package/src/resources/extensions/slash-commands/plan.ts +76 -19
  86. package/src/resources/extensions/subagent/agents.ts +9 -0
  87. package/src/resources/extensions/subagent/index.ts +30 -8
  88. package/src/resources/extensions/subagent/model-resolution.ts +1 -0
  89. package/src/resources/extensions/usage/index.ts +40 -2
  90. package/src/resources/extensions/voice/index.ts +1 -0
  91. package/src/resources/extensions/voice/push-to-talk.ts +3 -0
  92. package/src/resources/extensions/voice/tests/push-to-talk.test.ts +6 -0
@@ -0,0 +1,256 @@
1
+ import type { Theme } from "@gsd/pi-coding-agent";
2
+ import { Key, SelectList, type SelectItem, matchesKey, truncateToWidth } from "@gsd/pi-tui";
3
+
4
+ export interface McpManagerServerInfo {
5
+ name: string;
6
+ enabled: boolean;
7
+ connected: boolean;
8
+ transport: string;
9
+ toolCount: number;
10
+ sourceLabel: string;
11
+ }
12
+
13
+ export interface McpManagerCallbacks {
14
+ getServers: () => McpManagerServerInfo[];
15
+ onToggle: (name: string) => Promise<McpManagerServerInfo | null>;
16
+ onInspect: (name: string) => Promise<string>;
17
+ onReconnect: (name: string) => Promise<McpManagerServerInfo | null>;
18
+ onClose: () => void;
19
+ requestRender: () => void;
20
+ }
21
+
22
+ type ViewMode = "list" | "inspect";
23
+
24
+ function getSelectListTheme(theme: Theme) {
25
+ return {
26
+ selectedPrefix: (text: string) => theme.fg("accent", text),
27
+ selectedText: (text: string) => theme.fg("accent", text),
28
+ description: (text: string) => theme.fg("muted", text),
29
+ scrollInfo: (text: string) => theme.fg("dim", text),
30
+ noMatch: (text: string) => theme.fg("warning", text),
31
+ };
32
+ }
33
+
34
+ function serversToItems(servers: McpManagerServerInfo[]): SelectItem[] {
35
+ return servers.map((server) => ({
36
+ value: server.name,
37
+ label: server.name,
38
+ description: [
39
+ server.enabled ? "enabled" : "disabled",
40
+ server.transport,
41
+ server.connected ? "● connected" : "○ offline",
42
+ `${server.toolCount} tools`,
43
+ server.sourceLabel || undefined,
44
+ ].filter(Boolean).join(" "),
45
+ }));
46
+ }
47
+
48
+ export class McpManagerComponent {
49
+ private readonly theme: Theme;
50
+ private readonly callbacks: McpManagerCallbacks;
51
+ private selectList: SelectList;
52
+ private mode: ViewMode = "list";
53
+ private inspectServerName = "";
54
+ private inspectLines: string[] = [];
55
+ private inspectScrollOffset = 0;
56
+ private statusMessage = "";
57
+ private busy = false;
58
+ private statusTimeout: ReturnType<typeof setTimeout> | null = null;
59
+
60
+ constructor(callbacks: McpManagerCallbacks, theme: Theme) {
61
+ this.callbacks = callbacks;
62
+ this.theme = theme;
63
+ this.selectList = new SelectList([], 8, getSelectListTheme(theme));
64
+ this.bindSelectList();
65
+ this.refreshList();
66
+ }
67
+
68
+ invalidate(): void {
69
+ this.selectList.invalidate();
70
+ }
71
+
72
+ dispose(): void {
73
+ if (this.statusTimeout) {
74
+ clearTimeout(this.statusTimeout);
75
+ this.statusTimeout = null;
76
+ }
77
+ }
78
+
79
+ getMode(): ViewMode {
80
+ return this.mode;
81
+ }
82
+
83
+ handleInput(data: string): void {
84
+ if (this.mode === "inspect") {
85
+ this.handleInspectInput(data);
86
+ return;
87
+ }
88
+
89
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
90
+ this.callbacks.onClose();
91
+ return;
92
+ }
93
+
94
+ if (data === "i") {
95
+ void this.handleInspect();
96
+ return;
97
+ }
98
+
99
+ if (data === "r") {
100
+ void this.handleReconnect();
101
+ return;
102
+ }
103
+
104
+ this.selectList.handleInput(data);
105
+ this.callbacks.requestRender();
106
+ }
107
+
108
+ render(width: number): string[] {
109
+ const lines: string[] = [];
110
+ const add = (line = "") => lines.push(truncateToWidth(line, width));
111
+ const divider = this.theme.fg("border", "─".repeat(Math.max(width, 1)));
112
+
113
+ add(divider);
114
+ if (this.mode === "inspect") {
115
+ add(
116
+ this.theme.bold(this.theme.fg("toolTitle", ` MCP Tools · ${this.inspectServerName}`)) +
117
+ this.theme.fg("dim", " esc/q: back ↑↓/pgup/pgdn/home/end: scroll"),
118
+ );
119
+ add("");
120
+
121
+ const bodyHeight = Math.max(8, width > 0 ? 18 : 8);
122
+ const maxOffset = Math.max(0, this.inspectLines.length - bodyHeight);
123
+ this.inspectScrollOffset = Math.max(0, Math.min(this.inspectScrollOffset, maxOffset));
124
+ const visibleLines = this.inspectLines.slice(this.inspectScrollOffset, this.inspectScrollOffset + bodyHeight);
125
+ for (const line of visibleLines) add(line);
126
+ if (visibleLines.length === 0) add(this.theme.fg("dim", " No tool information"));
127
+ add("");
128
+ add(divider);
129
+ add(this.theme.fg("dim", ` ${this.inspectLines.length} lines`));
130
+ return lines;
131
+ }
132
+
133
+ add(
134
+ this.theme.bold(this.theme.fg("toolTitle", " MCP Servers")) +
135
+ this.theme.fg("dim", " ↑↓ navigate enter: toggle i: inspect r: reconnect esc: close"),
136
+ );
137
+ add("");
138
+ lines.push(...this.selectList.render(width));
139
+ add("");
140
+ add(divider);
141
+ const servers = this.callbacks.getServers();
142
+ const enabled = servers.filter((server) => server.enabled).length;
143
+ let footer = this.theme.fg("dim", ` ${servers.length} servers · ${enabled} enabled`);
144
+ if (this.busy) footer += this.theme.fg("accent", " · working…");
145
+ if (this.statusMessage) footer += this.theme.fg("accent", ` — ${this.statusMessage}`);
146
+ add(footer);
147
+ return lines;
148
+ }
149
+
150
+ private bindSelectList(): void {
151
+ this.selectList.onSelect = () => {
152
+ void this.handleToggle();
153
+ };
154
+ this.selectList.onCancel = () => {
155
+ this.callbacks.onClose();
156
+ };
157
+ }
158
+
159
+ private refreshList(preferredName?: string): void {
160
+ const currentSelected = preferredName ?? this.selectList.getSelectedItem()?.value;
161
+ this.selectList = new SelectList(
162
+ serversToItems(this.callbacks.getServers()),
163
+ 8,
164
+ getSelectListTheme(this.theme),
165
+ );
166
+ this.bindSelectList();
167
+ if (currentSelected) {
168
+ const items = this.callbacks.getServers();
169
+ const index = items.findIndex((item) => item.name === currentSelected);
170
+ if (index >= 0) this.selectList.setSelectedIndex(index);
171
+ }
172
+ this.callbacks.requestRender();
173
+ }
174
+
175
+ private setStatus(message: string): void {
176
+ this.statusMessage = message;
177
+ this.callbacks.requestRender();
178
+ if (this.statusTimeout) clearTimeout(this.statusTimeout);
179
+ if (!message) return;
180
+ this.statusTimeout = setTimeout(() => {
181
+ this.statusMessage = "";
182
+ this.callbacks.requestRender();
183
+ }, 3000);
184
+ this.statusTimeout.unref?.();
185
+ }
186
+
187
+ private getSelectedName(): string | undefined {
188
+ return this.selectList.getSelectedItem()?.value;
189
+ }
190
+
191
+ private async runBusy(task: () => Promise<void>): Promise<void> {
192
+ if (this.busy) return;
193
+ this.busy = true;
194
+ this.callbacks.requestRender();
195
+ try {
196
+ await task();
197
+ } finally {
198
+ this.busy = false;
199
+ this.callbacks.requestRender();
200
+ }
201
+ }
202
+
203
+ private async handleToggle(): Promise<void> {
204
+ const name = this.getSelectedName();
205
+ if (!name) return;
206
+ await this.runBusy(async () => {
207
+ this.setStatus(`Toggling ${name}...`);
208
+ const updated = await this.callbacks.onToggle(name);
209
+ this.refreshList(updated?.name ?? name);
210
+ if (updated) {
211
+ this.setStatus(`${updated.name}: ${updated.enabled ? "enabled" : "disabled"}`);
212
+ }
213
+ });
214
+ }
215
+
216
+ private async handleInspect(): Promise<void> {
217
+ const name = this.getSelectedName();
218
+ if (!name) return;
219
+ await this.runBusy(async () => {
220
+ this.setStatus(`Loading tools for ${name}...`);
221
+ const text = await this.callbacks.onInspect(name);
222
+ this.inspectServerName = name;
223
+ this.inspectLines = text.split("\n");
224
+ this.inspectScrollOffset = 0;
225
+ this.mode = "inspect";
226
+ this.setStatus("");
227
+ });
228
+ }
229
+
230
+ private async handleReconnect(): Promise<void> {
231
+ const name = this.getSelectedName();
232
+ if (!name) return;
233
+ await this.runBusy(async () => {
234
+ this.setStatus(`Reconnecting ${name}...`);
235
+ const updated = await this.callbacks.onReconnect(name);
236
+ this.refreshList(updated?.name ?? name);
237
+ this.setStatus(updated ? `${updated.name}: reconnected` : `${name}: reconnect failed`);
238
+ });
239
+ }
240
+
241
+ private handleInspectInput(data: string): void {
242
+ if (matchesKey(data, Key.escape) || data === "q") {
243
+ this.mode = "list";
244
+ this.callbacks.requestRender();
245
+ return;
246
+ }
247
+ const page = 12;
248
+ if (matchesKey(data, Key.up)) this.inspectScrollOffset -= 1;
249
+ else if (matchesKey(data, Key.down)) this.inspectScrollOffset += 1;
250
+ else if (matchesKey(data, Key.pageUp)) this.inspectScrollOffset -= page;
251
+ else if (matchesKey(data, Key.pageDown)) this.inspectScrollOffset += page;
252
+ else if (matchesKey(data, Key.home)) this.inspectScrollOffset = 0;
253
+ else if (matchesKey(data, Key.end)) this.inspectScrollOffset = Number.MAX_SAFE_INTEGER;
254
+ this.callbacks.requestRender();
255
+ }
256
+ }
@@ -0,0 +1,141 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import stripAnsi from "strip-ansi";
4
+
5
+ import { McpManagerComponent, type McpManagerCallbacks, type McpManagerServerInfo } from "../mcp-manager-component.js";
6
+
7
+ function createTheme() {
8
+ return {
9
+ fg: (_color: string, text: string) => text,
10
+ bold: (text: string) => text,
11
+ } as any;
12
+ }
13
+
14
+ function createCallbacks(overrides?: Partial<McpManagerCallbacks> & { servers?: McpManagerServerInfo[] }) {
15
+ let servers = overrides?.servers ?? [];
16
+ let closed = false;
17
+ let renderCount = 0;
18
+ const callbacks: McpManagerCallbacks = {
19
+ getServers: () => servers,
20
+ onToggle: async () => null,
21
+ onInspect: async () => "",
22
+ onReconnect: async () => null,
23
+ onClose: () => {
24
+ closed = true;
25
+ },
26
+ requestRender: () => {
27
+ renderCount += 1;
28
+ },
29
+ ...overrides,
30
+ };
31
+ return {
32
+ callbacks,
33
+ getClosed: () => closed,
34
+ getRenderCount: () => renderCount,
35
+ setServers: (next: McpManagerServerInfo[]) => {
36
+ servers = next;
37
+ },
38
+ };
39
+ }
40
+
41
+ test("McpManagerComponent renders server list", () => {
42
+ const { callbacks } = createCallbacks({
43
+ servers: [{
44
+ name: "alpha",
45
+ enabled: true,
46
+ connected: true,
47
+ transport: "stdio",
48
+ toolCount: 3,
49
+ sourceLabel: "project",
50
+ }],
51
+ });
52
+ const component = new McpManagerComponent(callbacks, createTheme());
53
+ const rendered = stripAnsi(component.render(100).join("\n"));
54
+ assert.match(rendered, /MCP Servers/);
55
+ assert.match(rendered, /alpha/);
56
+ assert.match(rendered, /3 tools/);
57
+ });
58
+
59
+ test("McpManagerComponent toggles and refreshes latest server state", async () => {
60
+ const state: McpManagerServerInfo = {
61
+ name: "alpha",
62
+ enabled: true,
63
+ connected: true,
64
+ transport: "stdio",
65
+ toolCount: 2,
66
+ sourceLabel: "project",
67
+ };
68
+ const harness = createCallbacks({
69
+ servers: [state],
70
+ onToggle: async () => {
71
+ const next = { ...state, enabled: false, connected: false, toolCount: 0 };
72
+ harness.setServers([next]);
73
+ return next;
74
+ },
75
+ });
76
+ const component = new McpManagerComponent(harness.callbacks, createTheme());
77
+ await (component as any).handleToggle();
78
+ const rendered = stripAnsi(component.render(100).join("\n"));
79
+ assert.match(rendered, /disabled/);
80
+ assert.match(rendered, /0 tools/);
81
+ });
82
+
83
+ test("McpManagerComponent inspects tools and switches mode", async () => {
84
+ const { callbacks } = createCallbacks({
85
+ servers: [{
86
+ name: "alpha",
87
+ enabled: true,
88
+ connected: true,
89
+ transport: "stdio",
90
+ toolCount: 2,
91
+ sourceLabel: "project",
92
+ }],
93
+ onInspect: async () => "alpha — 2 tools\n\n## search\nFind stuff",
94
+ });
95
+ const component = new McpManagerComponent(callbacks, createTheme());
96
+ await (component as any).handleInspect();
97
+ assert.equal(component.getMode(), "inspect");
98
+ const rendered = stripAnsi(component.render(100).join("\n"));
99
+ assert.match(rendered, /MCP Tools · alpha/);
100
+ assert.match(rendered, /## search/);
101
+ });
102
+
103
+ test("McpManagerComponent reconnect refreshes server details", async () => {
104
+ const current: McpManagerServerInfo = {
105
+ name: "alpha",
106
+ enabled: true,
107
+ connected: false,
108
+ transport: "stdio",
109
+ toolCount: 0,
110
+ sourceLabel: "project",
111
+ };
112
+ const harness = createCallbacks({
113
+ servers: [current],
114
+ onReconnect: async () => {
115
+ const next = { ...current, connected: true, toolCount: 4 };
116
+ harness.setServers([next]);
117
+ return next;
118
+ },
119
+ });
120
+ const component = new McpManagerComponent(harness.callbacks, createTheme());
121
+ await (component as any).handleReconnect();
122
+ const rendered = stripAnsi(component.render(100).join("\n"));
123
+ assert.match(rendered, /4 tools/);
124
+ assert.match(rendered, /connected/);
125
+ });
126
+
127
+ test("McpManagerComponent closes on escape in list mode", () => {
128
+ const harness = createCallbacks({
129
+ servers: [{
130
+ name: "alpha",
131
+ enabled: true,
132
+ connected: true,
133
+ transport: "stdio",
134
+ toolCount: 1,
135
+ sourceLabel: "project",
136
+ }],
137
+ });
138
+ const component = new McpManagerComponent(harness.callbacks, createTheme());
139
+ component.handleInput("\x1b");
140
+ assert.equal(harness.getClosed(), true);
141
+ });
@@ -53,3 +53,35 @@ test("#3029: getOrConnect normalizes name for connection cache lookup", () => {
53
53
  "getOrConnect should use config.name (canonical) as the connections cache key",
54
54
  );
55
55
  });
56
+
57
+ test("enabled MCP servers are warmed up on session start", () => {
58
+ assert.match(
59
+ source,
60
+ /pi\.on\("session_start", async \(_event, ctx\) => {[\s\S]*?warmupEnabledServers\(/,
61
+ "session_start should trigger MCP autoconnect warmup for enabled servers",
62
+ );
63
+ });
64
+
65
+ test("warmupEnabledServers preloads tool schemas during autoconnect", () => {
66
+ assert.ok(
67
+ source.includes("async function warmupServer(") &&
68
+ source.includes("toolCache.set(canonicalName, tools)") &&
69
+ source.includes("warmupEnabledServers()"),
70
+ "warmup path should list tools and populate tool cache during startup",
71
+ );
72
+ });
73
+
74
+ test("global MCP config path is supported", () => {
75
+ assert.ok(
76
+ source.includes('join(homedir(), ".lsd", "mcp.json")'),
77
+ "readConfigs should include ~/.lsd/mcp.json",
78
+ );
79
+ });
80
+
81
+ test("bare /mcp opens interactive manager when custom UI exists", () => {
82
+ assert.match(
83
+ source,
84
+ /if \(!args\.trim\(\) && typeof ctx\.ui\.custom === "function"\) {[\s\S]*?openMcpManager\(ctx\)/,
85
+ "bare /mcp should open the manager UI when custom UI is available",
86
+ );
87
+ });
@@ -191,8 +191,8 @@ function readAutoSwitchPlanModelSetting(): boolean {
191
191
  const settingsPath = join(getAgentDir(), "settings.json");
192
192
  if (!existsSync(settingsPath)) return false;
193
193
  const raw = readFileSync(settingsPath, "utf-8");
194
- const parsed = JSON.parse(raw) as { autoSwitchPlanModel?: unknown };
195
- return parsed.autoSwitchPlanModel === true;
194
+ const parsed = JSON.parse(raw) as { planModeAutoSwitchModel?: unknown };
195
+ return parsed.planModeAutoSwitchModel === true;
196
196
  } catch {
197
197
  return false;
198
198
  }
@@ -340,8 +340,8 @@ async function setModelIfNeeded(pi: ExtensionAPI, ctx: any, modelRef: ModelRef |
340
340
  return true;
341
341
  }
342
342
 
343
- function buildExecutionKickoffMessage(options: { permissionMode: RestorablePermissionMode; executeWithSubagent?: boolean }): string {
344
- const { permissionMode, executeWithSubagent = false } = options;
343
+ function buildExecutionKickoffMessage(options: { permissionMode: RestorablePermissionMode; executeWithSubagent?: boolean; executionNote?: string }): string {
344
+ const { permissionMode, executeWithSubagent = false, executionNote } = options;
345
345
  const task = state.task.trim();
346
346
 
347
347
  if (!executeWithSubagent) {
@@ -350,6 +350,10 @@ function buildExecutionKickoffMessage(options: { permissionMode: RestorablePermi
350
350
  ];
351
351
  if (task) details.push(`Original task: ${task}`);
352
352
  if (state.latestPlanPath) details.push(`Use the approved plan artifact at ${state.latestPlanPath} as the execution plan.`);
353
+ if (executionNote) details.push(`User execution note: ${executionNote}`);
354
+ details.push(
355
+ "After implementation: guide the user through verification by presenting a concise checklist based on the plan's Acceptance Criteria and Verification Plan. Run applicable checks (build, lint, tests) and report results.",
356
+ );
353
357
  return details.join(" ");
354
358
  }
355
359
 
@@ -366,6 +370,16 @@ function buildExecutionKickoffMessage(options: { permissionMode: RestorablePermi
366
370
  ];
367
371
  if (task) details.push(`Original task: ${task}`);
368
372
  if (state.latestPlanPath) details.push(`Primary plan artifact: ${state.latestPlanPath}`);
373
+ if (executionNote) {
374
+ details.push(`User execution note: ${executionNote}`);
375
+ // If the note contains a model request, surface it explicitly so the agent
376
+ // doesn't silently drop it. The model override will be resolved by
377
+ // normalizeSubagentModel when the subagent tool is invoked.
378
+ const modelMatch = executionNote.match(/\b(?:model|use\s+model)\s+["']?([\w.-]+(?:\/[\w.-]+)?)["']?\b/i);
379
+ if (modelMatch?.[1]) {
380
+ details.push(`The user explicitly requested model "${modelMatch[1]}". You MUST pass model="${modelMatch[1]}" in the subagent tool call. If normalizeSubagentModel cannot resolve this model, report the error to the user instead of silently falling back.`);
381
+ }
382
+ }
369
383
  details.push(
370
384
  "Important: if the plan is large and you estimate it would exceed a single subagent's context window (~200k tokens), " +
371
385
  "split execution across multiple sequential subagents instead of one. " +
@@ -375,7 +389,8 @@ function buildExecutionKickoffMessage(options: { permissionMode: RestorablePermi
375
389
  );
376
390
  details.push(
377
391
  "After all subagents complete: (1) do a quick review of the implementation — check that the plan steps were actually carried out, spot obvious issues or missed pieces, and verify the code compiles/passes lint if applicable. " +
378
- "(2) Then summarize what was done, what (if anything) needs follow-up, and flag any concerns found during review.",
392
+ "(2) Then guide the user through verification: present a concise checklist based on the plan's Acceptance Criteria and Verification Plan sections. Run applicable checks (build, lint, tests) and report results. " +
393
+ "(3) Summarize what was done, what (if anything) needs follow-up, and flag any concerns found during review.",
379
394
  );
380
395
  return details.join(" ");
381
396
  }
@@ -417,11 +432,12 @@ async function approvePlan(
417
432
  ctx: any,
418
433
  permissionMode: RestorablePermissionMode,
419
434
  executeWithSubagent = false,
435
+ executionNote?: string,
420
436
  ): Promise<void> {
421
- const reasoningModel = parseQualifiedModelRef(readPlanModeReasoningModel());
422
- if (reasoningModel) {
423
- await setModelIfNeeded(pi, ctx, reasoningModel);
424
- }
437
+ // Do NOT switch to reasoning model during execution.
438
+ // The reasoning model is only for plan-mode investigation, not execution.
439
+ // If a coding model is configured and we're using a subagent, the explicit
440
+ // model="<planModeCodingModel>" in the kickoff message will handle it.
425
441
 
426
442
  state = {
427
443
  ...state,
@@ -434,7 +450,7 @@ async function approvePlan(
434
450
  // subagent tool with the default session model BEFORE it ever sees the
435
451
  // explicit model="<planModeCodingModel>" instruction. Steering ensures the
436
452
  // configured plan-mode coding model reaches the subagent invocation.
437
- await pi.sendUserMessage(buildExecutionKickoffMessage({ permissionMode, executeWithSubagent }), { deliverAs: "steer" });
453
+ await pi.sendUserMessage(buildExecutionKickoffMessage({ permissionMode, executeWithSubagent, executionNote }), { deliverAs: "steer" });
438
454
  }
439
455
 
440
456
  async function cancelPlan(pi: ExtensionAPI, ctx: any, clearTask = true): Promise<RestorablePermissionMode> {
@@ -448,10 +464,12 @@ async function cancelPlan(pi: ExtensionAPI, ctx: any, clearTask = true): Promise
448
464
  function buildPlanModeSystemPrompt(): string {
449
465
  const details: string[] = [
450
466
  "You are currently in plan mode.",
467
+ "Terse output. All technical substance stays. Only fluff dies. Fragments OK.",
451
468
  "Investigate, clarify scope, and produce a persisted execution plan before making source changes.",
452
469
  "If requirements are ambiguous or constraints are missing, ask concise clarifying questions before drafting or saving a plan.",
453
470
  `Before writing or updating a plan artifact, make sure your confidence is at least ${MIN_PLAN_CONFIDENCE}/10. If confidence is lower, investigate more or ask clarifying questions first.`,
454
471
  "Include an explicit confidence line in every saved plan, for example: \"Confidence: 8/10\" or higher.",
472
+ "Every saved plan MUST include explicit \"Acceptance Criteria\" and \"Verification Plan\" sections. Plans missing these sections will be rejected for approval.",
455
473
  "When adjusting an existing saved plan, prefer the edit tool for targeted changes. Rewrite the whole file only when the structure changes substantially or an exact edit is impractical.",
456
474
  "Do not modify source files or run side-effect commands while plan mode is active.",
457
475
  "Persist plan artifacts under .lsd/plan/.",
@@ -502,6 +520,32 @@ function buildApprovalDialogInstructions(): string {
502
520
  return buildApprovalActionInstructions();
503
521
  }
504
522
 
523
+ /** Required heading patterns for plan artifacts. Matches common variants. */
524
+ const REQUIRED_PLAN_SECTIONS: Array<{ pattern: RegExp; label: string }> = [
525
+ { pattern: /\b(acceptance\s*criteria|success\s*criteria|done\s*criteria)\b/i, label: "Acceptance Criteria" },
526
+ { pattern: /\b(verification\s*(plan|steps|strategy)|how\s+to\s+verify|testing\s*plan)\b/i, label: "Verification Plan" },
527
+ ];
528
+
529
+ function validatePlanArtifact(markdown: string): { valid: boolean; missing: string[] } {
530
+ const missing: string[] = [];
531
+ for (const section of REQUIRED_PLAN_SECTIONS) {
532
+ if (!section.pattern.test(markdown)) {
533
+ missing.push(section.label);
534
+ }
535
+ }
536
+ return { valid: missing.length === 0, missing };
537
+ }
538
+
539
+ function buildPlanValidationSteeringMessage(planPath: string, missing: string[]): string {
540
+ const missingList = missing.map((m) => `- **${m}**`).join("\n");
541
+ return [
542
+ `Plan artifact saved at ${planPath}.`,
543
+ `The plan is missing required sections before it can be approved for implementation:`,
544
+ missingList,
545
+ `Please revise the plan to include these sections, then re-save. Do not ask for approval until all required sections are present.`,
546
+ ].join("\n\n");
547
+ }
548
+
505
549
  function buildApprovalSteeringMessage(planPath: string): string {
506
550
  return [
507
551
  `Plan artifact saved at ${planPath}.`,
@@ -739,13 +783,24 @@ export default function planCommand(pi: ExtensionAPI) {
739
783
  }
740
784
 
741
785
  const planMarkdown = readPlanArtifact(path);
742
- pi.sendMessage({
743
- customType: "plan-mode-preview",
744
- content: buildPlanPreviewMessage(path, planMarkdown),
745
- display: true,
746
- });
747
- ctx.ui?.notify?.("/plan to show plan", "info");
748
- pi.sendUserMessage(buildApprovalSteeringMessage(path), { deliverAs: "steer" });
786
+ if (!planMarkdown) {
787
+ ctx.ui?.notify?.("Plan artifact could not be read for validation", "warning");
788
+ pi.sendUserMessage(buildApprovalSteeringMessage(path), { deliverAs: "steer" });
789
+ } else {
790
+ const validation = validatePlanArtifact(planMarkdown);
791
+ if (!validation.valid) {
792
+ ctx.ui?.notify?.("Plan missing required sections — see guidance below", "warning");
793
+ pi.sendUserMessage(buildPlanValidationSteeringMessage(path, validation.missing), { deliverAs: "steer" });
794
+ } else {
795
+ pi.sendMessage({
796
+ customType: "plan-mode-preview",
797
+ content: buildPlanPreviewMessage(path, planMarkdown),
798
+ display: true,
799
+ });
800
+ ctx.ui?.notify?.("/plan to show plan", "info");
801
+ pi.sendUserMessage(buildApprovalSteeringMessage(path), { deliverAs: "steer" });
802
+ }
803
+ }
749
804
  }
750
805
  return;
751
806
  }
@@ -808,12 +863,13 @@ export default function planCommand(pi: ExtensionAPI) {
808
863
  permissionMode: DEFAULT_APPROVAL_PERMISSION_MODE,
809
864
  executeWithSubagent: false,
810
865
  };
866
+ const permissionNote = getAnswerNote(permissionAnswer);
811
867
  state = { ...state, targetPermissionMode: executionMode.permissionMode };
812
868
  if (executionMode.executeWithSubagent) {
813
869
  const modeLabel = executionMode.permissionMode === "danger-full-access" ? "bypass" : "auto";
814
870
  ctx.ui?.notify?.(`Plan approved: subagent(${modeLabel})`, "info");
815
871
  }
816
- await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent);
872
+ await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent, permissionNote);
817
873
  return;
818
874
  }
819
875
 
@@ -833,12 +889,13 @@ export default function planCommand(pi: ExtensionAPI) {
833
889
  permissionMode: DEFAULT_APPROVAL_PERMISSION_MODE,
834
890
  executeWithSubagent: false,
835
891
  };
892
+ const actionNote = getAnswerNote(actionAnswer);
836
893
  state = { ...state, targetPermissionMode: executionMode.permissionMode };
837
894
  if (executionMode.executeWithSubagent) {
838
895
  const modeLabel = executionMode.permissionMode === "danger-full-access" ? "bypass" : "auto";
839
896
  ctx.ui?.notify?.(`Plan approved: subagent(${modeLabel})`, "info");
840
897
  }
841
- await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent);
898
+ await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent, actionNote);
842
899
  return;
843
900
  }
844
901
 
@@ -9,6 +9,9 @@ import { getAgentDir, parseFrontmatter } from "@gsd/pi-coding-agent";
9
9
 
10
10
  const PROJECT_AGENT_DIR_CANDIDATES = [".lsd", ".gsd", ".pi"] as const;
11
11
 
12
+ /** Fixed read-only tool set for the reserved `scout` agent. */
13
+ const SCOUT_ALLOWED_TOOLS = ["read", "lsp", "grep", "find", "ls"] as const;
14
+
12
15
  export type AgentScope = "user" | "project" | "both";
13
16
 
14
17
  export interface AgentConfig {
@@ -143,6 +146,12 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
143
146
  addAgents(projectAgents);
144
147
  }
145
148
 
149
+ // Enforce reserved agent tool policies — scout is always read-only
150
+ const scout = agentMap.get("scout");
151
+ if (scout) {
152
+ scout.tools = [...SCOUT_ALLOWED_TOOLS];
153
+ }
154
+
146
155
  return { agents: Array.from(agentMap.values()), projectAgentsDir };
147
156
  }
148
157