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.
- package/README.md +82 -0
- package/dist/resources/extensions/mcp-client/index.js +230 -54
- package/dist/resources/extensions/mcp-client/mcp-manager-component.js +220 -0
- package/dist/resources/extensions/slash-commands/plan.js +72 -18
- package/dist/resources/extensions/subagent/agents.js +7 -0
- package/dist/resources/extensions/subagent/index.js +25 -8
- package/dist/resources/extensions/subagent/model-resolution.js +1 -0
- package/dist/resources/extensions/usage/index.js +34 -2
- package/dist/resources/extensions/voice/index.js +1 -0
- package/dist/resources/extensions/voice/push-to-talk.js +2 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js +72 -0
- package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +4 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +29 -2
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +1 -0
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tool-priority.js +1 -1
- package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -1
- package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/main.js +1 -0
- package/packages/pi-coding-agent/dist/main.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +104 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +39 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +135 -18
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +2 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +21 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +147 -9
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +51 -13
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +112 -18
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +4 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +34 -4
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +3 -0
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/agent-session.context-usage.test.ts +87 -0
- package/packages/pi-coding-agent/src/core/agent-session.ts +40 -2
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +1 -0
- package/packages/pi-coding-agent/src/core/extensions/types.ts +3 -0
- package/packages/pi-coding-agent/src/core/tool-priority.ts +1 -1
- package/packages/pi-coding-agent/src/main.ts +1 -0
- package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +129 -2
- package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +158 -18
- package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +18 -1
- package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +164 -10
- package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +60 -13
- package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +123 -20
- package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +1 -0
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +34 -4
- package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +4 -0
- package/pkg/package.json +1 -1
- package/src/resources/extensions/mcp-client/index.ts +259 -58
- package/src/resources/extensions/mcp-client/mcp-manager-component.ts +256 -0
- package/src/resources/extensions/mcp-client/tests/mcp-manager-component.test.ts +141 -0
- package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +32 -0
- package/src/resources/extensions/slash-commands/plan.ts +76 -19
- package/src/resources/extensions/subagent/agents.ts +9 -0
- package/src/resources/extensions/subagent/index.ts +30 -8
- package/src/resources/extensions/subagent/model-resolution.ts +1 -0
- package/src/resources/extensions/usage/index.ts +40 -2
- package/src/resources/extensions/voice/index.ts +1 -0
- package/src/resources/extensions/voice/push-to-talk.ts +3 -0
- 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 {
|
|
195
|
-
return parsed.
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|