@dyyz1993/pi-coding-agent 0.74.46 → 0.74.48

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 (49) hide show
  1. package/dist/core/agent-session.d.ts.map +1 -1
  2. package/dist/core/agent-session.js +16 -0
  3. package/dist/core/agent-session.js.map +1 -1
  4. package/dist/core/session-manager.d.ts +28 -1
  5. package/dist/core/session-manager.d.ts.map +1 -1
  6. package/dist/core/session-manager.js +89 -10
  7. package/dist/core/session-manager.js.map +1 -1
  8. package/dist/extensions/ask-tools/index.ts +45 -0
  9. package/dist/extensions/auto-memory/__tests__/extract-result.test.ts +42 -0
  10. package/dist/extensions/auto-memory/__tests__/prefetch-history.test.ts +136 -0
  11. package/dist/extensions/auto-memory/__tests__/prompts.test.ts +29 -0
  12. package/dist/extensions/auto-memory/__tests__/skip-rules.test.ts +366 -0
  13. package/dist/extensions/auto-memory/contract.d.ts +16 -0
  14. package/dist/extensions/auto-memory/contract.d.ts.map +1 -1
  15. package/dist/extensions/auto-memory/contract.js.map +1 -1
  16. package/dist/extensions/auto-memory/contract.ts +16 -0
  17. package/dist/extensions/auto-memory/index.ts +134 -13
  18. package/dist/extensions/auto-memory/prompts.ts +10 -0
  19. package/dist/extensions/auto-memory/skip-rules.ts +2 -0
  20. package/dist/extensions/auto-session-title/index.ts +2 -0
  21. package/dist/extensions/bash-ext/index.ts +855 -845
  22. package/dist/extensions/claude-hooks-compat/index.ts +12 -7
  23. package/dist/extensions/compaction-manager/index.ts +68 -7
  24. package/dist/extensions/coordinator/handler.test.ts +388 -123
  25. package/dist/extensions/coordinator/handler.ts +78 -12
  26. package/dist/extensions/coordinator/index.ts +306 -198
  27. package/dist/extensions/coordinator/types.d.ts +16 -0
  28. package/dist/extensions/coordinator/types.d.ts.map +1 -1
  29. package/dist/extensions/coordinator/types.js.map +1 -1
  30. package/dist/extensions/coordinator/types.ts +57 -49
  31. package/dist/extensions/hooks-engine/index.ts +3 -0
  32. package/dist/extensions/lsp/lsp/client/smart-file-tracker.ts +302 -0
  33. package/dist/extensions/lsp/lsp/index.ts +15 -9
  34. package/dist/extensions/lsp/lsp/lsp-clangd-e2e.test.ts +229 -0
  35. package/dist/extensions/lsp/lsp/utils/project-scanner.ts +101 -12
  36. package/dist/extensions/message-bridge/index.ts +14 -11
  37. package/dist/extensions/output-guard/index.ts +39 -0
  38. package/dist/extensions/preview/index.ts +23 -0
  39. package/dist/extensions/session-supervisor/index.ts +14 -8
  40. package/dist/extensions/subagent-v2/extract-parent-todos.test.ts +146 -0
  41. package/dist/extensions/subagent-v2/index.ts +430 -57
  42. package/dist/extensions/todo-ext/index.ts +62 -3
  43. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  44. package/dist/modes/interactive/interactive-mode.js +6 -0
  45. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  46. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  47. package/dist/modes/rpc/rpc-mode.js +10 -0
  48. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  49. package/package.json +1 -1
@@ -5,68 +5,76 @@ export const COORDINATOR_CHANNEL_NAME = "coordinator";
5
5
  export type SessionStatus = "idle" | "streaming" | "stopped" | "completed";
6
6
 
7
7
  export interface DelegatedTask {
8
- sessionId: string;
9
- title: string;
10
- task: string;
11
- projectPath: string;
12
- dispatchedAt: number;
13
- status: SessionStatus;
14
- completedAt?: number;
15
- result?: string;
8
+ sessionId: string;
9
+ title: string;
10
+ task: string;
11
+ projectPath: string;
12
+ dispatchedAt: number;
13
+ status: SessionStatus;
14
+ completedAt?: number;
15
+ result?: string;
16
16
  }
17
17
 
18
18
  export interface DelegateCreateResult {
19
- sessionId: string;
20
- status: "started" | "already_running";
19
+ sessionId: string;
20
+ status: "started" | "already_running";
21
21
  }
22
22
 
23
23
  export interface DelegateSendResult {
24
- delivered: boolean;
25
- targetStatus: "active" | "started" | "not_found";
24
+ delivered: boolean;
25
+ targetStatus: "active" | "started" | "not_found";
26
26
  }
27
27
 
28
28
  export interface DelegateListResult {
29
- tasks: DelegatedTask[];
29
+ tasks: DelegatedTask[];
30
30
  }
31
31
 
32
32
  export interface DelegateStatusExt {
33
- task: DelegatedTask | null;
34
- isCompacting?: boolean;
35
- contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
33
+ task: DelegatedTask | null;
34
+ isCompacting?: boolean;
35
+ contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
36
36
  }
37
37
 
38
38
  export interface CoordinatorChannelContract extends ChannelContract {
39
- methods: {
40
- session_delegate: {
41
- params: { task: string; title?: string };
42
- return: DelegateCreateResult;
43
- };
44
- session_delegate_send: {
45
- params: { targetSessionId: string; message: string };
46
- return: DelegateSendResult;
47
- };
48
- session_delegate_status: {
49
- params: { sessionId: string };
50
- return: DelegateStatusExt;
51
- };
52
- session_delegate_list: {
53
- params: Record<string, never>;
54
- return: DelegateListResult;
55
- };
56
- session_delegate_stop: {
57
- params: { sessionId: string };
58
- return: { ok: boolean };
59
- };
60
- session_delegate_fork: {
61
- params: { sessionId: string; task: string; title?: string };
62
- return: DelegateCreateResult;
63
- };
64
- };
65
- events: {
66
- message_received: { fromSessionId: string; message: string };
67
- task_started: { sessionId: string; title: string; task: string };
68
- task_stopped: { sessionId: string };
69
- task_completed: { sessionId: string; result?: string };
70
- task_error: { sessionId: string; error: string };
71
- };
39
+ methods: {
40
+ session_delegate: {
41
+ params: { task: string; title?: string; projectPath?: string };
42
+ return: DelegateCreateResult;
43
+ };
44
+ session_delegate_send: {
45
+ params: { targetSessionId: string; message: string };
46
+ return: DelegateSendResult;
47
+ };
48
+ session_delegate_status: {
49
+ params: { sessionId: string };
50
+ return: DelegateStatusExt;
51
+ };
52
+ session_delegate_list: {
53
+ params: Record<string, never>;
54
+ return: DelegateListResult;
55
+ };
56
+ session_delegate_stop: {
57
+ params: { sessionId: string };
58
+ return: { ok: boolean };
59
+ };
60
+ session_delegate_remove: {
61
+ params: { sessionId: string };
62
+ return: { ok: boolean };
63
+ };
64
+ session_delegate_clear_stopped: {
65
+ params: Record<string, never>;
66
+ return: { removed: number };
67
+ };
68
+ session_delegate_fork: {
69
+ params: { sessionId: string; task: string; title?: string; projectPath?: string };
70
+ return: DelegateCreateResult;
71
+ };
72
+ };
73
+ events: {
74
+ message_received: { fromSessionId: string; message: string };
75
+ task_started: { sessionId: string; title: string; task: string };
76
+ task_stopped: { sessionId: string };
77
+ task_completed: { sessionId: string; result?: string };
78
+ task_error: { sessionId: string; error: string };
79
+ };
72
80
  }
@@ -193,6 +193,9 @@ export default function hooksEngine(pi: ExtensionAPI): void {
193
193
 
194
194
  if (promptResults.length > 0) {
195
195
  console.log("[hook] Prompts to inject:", promptResults);
196
+ for (const prompt of promptResults) {
197
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
198
+ }
196
199
  }
197
200
 
198
201
  return undefined;
@@ -0,0 +1,302 @@
1
+ import { extname } from "node:path";
2
+
3
+ export interface SmartFileTrackerOptions {
4
+ maxOpenFiles?: number;
5
+ now?: () => number;
6
+ priorityMap?: Record<string, number>;
7
+ excludedExtensions?: Set<string>;
8
+ }
9
+
10
+ export interface MemoryUsage {
11
+ heapUsed: number;
12
+ heapTotal: number;
13
+ }
14
+
15
+ export interface TrackerStatistics {
16
+ totalOpens: number;
17
+ totalEvictions: number;
18
+ evictionReasons: Array<"window_full" | "memory_pressure" | "excluded_type">;
19
+ accessCounts: Map<string, number>;
20
+ openFileCount: number;
21
+ }
22
+
23
+ interface TrackedFile {
24
+ filePath: string;
25
+ lastAccess: number;
26
+ modifiedTime: number;
27
+ accessCount: number;
28
+ priority: number;
29
+ }
30
+
31
+ // Default priority map for common file types (higher = more important)
32
+ const DEFAULT_PRIORITY_MAP: Record<string, number> = {
33
+ // TypeScript/JavaScript (highest)
34
+ ".ts": 100,
35
+ ".tsx": 100,
36
+ ".js": 90,
37
+ ".jsx": 90,
38
+ ".mjs": 90,
39
+ ".cjs": 90,
40
+
41
+ // Framework files
42
+ ".vue": 95,
43
+ ".svelte": 95,
44
+
45
+ // Other languages
46
+ ".py": 80,
47
+ ".rs": 80,
48
+ ".go": 80,
49
+ ".java": 75,
50
+ ".kt": 75,
51
+ ".c": 70,
52
+ ".cpp": 70,
53
+ ".h": 70,
54
+ ".hpp": 70,
55
+ ".cs": 70,
56
+
57
+ // Config files
58
+ ".json": 30,
59
+ ".yaml": 30,
60
+ ".yml": 30,
61
+ ".toml": 30,
62
+
63
+ // Documentation
64
+ ".md": 20,
65
+
66
+ // Text files
67
+ ".txt": 10,
68
+ };
69
+
70
+ // Default excluded file types (never tracked)
71
+ const DEFAULT_EXCLUDED_EXTENSIONS: Set<string> = new Set([
72
+ ".log",
73
+ ".bak",
74
+ ".tmp",
75
+ ".temp",
76
+ ".swp",
77
+ ".cache",
78
+ ".DS_Store",
79
+ ".map",
80
+ ".lock",
81
+ ".pid",
82
+ // Special files without extension
83
+ ".gitignore",
84
+ ".gitattributes",
85
+ ".gitmodules",
86
+ ".editorconfig",
87
+ ".eslintrc",
88
+ ".prettierrc",
89
+ ]);
90
+
91
+ // Memory thresholds (in bytes)
92
+ const HIGH_MEMORY_THRESHOLD = 3_000_000_000; // 3GB
93
+ const CRITICAL_MEMORY_THRESHOLD = 3_500_000_000; // 3.5GB
94
+ const BASE_MAX_FILES = 30;
95
+ const HIGH_MEMORY_MAX_FILES = 10;
96
+ const CRITICAL_MEMORY_MAX_FILES = 5;
97
+
98
+ export interface SmartFileTracker {
99
+ open(filePath: string, onClose: (file: string) => void, mtime?: number): void;
100
+ getOpenFiles(): string[];
101
+ closeAll(onClose: (file: string) => void): void;
102
+ updateMemoryUsage(usage: MemoryUsage): void;
103
+ getStatistics(): TrackerStatistics;
104
+ }
105
+
106
+ export function createSmartFileTracker(options: SmartFileTrackerOptions = {}): SmartFileTracker {
107
+ const baseMaxOpenFiles = options.maxOpenFiles ?? BASE_MAX_FILES;
108
+ const now = options.now ?? (() => Date.now());
109
+ const priorityMap = options.priorityMap ?? DEFAULT_PRIORITY_MAP;
110
+ const excludedExtensions = options.excludedExtensions ?? DEFAULT_EXCLUDED_EXTENSIONS;
111
+
112
+ const files = new Map<string, TrackedFile>();
113
+ const stats: TrackerStatistics = {
114
+ totalOpens: 0,
115
+ totalEvictions: 0,
116
+ evictionReasons: [],
117
+ accessCounts: new Map(),
118
+ openFileCount: 0,
119
+ };
120
+
121
+ let memoryUsage: MemoryUsage = {
122
+ heapUsed: 0,
123
+ heapTotal: 0,
124
+ };
125
+
126
+ // Calculate current max files based on memory pressure
127
+ function getCurrentMaxFiles(): number {
128
+ if (!memoryUsage.heapUsed || !memoryUsage.heapTotal) {
129
+ return baseMaxOpenFiles;
130
+ }
131
+
132
+ const memoryRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
133
+
134
+ if (memoryRatio > CRITICAL_MEMORY_THRESHOLD / memoryUsage.heapTotal) {
135
+ return Math.min(CRITICAL_MEMORY_MAX_FILES, baseMaxOpenFiles);
136
+ }
137
+
138
+ if (memoryRatio > HIGH_MEMORY_THRESHOLD / memoryUsage.heapTotal) {
139
+ return Math.min(HIGH_MEMORY_MAX_FILES, baseMaxOpenFiles);
140
+ }
141
+
142
+ return baseMaxOpenFiles;
143
+ }
144
+
145
+ // Get file priority based on extension
146
+ function getFilePriority(filePath: string): number {
147
+ const ext = extname(filePath).toLowerCase();
148
+ return priorityMap[ext] ?? 50; // Default priority for unknown types
149
+ }
150
+
151
+ // Check if file should be excluded
152
+ function isExcluded(filePath: string): boolean {
153
+ const ext = extname(filePath).toLowerCase();
154
+ if (excludedExtensions.has(ext)) {
155
+ return true;
156
+ }
157
+
158
+ // Check excluded file names (files without extension like .gitignore)
159
+ const basename = filePath.split("/").pop() ?? filePath;
160
+ if (excludedExtensions.has(basename)) {
161
+ return true;
162
+ }
163
+
164
+ return false;
165
+ }
166
+
167
+ // Sort files by priority (higher first), then by modified time (newer first), then by last access
168
+ function getSortedFiles(): TrackedFile[] {
169
+ return Array.from(files.values()).sort((a, b) => {
170
+ // Higher priority first
171
+ if (b.priority !== a.priority) {
172
+ return b.priority - a.priority;
173
+ }
174
+ // Newer modified time first
175
+ if (b.modifiedTime !== a.modifiedTime) {
176
+ return b.modifiedTime - a.modifiedTime;
177
+ }
178
+ // More recently accessed first
179
+ if (b.lastAccess !== a.lastAccess) {
180
+ return b.lastAccess - a.lastAccess;
181
+ }
182
+ // Higher access count first
183
+ return b.accessCount - a.accessCount;
184
+ });
185
+ }
186
+
187
+ // Evict lowest priority file(s) to fit new file
188
+ function evictIfNeeded(onClose: (file: string) => void): void {
189
+ const currentMax = getCurrentMaxFiles();
190
+ // Evict if we're at or over the limit (before adding new file)
191
+ while (files.size >= currentMax) {
192
+ const sorted = getSortedFiles();
193
+ const lowest = sorted[sorted.length - 1]; // Last element has lowest priority
194
+
195
+ if (!lowest) {
196
+ break;
197
+ }
198
+
199
+ files.delete(lowest.filePath);
200
+ onClose(lowest.filePath);
201
+
202
+ stats.totalEvictions++;
203
+ stats.evictionReasons.push("window_full");
204
+ stats.openFileCount = files.size;
205
+
206
+ // If memory is high, evict multiple files
207
+ const memoryRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
208
+ if (memoryRatio > HIGH_MEMORY_THRESHOLD / memoryUsage.heapTotal && files.size > currentMax / 2) {
209
+ continue; // Keep evicting
210
+ }
211
+
212
+ break;
213
+ }
214
+ }
215
+
216
+ // Evict files to fit within memory constraints
217
+ function evictForMemory(onClose?: (file: string) => void): void {
218
+ const currentMax = getCurrentMaxFiles();
219
+ while (files.size > currentMax) {
220
+ const sorted = getSortedFiles();
221
+ const lowest = sorted[sorted.length - 1];
222
+
223
+ if (!lowest) {
224
+ break;
225
+ }
226
+
227
+ files.delete(lowest.filePath);
228
+ if (onClose) {
229
+ onClose(lowest.filePath);
230
+ }
231
+
232
+ stats.totalEvictions++;
233
+ stats.evictionReasons.push("memory_pressure");
234
+ stats.openFileCount = files.size;
235
+ }
236
+ }
237
+
238
+ return {
239
+ open(filePath: string, onClose: (file: string) => void, mtime?: number): void {
240
+ // Check if file is excluded
241
+ if (isExcluded(filePath)) {
242
+ onClose(filePath);
243
+ stats.totalEvictions++;
244
+ stats.evictionReasons.push("excluded_type");
245
+ return;
246
+ }
247
+
248
+ stats.totalOpens++;
249
+
250
+ // Update access count
251
+ const currentCount = stats.accessCounts.get(filePath) ?? 0;
252
+ stats.accessCounts.set(filePath, currentCount + 1);
253
+
254
+ // If file already exists, update its metadata
255
+ if (files.has(filePath)) {
256
+ const entry = files.get(filePath)!;
257
+ entry.lastAccess = now();
258
+ entry.modifiedTime = mtime ?? entry.modifiedTime;
259
+ entry.accessCount++;
260
+ return;
261
+ }
262
+
263
+ // Evict files if needed (before adding new file)
264
+ evictIfNeeded(onClose);
265
+
266
+ // Add new file
267
+ const newFile: TrackedFile = {
268
+ filePath,
269
+ lastAccess: now(),
270
+ modifiedTime: mtime ?? now(),
271
+ accessCount: 1,
272
+ priority: getFilePriority(filePath),
273
+ };
274
+
275
+ files.set(filePath, newFile);
276
+ stats.openFileCount = files.size;
277
+ },
278
+
279
+ getOpenFiles(): string[] {
280
+ const sorted = getSortedFiles();
281
+ return sorted.map((f) => f.filePath);
282
+ },
283
+
284
+ closeAll(onClose: (file: string) => void): void {
285
+ for (const filePath of files.keys()) {
286
+ onClose(filePath);
287
+ }
288
+ files.clear();
289
+ stats.openFileCount = 0;
290
+ },
291
+
292
+ updateMemoryUsage(usage: MemoryUsage): void {
293
+ memoryUsage = usage;
294
+ // Evict files if memory pressure increased window size
295
+ evictForMemory();
296
+ },
297
+
298
+ getStatistics(): TrackerStatistics {
299
+ return { ...stats, accessCounts: new Map(stats.accessCounts) };
300
+ },
301
+ };
302
+ }
@@ -71,15 +71,21 @@ export default function lspExtension(pi: ExtensionAPI): void {
71
71
  })),
72
72
  }));
73
73
 
74
- pi.sendMessage(
75
- {
76
- customType: "lsp_diagnostics",
77
- content: `[LSP] Post-edit diagnostics found issues in ${results.length} file(s): ${summary}.\nPlease review and fix the issues listed below.`,
78
- display: true,
79
- details: { files: fileSummaries },
80
- },
81
- { triggerTurn: true },
82
- );
74
+ try {
75
+ pi.sendMessage(
76
+ {
77
+ customType: "lsp_diagnostics",
78
+ content: `[LSP] Post-edit diagnostics found issues in ${results.length} file(s): ${summary}.\nPlease review and fix the issues listed below.`,
79
+ display: true,
80
+ details: { files: fileSummaries },
81
+ },
82
+ { triggerTurn: true },
83
+ );
84
+ } catch (err) {
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ if (msg.includes("stale")) return;
87
+ throw err;
88
+ }
83
89
  });
84
90
 
85
91
  let idleCleanupTimer: ReturnType<typeof setTimeout> | undefined;
@@ -0,0 +1,229 @@
1
+ import { tmpdir } from "node:os";
2
+ import { mkdir, writeFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
5
+ import type { ExtensionAPI } from "@dyyz1993/pi-coding-agent";
6
+ import lspExtensionDefault from "./index.js";
7
+
8
+ const TEST_DIR = "/tmp/lsp-clangd-test";
9
+ const TEST_FILE = "test.c";
10
+
11
+ function createMockPi() {
12
+ const handlers: Record<string, Array<(event: any, ctx: any) => any>> = {};
13
+ const registeredTools = new Map<string, any>();
14
+ const channelSendFn = vi.fn();
15
+ const registerCommandFn = vi.fn();
16
+ let channelOnReceiveHandler: ((data: unknown) => void) | null = null;
17
+ let currentChannel: {
18
+ name: string;
19
+ send: (data: unknown) => void;
20
+ onReceive: (handler: (data: unknown) => void) => () => void;
21
+ invoke: (data: unknown, timeoutMs?: number) => Promise<unknown>;
22
+ call: (method: string, params: Record<string, unknown>, timeoutMs?: number) => Promise<unknown>;
23
+ } | null = null;
24
+
25
+ const pi = {
26
+ on: vi.fn((event: string, handler: any) => {
27
+ if (!handlers[event]) handlers[event] = [];
28
+ handlers[event].push(handler);
29
+ }),
30
+ callLLM: vi.fn(async () => "{}"),
31
+ callLLMStructured: vi.fn(async () => ({})),
32
+ forkAgent: vi.fn(async () => ({ text: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 } })),
33
+ once: vi.fn(),
34
+ emit: vi.fn(),
35
+ setStatus: vi.fn(),
36
+ registerProvider: vi.fn(),
37
+ unregisterProvider: vi.fn(),
38
+ events: { on: vi.fn(), off: vi.fn(), emit: vi.fn(), once: vi.fn() },
39
+ registerChannel: vi.fn(() => {
40
+ currentChannel = {
41
+ name: "lsp",
42
+ send: channelSendFn,
43
+ onReceive: vi.fn((handler: (data: unknown) => void) => {
44
+ channelOnReceiveHandler = handler;
45
+ return () => { channelOnReceiveHandler = null; };
46
+ }),
47
+ invoke: vi.fn(async (data: unknown) => {
48
+ if (!channelOnReceiveHandler) return {};
49
+ const msg = data as Record<string, unknown>;
50
+ const invokeId = msg.__invokeId as string;
51
+ return new Promise((resolve) => {
52
+ const orig = channelSendFn.getMockImplementation() ?? channelSendFn;
53
+ channelSendFn.mockImplementation((response: unknown) => {
54
+ const resp = response as Record<string, unknown>;
55
+ if (resp?.invokeId === invokeId) {
56
+ channelSendFn.mockImplementation(orig as any);
57
+ resolve(response);
58
+ }
59
+ });
60
+ channelOnReceiveHandler!(data);
61
+ });
62
+ }),
63
+ call: vi.fn(async (method: string, params: Record<string, unknown>, _timeoutMs?: number) => {
64
+ if (!channelOnReceiveHandler) return {};
65
+ const invokeId = `invoke_${method}_${Date.now()}`;
66
+ return new Promise((resolve) => {
67
+ const orig = channelSendFn.getMockImplementation() ?? channelSendFn;
68
+ channelSendFn.mockImplementation((response: unknown) => {
69
+ const resp = response as Record<string, unknown>;
70
+ if (resp?.invokeId === invokeId) {
71
+ channelSendFn.mockImplementation(orig as any);
72
+ resolve(response);
73
+ }
74
+ });
75
+ channelOnReceiveHandler!({ __call: method, invokeId, ...params });
76
+ });
77
+ }),
78
+ };
79
+ return currentChannel;
80
+ }),
81
+ registerTool: vi.fn((tool: any) => {
82
+ registeredTools.set(tool.name, tool);
83
+ }),
84
+ registerCommand: registerCommandFn,
85
+ appendEntry: vi.fn(),
86
+ sendMessage: vi.fn(),
87
+ off: vi.fn(),
88
+ } as unknown as ExtensionAPI;
89
+
90
+ return {
91
+ pi,
92
+ handlers,
93
+ registeredTools,
94
+ channelSend: channelSendFn,
95
+ registerCommandFn,
96
+ getCurrentChannel: () => currentChannel,
97
+ };
98
+ }
99
+
100
+ async function fireSessionStart(
101
+ mock: ReturnType<typeof createMockPi>,
102
+ cwd: string,
103
+ ): Promise<void> {
104
+ for (const h of mock.handlers.session_start ?? []) {
105
+ await h(
106
+ {},
107
+ {
108
+ sessionManager: { getBranch: () => [] },
109
+ hasUI: false,
110
+ ui: { notify: vi.fn() },
111
+ cwd,
112
+ isIdle: () => true,
113
+ signal: undefined,
114
+ abort: () => {},
115
+ hasPendingMessages: () => false,
116
+ shutdown: () => {},
117
+ getContextUsage: () => undefined,
118
+ compact: () => {},
119
+ getSystemPrompt: () => "",
120
+ model: undefined,
121
+ },
122
+ );
123
+ }
124
+ }
125
+
126
+ async function fireSessionShutdown(mock: ReturnType<typeof createMockPi>): Promise<void> {
127
+ for (const h of mock.handlers.session_shutdown ?? []) {
128
+ await h({}, {});
129
+ }
130
+ }
131
+
132
+ async function fireToolResult(
133
+ mock: ReturnType<typeof createMockPi>,
134
+ filePath: string,
135
+ toolName: "write" | "edit" = "write",
136
+ ): Promise<any> {
137
+ const results: any[] = [];
138
+ for (const h of mock.handlers.tool_result ?? []) {
139
+ const result = await h(
140
+ {
141
+ type: "tool_result",
142
+ toolCallId: "tc_e2e_1",
143
+ toolName,
144
+ input: { path: filePath },
145
+ content: [{ type: "text", text: `File written: ${filePath}` }],
146
+ isError: false,
147
+ details: undefined,
148
+ },
149
+ {
150
+ cwd: TEST_DIR,
151
+ ui: { notify: vi.fn() },
152
+ },
153
+ );
154
+ if (result) results.push(result);
155
+ }
156
+ return results;
157
+ }
158
+
159
+ describe("clangd E2E integration", () => {
160
+ const originalCwd = process.cwd();
161
+
162
+ beforeAll(async () => {
163
+ await mkdir(join(TEST_DIR, ".pi"), { recursive: true });
164
+ await writeFile(join(TEST_DIR, ".pi", "lsp.json"), JSON.stringify({
165
+ servers: [
166
+ {
167
+ name: "clangd",
168
+ command: ["clangd"],
169
+ fileTypes: [".c", ".h", ".cpp", ".hpp", ".cc", ".cxx"],
170
+ },
171
+ ],
172
+ }));
173
+ await writeFile(join(TEST_DIR, TEST_FILE), `#include <stdio.h>\n\nint main() {\n int x = "hello";\n printf("%d\\n", x);\n return 0;\n}\n`);
174
+ process.chdir(TEST_DIR);
175
+ });
176
+
177
+ afterAll(async () => {
178
+ process.chdir(originalCwd);
179
+ });
180
+
181
+ it(
182
+ "starts clangd, detects type error in test.c via diagnostics",
183
+ async () => {
184
+ const mock = createMockPi();
185
+ lspExtensionDefault(mock.pi);
186
+
187
+ await fireSessionStart(mock, TEST_DIR);
188
+
189
+ const channel = mock.getCurrentChannel();
190
+ expect(channel).not.toBeNull();
191
+
192
+ const statusResult = await channel!.call("getStatus", {});
193
+ console.log("[e2e] Status after session_start:", JSON.stringify(statusResult, null, 2));
194
+
195
+ const status = statusResult as any;
196
+ expect(status.state).toBeDefined();
197
+
198
+ const readyServers = (status.servers as any[])?.filter((s: any) => s.state === "ready") ?? [];
199
+ console.log(`[e2e] Ready servers: ${readyServers.length}`);
200
+ for (const s of readyServers) {
201
+ console.log(`[e2e] - ${s.name} [${(s.fileTypes ?? []).join(",")}] state=${s.state}`);
202
+ }
203
+
204
+ if (readyServers.length === 0) {
205
+ console.log("[e2e] No clangd server became ready — skipping diagnostics check");
206
+ console.log("[e2e] All servers:", JSON.stringify(status.servers, null, 2));
207
+ }
208
+
209
+ expect(readyServers.length).toBeGreaterThanOrEqual(1);
210
+
211
+ const toolResults = await fireToolResult(mock, TEST_FILE);
212
+ console.log("[e2e] tool_result handler results:", JSON.stringify(toolResults, null, 2));
213
+
214
+ const diagnosticsContent = toolResults.find(
215
+ (r: any) => r?.content?.some?.((c: any) => c.text?.includes("[LSP]")),
216
+ );
217
+ if (diagnosticsContent) {
218
+ console.log("[e2e] Diagnostics found in tool_result response:");
219
+ for (const c of diagnosticsContent.content) {
220
+ console.log(c.text);
221
+ }
222
+ }
223
+
224
+ await fireSessionShutdown(mock);
225
+ console.log("[e2e] Session shutdown complete");
226
+ },
227
+ 30_000,
228
+ );
229
+ });