decorated-pi 0.7.1 → 0.7.3
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 +29 -8
- package/commands/dp-settings.ts +3 -3
- package/hooks/compaction.ts +115 -85
- package/hooks/externalize.ts +48 -35
- package/hooks/inject-agents-md.ts +20 -12
- package/hooks/mcp.ts +61 -64
- package/hooks/pi-tool-filter.ts +2 -1
- package/hooks/rtk.ts +24 -96
- package/hooks/skeleton.ts +86 -23
- package/hooks/smart-at.ts +59 -7
- package/hooks/wakatime.ts +16 -28
- package/index.ts +122 -4
- package/package.json +2 -4
- package/settings.ts +164 -4
- package/skills/pi-docs/SKILL.md +13 -0
- package/tools/ask/index.ts +0 -3
- package/tools/lsp/servers.ts +31 -15
- package/tools/lsp/tools.ts +0 -1
- package/tools/mcp/builtin/codegraph.ts +1 -1
- package/tools/mcp/client.ts +1 -1
- package/tools/mcp/config.ts +34 -15
- package/tools/patch/core.ts +10 -0
- package/tools/patch/index.ts +338 -226
- package/tsconfig.json +1 -0
- package/ui/ask.ts +72 -22
- package/ui/module-settings.ts +166 -16
- package/utils/which.ts +109 -0
package/hooks/mcp.ts
CHANGED
|
@@ -21,7 +21,7 @@ interface ServerStatus {
|
|
|
21
21
|
name: string;
|
|
22
22
|
url: string;
|
|
23
23
|
source: string;
|
|
24
|
-
state: "connecting" | "connected" | "failed" | "disabled" | "
|
|
24
|
+
state: "connecting" | "connected" | "failed" | "disabled" | "waiting reload";
|
|
25
25
|
toolCount: number;
|
|
26
26
|
tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }>;
|
|
27
27
|
error?: string;
|
|
@@ -36,6 +36,45 @@ function canUseServer(config: McpServerConfig, cwd?: string): boolean {
|
|
|
36
36
|
return config.canUseInProject(cwd ?? process.cwd());
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function markServerFailed(config: McpServerConfig, error: string): void {
|
|
40
|
+
allServers.set(config.name, {
|
|
41
|
+
name: config.name,
|
|
42
|
+
url: config.url ?? config.command ?? "(unknown)",
|
|
43
|
+
source: config.source,
|
|
44
|
+
state: "failed",
|
|
45
|
+
toolCount: 0,
|
|
46
|
+
tools: [],
|
|
47
|
+
error,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function markServerConnected(config: McpServerConfig, tools: McpToolCache[]): void {
|
|
52
|
+
allServers.set(config.name, {
|
|
53
|
+
name: config.name,
|
|
54
|
+
url: config.url ?? config.command ?? "(unknown)",
|
|
55
|
+
source: config.source,
|
|
56
|
+
state: "connected",
|
|
57
|
+
toolCount: tools.length,
|
|
58
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function markServerState(
|
|
63
|
+
name: string,
|
|
64
|
+
state: "disabled" | "waiting reload" | "connecting",
|
|
65
|
+
config: { url?: string; command?: string; source: string } | undefined,
|
|
66
|
+
): void {
|
|
67
|
+
allServers.set(name, {
|
|
68
|
+
name,
|
|
69
|
+
url: config?.url ?? config?.command ?? "(unknown)",
|
|
70
|
+
source: config?.source ?? "unknown",
|
|
71
|
+
state,
|
|
72
|
+
toolCount: 0,
|
|
73
|
+
tools: [],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
39
78
|
async function connectAll(
|
|
40
79
|
configs: McpServerConfig[],
|
|
41
80
|
registry: any,
|
|
@@ -51,7 +90,7 @@ async function connectAll(
|
|
|
51
90
|
// every caller's updates are visible.
|
|
52
91
|
allServers.clear();
|
|
53
92
|
for (const s of configs) {
|
|
54
|
-
|
|
93
|
+
markServerState(s.name, "connecting", s);
|
|
55
94
|
}
|
|
56
95
|
|
|
57
96
|
// Watchdog: the inner Promise.race in McpConnection.connect() has a
|
|
@@ -87,11 +126,7 @@ async function connectAll(
|
|
|
87
126
|
if (watchdogFired) break; // remaining servers are already failed
|
|
88
127
|
|
|
89
128
|
if (!canUseServer(server, cachedCwd || undefined)) {
|
|
90
|
-
|
|
91
|
-
name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
|
|
92
|
-
state: "failed", toolCount: 0, tools: [],
|
|
93
|
-
error: "Project is missing required artefacts for this server",
|
|
94
|
-
});
|
|
129
|
+
markServerFailed(server, "Project is missing required artefacts for this server");
|
|
95
130
|
continue;
|
|
96
131
|
}
|
|
97
132
|
|
|
@@ -108,11 +143,7 @@ async function connectAll(
|
|
|
108
143
|
// failed state and do NOT write an empty cache, otherwise a
|
|
109
144
|
// subsequent /reload will keep using the empty cache forever.
|
|
110
145
|
try { await conn.disconnect(); } catch { /* ignore */ }
|
|
111
|
-
|
|
112
|
-
name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
|
|
113
|
-
state: "failed", toolCount: 0, tools: [],
|
|
114
|
-
error: "Server connected but returned no tools (e.g. codegraph without a .codegraph index)",
|
|
115
|
-
});
|
|
146
|
+
markServerFailed(server, "Server connected but returned no tools (e.g. codegraph without a .codegraph index)");
|
|
116
147
|
continue;
|
|
117
148
|
}
|
|
118
149
|
activeConnections.push(conn);
|
|
@@ -134,19 +165,13 @@ async function connectAll(
|
|
|
134
165
|
schemaChanges.push(`${server.name} (${parts.join(', ')})`);
|
|
135
166
|
}
|
|
136
167
|
}
|
|
137
|
-
allServers.set(server.name, {
|
|
138
|
-
name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
|
|
139
|
-
state: "connected", toolCount: conn.tools.length, tools: actualTools,
|
|
140
|
-
});
|
|
141
168
|
const tools: McpToolCache[] = conn.tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
|
|
169
|
+
markServerConnected(server, tools);
|
|
142
170
|
updateServerCache(server.name, { tools, cachedAt: Date.now() }, cacheScopeForSource(server.source), cachedCwd || undefined);
|
|
143
171
|
} catch (err) {
|
|
144
172
|
if (watchdogFired) return { schemaChanges, hasNewServer };
|
|
145
173
|
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
-
|
|
147
|
-
name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
|
|
148
|
-
state: "failed", toolCount: 0, tools: [], error: msg,
|
|
149
|
-
});
|
|
174
|
+
markServerFailed(server, msg);
|
|
150
175
|
}
|
|
151
176
|
}
|
|
152
177
|
|
|
@@ -174,7 +199,7 @@ export function getMcpStatus(): ServerStatus[] {
|
|
|
174
199
|
// or was toggled enabled after startup and needs /reload.
|
|
175
200
|
result.push({
|
|
176
201
|
name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source,
|
|
177
|
-
state: config.enabled ? "
|
|
202
|
+
state: config.enabled ? "waiting reload" : "disabled",
|
|
178
203
|
toolCount: cachedEntry?.tools.length ?? 0, tools: cachedEntry?.tools ?? [],
|
|
179
204
|
});
|
|
180
205
|
}
|
|
@@ -194,35 +219,23 @@ export async function updateConfigEnabled(serverName: string, enabled: boolean):
|
|
|
194
219
|
activeConnections = activeConnections.filter(c => c.serverName !== serverName);
|
|
195
220
|
}
|
|
196
221
|
// Mark the server as disabled in allServers (or add it if absent).
|
|
197
|
-
|
|
198
|
-
name: serverName,
|
|
199
|
-
url: config?.url ?? config?.command ?? "(unknown)",
|
|
200
|
-
source: config?.source ?? "unknown",
|
|
201
|
-
state: "disabled",
|
|
202
|
-
toolCount: 0,
|
|
203
|
-
tools: [],
|
|
204
|
-
});
|
|
222
|
+
markServerState(serverName, "disabled", config ?? undefined);
|
|
205
223
|
return;
|
|
206
224
|
}
|
|
207
225
|
|
|
208
226
|
// Re-enabling at runtime: the config is now enabled but tools aren't
|
|
209
227
|
// registered until the next session_start. Mark it as waiting_reload
|
|
210
228
|
// so the user sees exactly why the server isn't usable yet.
|
|
211
|
-
|
|
212
|
-
name: serverName,
|
|
213
|
-
url: config?.url ?? config?.command ?? "(unknown)",
|
|
214
|
-
source: config?.source ?? "unknown",
|
|
215
|
-
state: "waiting reload",
|
|
216
|
-
toolCount: 0,
|
|
217
|
-
tools: [],
|
|
218
|
-
});
|
|
229
|
+
markServerState(serverName, "waiting reload", config ?? undefined);
|
|
219
230
|
}
|
|
220
231
|
|
|
221
232
|
export async function refreshServerCache(serverName: string, registry: any): Promise<{ ok: boolean; error?: string }> {
|
|
222
233
|
const config = resolveMcpConfigs(cachedCwd).find(s => s.name === serverName);
|
|
223
234
|
if (!config) return { ok: false, error: `Server "${serverName}" not found in config.` };
|
|
224
235
|
if (!canUseServer(config, cachedCwd || undefined)) {
|
|
225
|
-
|
|
236
|
+
const error = "Project is missing required artefacts for this server";
|
|
237
|
+
markServerFailed(config, error);
|
|
238
|
+
return { ok: false, error };
|
|
226
239
|
}
|
|
227
240
|
const existing = activeConnections.find(c => c.serverName === serverName);
|
|
228
241
|
if (existing) {
|
|
@@ -236,21 +249,17 @@ export async function refreshServerCache(serverName: string, registry: any): Pro
|
|
|
236
249
|
if (conn.tools.length === 0) {
|
|
237
250
|
try { await conn.disconnect(); } catch { /* ignore */ }
|
|
238
251
|
const error = "Server connected but returned no tools (e.g. codegraph without a .codegraph index)";
|
|
239
|
-
|
|
252
|
+
markServerFailed(config, error);
|
|
240
253
|
return { ok: false, error };
|
|
241
254
|
}
|
|
242
255
|
activeConnections.push(conn);
|
|
243
|
-
allServers.set(config.name, {
|
|
244
|
-
name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source,
|
|
245
|
-
state: "connected", toolCount: conn.tools.length,
|
|
246
|
-
tools: conn.tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
247
|
-
});
|
|
248
256
|
const tools: McpToolCache[] = conn.tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
|
|
257
|
+
markServerConnected(config, tools);
|
|
249
258
|
updateServerCache(config.name, { tools, cachedAt: Date.now() }, cacheScopeForSource(config.source), cachedCwd || undefined);
|
|
250
259
|
return { ok: true };
|
|
251
260
|
} catch (err) {
|
|
252
261
|
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
-
|
|
262
|
+
markServerFailed(config, msg);
|
|
254
263
|
return { ok: false, error: msg };
|
|
255
264
|
}
|
|
256
265
|
}
|
|
@@ -277,7 +286,10 @@ export function getCachedMcpConfigs(): McpServerConfig[] {
|
|
|
277
286
|
* can find it.
|
|
278
287
|
*/
|
|
279
288
|
export async function ensureMcpServerReady(pi: ExtensionAPI, config: McpServerConfig, cwd?: string): Promise<void> {
|
|
280
|
-
if (!canUseServer(config, cwd))
|
|
289
|
+
if (!canUseServer(config, cwd)) {
|
|
290
|
+
markServerFailed(config, "Project is missing required artefacts for this server");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
281
293
|
|
|
282
294
|
const scope = cacheScopeForSource(config.source);
|
|
283
295
|
const cache = loadScopedMcpCache(scope, cwd);
|
|
@@ -303,15 +315,7 @@ export async function ensureMcpServerReady(pi: ExtensionAPI, config: McpServerCo
|
|
|
303
315
|
// Empty tool list means the server is not useful. Disconnect and
|
|
304
316
|
// leave cache untouched so the next /reload retries the connection.
|
|
305
317
|
try { await conn.disconnect(); } catch { /* ignore */ }
|
|
306
|
-
|
|
307
|
-
name: config.name,
|
|
308
|
-
url: config.url ?? config.command ?? "(unknown)",
|
|
309
|
-
source: config.source,
|
|
310
|
-
state: "failed",
|
|
311
|
-
toolCount: 0,
|
|
312
|
-
tools: [],
|
|
313
|
-
error: "Server connected but returned no tools (e.g. codegraph without a .codegraph index)",
|
|
314
|
-
});
|
|
318
|
+
markServerFailed(config, "Server connected but returned no tools (e.g. codegraph without a .codegraph index)");
|
|
315
319
|
return;
|
|
316
320
|
}
|
|
317
321
|
activeConnections.push(conn);
|
|
@@ -320,6 +324,7 @@ export async function ensureMcpServerReady(pi: ExtensionAPI, config: McpServerCo
|
|
|
320
324
|
description: t.description,
|
|
321
325
|
inputSchema: t.inputSchema,
|
|
322
326
|
}));
|
|
327
|
+
markServerConnected(config, tools);
|
|
323
328
|
updateServerCache(config.name, { tools, cachedAt: Date.now() }, scope, cwd);
|
|
324
329
|
for (const t of tools) {
|
|
325
330
|
try {
|
|
@@ -328,15 +333,7 @@ export async function ensureMcpServerReady(pi: ExtensionAPI, config: McpServerCo
|
|
|
328
333
|
}
|
|
329
334
|
} catch (err) {
|
|
330
335
|
const msg = err instanceof Error ? err.message : String(err);
|
|
331
|
-
|
|
332
|
-
name: config.name,
|
|
333
|
-
url: config.url ?? config.command ?? "(unknown)",
|
|
334
|
-
source: config.source,
|
|
335
|
-
state: "failed",
|
|
336
|
-
toolCount: 0,
|
|
337
|
-
tools: [],
|
|
338
|
-
error: `No cache and initial connect failed: ${msg}`,
|
|
339
|
-
});
|
|
336
|
+
markServerFailed(config, `No cache and initial connect failed: ${msg}`);
|
|
340
337
|
}
|
|
341
338
|
}
|
|
342
339
|
|
package/hooks/pi-tool-filter.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* pi-tool-filter — unregister pi native tools that are replaced by our extensions.
|
|
3
3
|
*
|
|
4
4
|
* edit → replaced by patch
|
|
5
|
+
* write → replaced by patch
|
|
5
6
|
* grep → replaced by bash
|
|
6
7
|
* find → replaced by bash
|
|
7
8
|
* ls → replaced by bash
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
|
|
10
11
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
11
12
|
|
|
12
|
-
const TOOLS_TO_DROP = new Set(["edit", "grep", "find", "ls"]);
|
|
13
|
+
const TOOLS_TO_DROP = new Set(["edit", "write", "grep", "find", "ls"]);
|
|
13
14
|
|
|
14
15
|
export const piToolFilterModule: Module = {
|
|
15
16
|
name: "pi-tool-filter",
|
package/hooks/rtk.ts
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* rtk — rewrite bash commands through system-installed RTK.
|
|
3
3
|
*
|
|
4
|
-
* Uses `rtk rewrite` as a preflight
|
|
5
|
-
* When a rewritten RTK command fails,
|
|
4
|
+
* Uses `rtk rewrite` as a preflight against pi's built-in bash tool. If RTK is
|
|
5
|
+
* not installed, this module is inactive. When a rewritten RTK command fails,
|
|
6
|
+
* the original command is executed once as fallback.
|
|
7
|
+
*
|
|
8
|
+
* We do NOT register our own bash tool. We hook the existing one via
|
|
9
|
+
* `tool_call` (rewrite the command before it runs) and `tool_result` (fall
|
|
10
|
+
* back to the original on error). Overriding bash via `pi.registerTool` would
|
|
11
|
+
* conflict with other extensions (e.g. pi-sandbox) that also override bash.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
12
|
-
import * as fs from "node:fs";
|
|
13
|
-
import * as os from "node:os";
|
|
14
|
+
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
14
16
|
import * as path from "node:path";
|
|
15
17
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
18
|
+
import { resolveDependency } from "../settings.js";
|
|
16
19
|
|
|
17
20
|
// ─── Locating RTK ─────────────────────────────────────────────────────────
|
|
18
21
|
|
|
19
22
|
export function findSystemRtk(): string | null {
|
|
20
|
-
|
|
21
|
-
if (process.platform === "win32") {
|
|
22
|
-
const output = execFileSync("where", ["rtk"], { encoding: "utf-8" }).trim();
|
|
23
|
-
return output.split(/\r?\n/)[0] || null;
|
|
24
|
-
}
|
|
25
|
-
const shell = process.env.SHELL || "sh";
|
|
26
|
-
return execFileSync(shell, ["-lc", "command -v rtk"], { encoding: "utf-8" }).trim() || null;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
23
|
+
return resolveDependency("rtk");
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
export function shellQuote(value: string): string {
|
|
@@ -45,49 +39,12 @@ export function rewriteWithRtk(command: string, rtkPath: string): string | null
|
|
|
45
39
|
return buildRtkCommand(raw, rtkPath);
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
// ───
|
|
49
|
-
|
|
50
|
-
interface PiShellSettings {
|
|
51
|
-
shellPath?: string;
|
|
52
|
-
shellCommandPrefix?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function readJsonObject(filePath: string): Record<string, unknown> {
|
|
56
|
-
try {
|
|
57
|
-
if (!fs.existsSync(filePath)) return {};
|
|
58
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
59
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
60
|
-
} catch {
|
|
61
|
-
return {};
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function loadPiShellSettings(cwd: string): PiShellSettings {
|
|
66
|
-
const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
67
|
-
const globalSettings = readJsonObject(path.join(agentDir, "settings.json"));
|
|
68
|
-
const projectSettings = readJsonObject(path.join(cwd, ".pi", "settings.json"));
|
|
69
|
-
const merged = { ...globalSettings, ...projectSettings } as Record<string, unknown>;
|
|
70
|
-
const result: PiShellSettings = {};
|
|
71
|
-
if (typeof merged.shellPath === "string" && merged.shellPath.trim()) result.shellPath = merged.shellPath;
|
|
72
|
-
if (typeof merged.shellCommandPrefix === "string" && merged.shellCommandPrefix.trim()) {
|
|
73
|
-
result.shellCommandPrefix = merged.shellCommandPrefix;
|
|
74
|
-
}
|
|
75
|
-
return result;
|
|
76
|
-
}
|
|
42
|
+
// ─── Fallback execution ──────────────────────────────────────────────────
|
|
77
43
|
|
|
78
44
|
export function appendStatus(text: string, status: string): string {
|
|
79
45
|
return text ? `${text}\n\n${status}` : status;
|
|
80
46
|
}
|
|
81
47
|
|
|
82
|
-
export function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
|
|
83
|
-
const command = typeof args?.command === "string" ? args.command : null;
|
|
84
|
-
const timeout = typeof args?.timeout === "number" ? args.timeout : undefined;
|
|
85
|
-
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
|
86
|
-
const commandDisplay = command === null ? theme.fg("error", "<invalid command>") : command || theme.fg("toolOutput", "...");
|
|
87
|
-
const tag = showTag ? theme.fg("borderAccent", " [RTK]") : "";
|
|
88
|
-
return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix + tag;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
48
|
export async function executeOriginalBash(command: string, cwd: string, timeout: number | undefined, signal?: AbortSignal) {
|
|
92
49
|
const ops = createLocalBashOperations();
|
|
93
50
|
const chunks: Buffer[] = [];
|
|
@@ -158,48 +115,19 @@ export const rtkModule: Module = {
|
|
|
158
115
|
},
|
|
159
116
|
};
|
|
160
117
|
|
|
161
|
-
export function setupRtk(sk: Skeleton
|
|
118
|
+
export function setupRtk(sk: Skeleton): void {
|
|
162
119
|
rtkBinary = findSystemRtk();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// Register a wrapped bash tool that shows [RTK] tag in TUI.
|
|
171
|
-
const shellSettings = loadPiShellSettings(process.cwd());
|
|
172
|
-
const bashTool = createBashToolDefinition(process.cwd(), {
|
|
173
|
-
shellPath: shellSettings.shellPath,
|
|
174
|
-
commandPrefix: shellSettings.shellCommandPrefix,
|
|
175
|
-
});
|
|
176
|
-
const baseRenderCall = bashTool.renderCall?.bind(bashTool);
|
|
177
|
-
|
|
178
|
-
if (baseRenderCall) {
|
|
179
|
-
bashTool.renderCall = (args: any, theme: any, context: any) => {
|
|
180
|
-
const command = typeof args?.command === "string" ? args.command : "";
|
|
181
|
-
if (!command) {
|
|
182
|
-
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
183
|
-
const placeholder = theme.fg("toolOutput", "...");
|
|
184
|
-
text.setText(theme.fg("toolTitle", theme.bold(`$ ${placeholder}`)));
|
|
185
|
-
return text;
|
|
186
|
-
}
|
|
187
|
-
const component = baseRenderCall(args, theme, context);
|
|
188
|
-
const predicted = command
|
|
189
|
-
? (rewriteabilityCache.get(command) ?? (() => {
|
|
190
|
-
const value = rewriteWithRtk(command, rtkBinary!) !== null;
|
|
191
|
-
rewriteabilityCache.set(command, value);
|
|
192
|
-
return value;
|
|
193
|
-
})())
|
|
194
|
-
: false;
|
|
195
|
-
const rewritten = rewrittenCommands.has(context.toolCallId) || predicted;
|
|
196
|
-
if (component instanceof Text) {
|
|
197
|
-
component.setText(formatBashCallWithTag(args, theme, rewritten));
|
|
198
|
-
}
|
|
199
|
-
return component;
|
|
200
|
-
};
|
|
120
|
+
if (!rtkBinary) {
|
|
121
|
+
sk.declareMissing({
|
|
122
|
+
name: "rtk",
|
|
123
|
+
module: "rtk",
|
|
124
|
+
hint: "Install RTK so bash rewrite can activate.",
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
201
127
|
}
|
|
202
128
|
|
|
203
|
-
pi.
|
|
129
|
+
// Hook pi's built-in bash tool via tool_call/tool_result. We deliberately do
|
|
130
|
+
// not call pi.registerTool — that would shadow pi's bash and conflict with
|
|
131
|
+
// any other extension that also overrides bash (e.g. pi-sandbox).
|
|
204
132
|
sk.register(rtkModule);
|
|
205
133
|
}
|
package/hooks/skeleton.ts
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
* Skeleton — the only place that calls pi.on(...) for hooks.
|
|
3
3
|
*
|
|
4
4
|
* sk.register(module) → installs module's hook handlers
|
|
5
|
-
* sk.declareDependency({...}) →
|
|
5
|
+
* sk.declareDependency({...}) → check now, report on session_start
|
|
6
6
|
* sk.declareGuideline("...") → skeleton injects on before_agent_start
|
|
7
|
-
* sk.install(pi)
|
|
7
|
+
* sk.install(pi) → call once, after all setup<X> calls
|
|
8
8
|
*
|
|
9
9
|
* Handlers receive (event, ctx, pi) so they can call pi.* APIs
|
|
10
10
|
* (setSessionName, registerTool, appendEntry, etc.).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { isDontBother } from "../settings.js";
|
|
14
15
|
|
|
15
16
|
// ─── Event union ───────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -42,13 +43,24 @@ export type ComposeHandler<E extends HookEvent> = (
|
|
|
42
43
|
pi: ExtensionAPI,
|
|
43
44
|
) => any | Promise<any>;
|
|
44
45
|
|
|
46
|
+
/** Result: each handler sees the original event; the last non-undefined
|
|
47
|
+
* return value wins. Matches runner.emit()'s behavior for `session_before_*`
|
|
48
|
+
* events, where the runner collects a single `{ cancel?, compaction? }`
|
|
49
|
+
* result from all extensions. Use for events whose contract is "the
|
|
50
|
+
* extension either overrides or steps aside". */
|
|
51
|
+
export type ResultHandler<E extends HookEvent> = (
|
|
52
|
+
event: any,
|
|
53
|
+
ctx: ExtensionContext,
|
|
54
|
+
pi: ExtensionAPI,
|
|
55
|
+
) => any | Promise<any>;
|
|
56
|
+
|
|
45
57
|
export interface Module {
|
|
46
58
|
readonly name: string;
|
|
47
59
|
readonly hooks: {
|
|
48
60
|
session_start?: ParallelHandler<"session_start">[];
|
|
49
61
|
session_shutdown?: ParallelHandler<"session_shutdown">[];
|
|
50
62
|
session_compact?: ParallelHandler<"session_compact">[];
|
|
51
|
-
session_before_compact?:
|
|
63
|
+
session_before_compact?: ResultHandler<"session_before_compact">[];
|
|
52
64
|
before_agent_start?: ComposeHandler<"before_agent_start">[];
|
|
53
65
|
agent_start?: ParallelHandler<"agent_start">[];
|
|
54
66
|
agent_end?: ParallelHandler<"agent_end">[];
|
|
@@ -61,9 +73,9 @@ export interface Module {
|
|
|
61
73
|
// ─── Declarations ──────────────────────────────────────────────────────────
|
|
62
74
|
|
|
63
75
|
export interface Dependency {
|
|
64
|
-
|
|
65
|
-
check: () => boolean;
|
|
76
|
+
name: string;
|
|
66
77
|
hint?: string;
|
|
78
|
+
module?: string;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
/** Collected result shape for a module's declared dependency. Modules
|
|
@@ -73,9 +85,12 @@ export interface Dependency {
|
|
|
73
85
|
* uses `Dependency.check` directly rather than collecting statuses. */
|
|
74
86
|
export interface DependencyStatus {
|
|
75
87
|
module: string;
|
|
88
|
+
/** Binary/config key, not necessarily the resolved absolute path. */
|
|
76
89
|
label: string;
|
|
77
90
|
state: "ok" | "missing";
|
|
78
91
|
detail: string;
|
|
92
|
+
/** Resolved executable path when state is ok. */
|
|
93
|
+
path?: string;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
// ─── Skeleton ──────────────────────────────────────────────────────────────
|
|
@@ -86,9 +101,23 @@ const COMPOSE_EVENTS = new Set<HookEvent>([
|
|
|
86
101
|
"tool_result",
|
|
87
102
|
]);
|
|
88
103
|
|
|
104
|
+
/** Events whose handler return value is propagated to the extension runner
|
|
105
|
+
* (no chaining — each handler sees the original event, last non-undefined
|
|
106
|
+
* return wins). Required for `session_before_compact`, whose contract is
|
|
107
|
+
* `{ cancel?, compaction? }`; without this, hooks can't override pi's
|
|
108
|
+
* default compaction. */
|
|
109
|
+
const RESULT_EVENTS = new Set<HookEvent>([
|
|
110
|
+
"session_before_compact",
|
|
111
|
+
]);
|
|
112
|
+
|
|
89
113
|
export interface Skeleton {
|
|
90
114
|
register(module: Module): void;
|
|
91
|
-
|
|
115
|
+
/** Returns whether the dependency check passed right now. */
|
|
116
|
+
/** Declare that a binary dependency is missing. Module calls this
|
|
117
|
+
* after its own which() lookup failed. Skeleton dedupes by name,
|
|
118
|
+
* honors `dependencies[name].dontBother`, and shows a single
|
|
119
|
+
* "run /dp-settings" notification on session_start. */
|
|
120
|
+
declareMissing(dep: Omit<Dependency, "module"> & { module?: string }): void;
|
|
92
121
|
install(pi: ExtensionAPI): void;
|
|
93
122
|
inspect(): Inspection;
|
|
94
123
|
}
|
|
@@ -96,13 +125,14 @@ export interface Skeleton {
|
|
|
96
125
|
export interface Inspection {
|
|
97
126
|
modules: string[];
|
|
98
127
|
events: Record<string, Array<{ module: string; order: number }>>;
|
|
99
|
-
dependencies: Array<{
|
|
128
|
+
dependencies: Array<{ name: string; module?: string; hint?: string }>;
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
export function createSkeleton(): Skeleton {
|
|
103
132
|
const modules: Module[] = [];
|
|
104
133
|
const registry = new Map<HookEvent, Array<{ module: string; handler: (event: any, ctx: ExtensionContext, pi: ExtensionAPI) => any }>>();
|
|
105
134
|
const dependencies: Dependency[] = [];
|
|
135
|
+
let dependencyNotifyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
106
136
|
|
|
107
137
|
function collect(mod: Module) {
|
|
108
138
|
for (const [event, handlers] of Object.entries(mod.hooks)) {
|
|
@@ -121,8 +151,10 @@ export function createSkeleton(): Skeleton {
|
|
|
121
151
|
collect(mod);
|
|
122
152
|
},
|
|
123
153
|
|
|
124
|
-
|
|
125
|
-
|
|
154
|
+
declareMissing(dep) {
|
|
155
|
+
// Dedupe by name — multiple modules may depend on the same binary.
|
|
156
|
+
if (dependencies.some((d) => d.name === dep.name)) return;
|
|
157
|
+
dependencies.push(dep as Dependency);
|
|
126
158
|
},
|
|
127
159
|
|
|
128
160
|
install(pi) {
|
|
@@ -138,6 +170,15 @@ export function createSkeleton(): Skeleton {
|
|
|
138
170
|
}
|
|
139
171
|
return current === event ? undefined : current;
|
|
140
172
|
});
|
|
173
|
+
} else if (RESULT_EVENTS.has(event)) {
|
|
174
|
+
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
175
|
+
let result;
|
|
176
|
+
for (const { handler } of handlers) {
|
|
177
|
+
const r = await handler(event, ctx, pi);
|
|
178
|
+
if (r !== undefined) result = r;
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
});
|
|
141
182
|
} else {
|
|
142
183
|
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
143
184
|
for (const { handler } of handlers) await handler(event, ctx, pi);
|
|
@@ -146,21 +187,43 @@ export function createSkeleton(): Skeleton {
|
|
|
146
187
|
}
|
|
147
188
|
|
|
148
189
|
// Skeleton-owned: dependency check on session_start.
|
|
149
|
-
|
|
190
|
+
// Defer with setTimeout(0) so the notification fires AFTER pi's
|
|
191
|
+
// startup/reload UI rebuild (rebuildChatFromMessages); otherwise it
|
|
192
|
+
// is appended to the chat and immediately wiped.
|
|
193
|
+
const runDependencyCheck = (ctx: ExtensionContext) => {
|
|
150
194
|
if (!ctx.hasUI) return;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
195
|
+
if (dependencyNotifyTimer) clearTimeout(dependencyNotifyTimer);
|
|
196
|
+
dependencyNotifyTimer = setTimeout(() => {
|
|
197
|
+
dependencyNotifyTimer = undefined;
|
|
198
|
+
const missing: string[] = [];
|
|
199
|
+
for (const dep of dependencies) {
|
|
200
|
+
// dontBother flag silences the notification per-binary.
|
|
201
|
+
if (isDontBother(dep.name)) continue;
|
|
202
|
+
missing.push(dep.name);
|
|
159
203
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
204
|
+
if (missing.length) {
|
|
205
|
+
try {
|
|
206
|
+
ctx.ui.notify(
|
|
207
|
+
`[decorated-pi] Some dependencies are missing (${missing.length}). Run /dp-settings → Dependencies to configure.`,
|
|
208
|
+
"info",
|
|
209
|
+
);
|
|
210
|
+
} catch {
|
|
211
|
+
// Extension context may be stale if another reload/session switch happened.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}, 0);
|
|
215
|
+
};
|
|
216
|
+
pi.on("session_start", async (event: any, ctx: ExtensionContext) => {
|
|
217
|
+
// Only check on cold startup and explicit reload — other reasons
|
|
218
|
+
// (new/resume/fork) inherit the existing session's tool/runtime
|
|
219
|
+
// and don't need a fresh check.
|
|
220
|
+
if (event.reason !== "startup" && event.reason !== "reload") return;
|
|
221
|
+
runDependencyCheck(ctx);
|
|
222
|
+
});
|
|
223
|
+
pi.on("session_shutdown", async () => {
|
|
224
|
+
if (!dependencyNotifyTimer) return;
|
|
225
|
+
clearTimeout(dependencyNotifyTimer);
|
|
226
|
+
dependencyNotifyTimer = undefined;
|
|
164
227
|
});
|
|
165
228
|
|
|
166
229
|
// Skeleton-owned: system-prompt options sort for cache stability.
|
|
@@ -179,7 +242,7 @@ export function createSkeleton(): Skeleton {
|
|
|
179
242
|
return {
|
|
180
243
|
modules: modules.map((m) => m.name),
|
|
181
244
|
events,
|
|
182
|
-
dependencies: dependencies.map((d) => ({
|
|
245
|
+
dependencies: dependencies.map((d) => ({ name: d.name, module: d.module, hint: d.hint })),
|
|
183
246
|
};
|
|
184
247
|
},
|
|
185
248
|
};
|