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/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" | "waiting_reload";
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
- allServers.set(s.name, { name: s.name, url: s.url ?? s.command ?? "(unknown)", source: s.source, state: "connecting", toolCount: 0, tools: [] });
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
- allServers.set(server.name, {
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
- allServers.set(server.name, {
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
- allServers.set(server.name, {
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 ? "waiting_reload" : "disabled",
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
- allServers.set(serverName, {
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
- allServers.set(serverName, {
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
- return { ok: false, error: "Project is missing required artefacts for this server" };
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
- allServers.set(config.name, { name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source, state: "failed", toolCount: 0, tools: [], error });
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
- allServers.set(config.name, { name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source, state: "failed", toolCount: 0, tools: [], error: msg });
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)) return;
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
- allServers.set(config.name, {
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
- allServers.set(config.name, {
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
 
@@ -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. If RTK is not installed, this module is inactive.
5
- * When a rewritten RTK command fails, the original command is executed once as fallback.
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
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
- try {
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
- // ─── Bash tool registration ──────────────────────────────────────────────
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, pi: ExtensionAPI): void {
118
+ export function setupRtk(sk: Skeleton): void {
162
119
  rtkBinary = findSystemRtk();
163
- sk.declareDependency({
164
- label: "rtk",
165
- check: () => findSystemRtk() !== null,
166
- hint: "Install RTK so bash rewrite/tagging can activate.",
167
- });
168
- if (!rtkBinary) return;
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.registerTool(bashTool);
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({...}) → skeleton checks on session_start
5
+ * sk.declareDependency({...}) → check now, report on session_start
6
6
  * sk.declareGuideline("...") → skeleton injects on before_agent_start
7
- * sk.install(pi) → call once, after all setup<X> calls
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?: ParallelHandler<"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
- label: string;
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
- declareDependency(dep: Dependency): void;
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<{ label: string; hint?: string }>;
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
- declareDependency(dep) {
125
- dependencies.push(dep);
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
- pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
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
- const hints: string[] = [];
152
- let anyMissing = false;
153
- for (const dep of dependencies) {
154
- let ok = false;
155
- try { ok = dep.check(); } catch { ok = false; }
156
- if (!ok) {
157
- anyMissing = true;
158
- if (dep.hint) hints.push(` [${dep.label}] ${dep.hint}`);
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
- if (anyMissing) {
162
- ctx.ui.notify(`[decorated-pi] missing dependencies:\n${hints.join("\n")}`, "info");
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) => ({ label: d.label, hint: d.hint })),
245
+ dependencies: dependencies.map((d) => ({ name: d.name, module: d.module, hint: d.hint })),
183
246
  };
184
247
  },
185
248
  };