decorated-pi 0.7.2 → 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/skeleton.ts CHANGED
@@ -11,6 +11,7 @@
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,10 +73,8 @@ 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;
67
- /** Display/source tag for inspection and notifications. */
68
78
  module?: string;
69
79
  }
70
80
 
@@ -75,9 +85,12 @@ export interface Dependency {
75
85
  * uses `Dependency.check` directly rather than collecting statuses. */
76
86
  export interface DependencyStatus {
77
87
  module: string;
88
+ /** Binary/config key, not necessarily the resolved absolute path. */
78
89
  label: string;
79
90
  state: "ok" | "missing";
80
91
  detail: string;
92
+ /** Resolved executable path when state is ok. */
93
+ path?: string;
81
94
  }
82
95
 
83
96
  // ─── Skeleton ──────────────────────────────────────────────────────────────
@@ -88,10 +101,23 @@ const COMPOSE_EVENTS = new Set<HookEvent>([
88
101
  "tool_result",
89
102
  ]);
90
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
+
91
113
  export interface Skeleton {
92
114
  register(module: Module): void;
93
115
  /** Returns whether the dependency check passed right now. */
94
- declareDependency(dep: Dependency): boolean;
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;
95
121
  install(pi: ExtensionAPI): void;
96
122
  inspect(): Inspection;
97
123
  }
@@ -99,7 +125,7 @@ export interface Skeleton {
99
125
  export interface Inspection {
100
126
  modules: string[];
101
127
  events: Record<string, Array<{ module: string; order: number }>>;
102
- dependencies: Array<{ label: string; module?: string; hint?: string }>;
128
+ dependencies: Array<{ name: string; module?: string; hint?: string }>;
103
129
  }
104
130
 
105
131
  export function createSkeleton(): Skeleton {
@@ -125,13 +151,10 @@ export function createSkeleton(): Skeleton {
125
151
  collect(mod);
126
152
  },
127
153
 
128
- declareDependency(dep) {
129
- dependencies.push(dep);
130
- try {
131
- return dep.check();
132
- } catch {
133
- return false;
134
- }
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);
135
158
  },
136
159
 
137
160
  install(pi) {
@@ -147,6 +170,15 @@ export function createSkeleton(): Skeleton {
147
170
  }
148
171
  return current === event ? undefined : current;
149
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
+ });
150
182
  } else {
151
183
  pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
152
184
  for (const { handler } of handlers) await handler(event, ctx, pi);
@@ -165,13 +197,16 @@ export function createSkeleton(): Skeleton {
165
197
  dependencyNotifyTimer = undefined;
166
198
  const missing: string[] = [];
167
199
  for (const dep of dependencies) {
168
- let ok = false;
169
- try { ok = dep.check(); } catch { ok = false; }
170
- if (!ok) missing.push(dep.label);
200
+ // dontBother flag silences the notification per-binary.
201
+ if (isDontBother(dep.name)) continue;
202
+ missing.push(dep.name);
171
203
  }
172
204
  if (missing.length) {
173
205
  try {
174
- ctx.ui.notify(`[decorated-pi] missing dependencies: ${missing.join(", ")}`, "info");
206
+ ctx.ui.notify(
207
+ `[decorated-pi] Some dependencies are missing (${missing.length}). Run /dp-settings → Dependencies to configure.`,
208
+ "info",
209
+ );
175
210
  } catch {
176
211
  // Extension context may be stale if another reload/session switch happened.
177
212
  }
@@ -207,7 +242,7 @@ export function createSkeleton(): Skeleton {
207
242
  return {
208
243
  modules: modules.map((m) => m.name),
209
244
  events,
210
- dependencies: dependencies.map((d) => ({ label: d.label, module: d.module, hint: d.hint })),
245
+ dependencies: dependencies.map((d) => ({ name: d.name, module: d.module, hint: d.hint })),
211
246
  };
212
247
  },
213
248
  };
package/hooks/smart-at.ts CHANGED
@@ -93,7 +93,15 @@ export const smartAtModule: Module = {
93
93
  session_start: [
94
94
  async (_event: any, ctx: ExtensionContext) => {
95
95
  const cwd = String(ctx.cwd || "").trim();
96
- const created = FileFinder.create({ basePath: cwd || "." });
96
+ // Always opt in to home/root scanning. These flags are opt-in guards
97
+ // in FFF — when cwd is a normal project, they're no-ops; when cwd
98
+ // IS $HOME or /, they let FFF index it. Without them, create() fails
99
+ // outright when cwd is a home/root, leaving the user without @-search.
100
+ const created = FileFinder.create({
101
+ basePath: cwd || ".",
102
+ enableHomeDirScanning: true,
103
+ enableFsRootScanning: true,
104
+ });
97
105
  if (!created.ok) {
98
106
  // FFF not available on this platform; silently skip.
99
107
  return;
@@ -102,11 +110,18 @@ export const smartAtModule: Module = {
102
110
  const finder = created.value;
103
111
  currentFinder = finder;
104
112
 
113
+ let scanWidgetVisible = false;
114
+
105
115
  // Start the scan in the background. We don't wait for it here so
106
- // session_start returns immediately; the provider queries the
107
- // (possibly partial) index and FFF returns whatever it has indexed
108
- // so far. Full results appear as the scan progresses.
109
- void finder.waitForScan(60_000);
116
+ // session_start returns immediately. If a scanning status was shown,
117
+ // clear it when the scan finishes even if no new autocomplete request
118
+ // is triggered afterwards.
119
+ void finder.waitForScan(60_000).then(() => {
120
+ if (currentFinder === finder && !finder.isDestroyed && scanWidgetVisible) {
121
+ scanWidgetVisible = false;
122
+ ctx.ui.setWidget("smart-at", undefined);
123
+ }
124
+ });
110
125
 
111
126
  ctx.ui.addAutocompleteProvider((orig: any) => ({
112
127
  getSuggestions: (
@@ -142,18 +157,47 @@ export const smartAtModule: Module = {
142
157
  return null;
143
158
  }
144
159
 
160
+ // 0 items during the initial scan means FFF is not ready yet.
161
+ // Autocomplete has no non-selectable dropdown state: returning an
162
+ // item would force SelectList to render a selectable "→ ..." row.
163
+ // So use a static below-editor widget while scanning, and clear it
164
+ // once the scan completes (see waitForScan above). After scanning,
165
+ // 0 items just means "no match".
166
+ if (r.value.items.length === 0) {
167
+ if (finder.isScanning()) {
168
+ scanWidgetVisible = true;
169
+ ctx.ui.setWidget(
170
+ "smart-at",
171
+ ["⏳ scanning… (indexing files, please wait)"],
172
+ { placement: "belowEditor" },
173
+ );
174
+ } else {
175
+ scanWidgetVisible = false;
176
+ ctx.ui.setWidget("smart-at", undefined);
177
+ }
178
+ return null;
179
+ }
180
+
145
181
  const result = buildResult(r.value.items, lowerQuery);
146
182
  if (!result) {
147
183
  ctx.ui.setWidget("smart-at", undefined);
148
184
  return null;
149
185
  }
150
186
 
187
+ scanWidgetVisible = false;
151
188
  ctx.ui.setWidget("smart-at", [WIDGET_FOOTER]);
152
189
  return Promise.resolve({ ...result, prefix });
153
190
  },
154
- applyCompletion: (...args: any[]) => {
191
+ applyCompletion: (
192
+ lines: string[],
193
+ cl: number,
194
+ cc: number,
195
+ item: { value: string; label: string },
196
+ prefix: string,
197
+ ) => {
198
+ scanWidgetVisible = false;
155
199
  ctx.ui.setWidget("smart-at", undefined);
156
- return orig.applyCompletion.apply(orig, args);
200
+ return orig.applyCompletion(lines, cl, cc, item, prefix);
157
201
  },
158
202
  shouldTriggerFileCompletion:
159
203
  orig.shouldTriggerFileCompletion?.bind(orig),
package/hooks/wakatime.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { execFile, execFileSync } from "node:child_process";
9
+ import { execFile } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
+ import { resolveDependency } from "../settings.js";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import type { Module, Skeleton } from "./skeleton.js";
15
16
 
@@ -122,36 +123,21 @@ export function buildPluginString(version = PACKAGE_VERSION): string {
122
123
  return `pi/${version} pi/${version}`;
123
124
  }
124
125
 
126
+ export function wakatimeDependencyExtendPath(): string[] {
127
+ return [path.join(os.homedir(), ".wakatime")];
128
+ }
129
+
125
130
  function findWakatimeCliOnPath(): string | null {
126
- try {
127
- if (process.platform === "win32") {
128
- const output = execFileSync("where", ["wakatime-cli"], { encoding: "utf-8" }).trim();
129
- const first = output.split(/\r?\n/)[0]?.trim();
130
- return first ? path.resolve(first) : null;
131
- }
132
- const shell = process.env.SHELL || "sh";
133
- const output = execFileSync(shell, ["-lc", "command -v wakatime-cli"], { encoding: "utf-8" }).trim();
134
- return output ? path.resolve(output) : null;
135
- } catch {
136
- return null;
137
- }
131
+ return resolveDependency("wakatime-cli", { extendPath: wakatimeDependencyExtendPath() });
138
132
  }
139
133
 
140
134
  export function findWakatimeCli(options: {
141
135
  probePath?: () => string | null;
142
- exists?: (candidate: string) => boolean;
143
- fallbackPath?: string;
144
136
  } = {}): string | null {
145
137
  if (cachedWakatimeCliPath !== undefined) return cachedWakatimeCliPath;
146
138
  const probePath = options.probePath ?? findWakatimeCliOnPath;
147
- const exists = options.exists ?? fs.existsSync;
148
- const fromPath = probePath();
149
- if (fromPath) {
150
- cachedWakatimeCliPath = path.resolve(fromPath);
151
- return cachedWakatimeCliPath;
152
- }
153
- const fallback = path.resolve(options.fallbackPath ?? WAKATIME_CLI_FALLBACK);
154
- cachedWakatimeCliPath = exists(fallback) ? fallback : null;
139
+ const found = probePath();
140
+ cachedWakatimeCliPath = found ? path.resolve(found) : null;
155
141
  return cachedWakatimeCliPath;
156
142
  }
157
143
 
@@ -342,13 +328,14 @@ export function setupWakatimeWithApiKey(
342
328
  export function setupWakatime(sk: Skeleton, pi: ExtensionAPI): void {
343
329
  const apiKey = readWakatimeCfgApiKey();
344
330
  const cliPath = findWakatimeCli();
345
- const ready = sk.declareDependency({
346
- label: "wakatime-cli",
347
- module: "wakatime",
348
- check: () => findWakatimeCli() !== null,
349
- hint: "Install wakatime-cli to track coding activity.",
350
- });
351
- if (!apiKey || !cliPath || !ready) return;
331
+ if (!cliPath) {
332
+ sk.declareMissing({
333
+ name: "wakatime-cli",
334
+ module: "wakatime",
335
+ hint: "Install wakatime-cli to track coding activity.",
336
+ });
337
+ }
338
+ if (!apiKey || !cliPath) return;
352
339
 
353
340
  const sendHeartbeat: HeartbeatSender = (hb, cwd) => sendHeartbeatViaCli(hb, apiKey, cliPath, cwd);
354
341
 
package/index.ts CHANGED
@@ -214,7 +214,7 @@ export default async function (pi: ExtensionAPI) {
214
214
 
215
215
  // Compaction + RTK (these also install their own pi.on via setup<>()).
216
216
  setupCompaction(sk);
217
- if (isModuleEnabled("rtk")) setupRtk(sk, pi);
217
+ if (isModuleEnabled("rtk")) setupRtk(sk);
218
218
  if (isModuleEnabled("wakatime")) setupWakatime(sk, pi);
219
219
 
220
220
  // ── Tools (conditional on module switches) ────────────────────────────
@@ -225,11 +225,13 @@ export default async function (pi: ExtensionAPI) {
225
225
  registerLspTools(pi, new LspServerManager());
226
226
  }
227
227
  for (const dep of lspDeps) {
228
- sk.declareDependency({
229
- label: `lsp:${dep.label}`,
230
- module: `lsp:${dep.label}`,
231
- check: () => collectLspDependencyStatuses(process.cwd()).some((s) => s.label === dep.label && s.state === "ok"),
232
- });
228
+ if (dep.state !== "ok") {
229
+ sk.declareMissing({
230
+ name: dep.label,
231
+ module: "lsp",
232
+ hint: dep.detail,
233
+ });
234
+ }
233
235
  }
234
236
  }
235
237
  if (isModuleEnabled("ask")) registerAskTool(pi);
@@ -245,11 +247,13 @@ export default async function (pi: ExtensionAPI) {
245
247
  sk.register(mcpModule);
246
248
  const mcpDeps = collectMcpDependencyStatuses(process.cwd());
247
249
  for (const dep of mcpDeps) {
248
- sk.declareDependency({
249
- label: dep.module,
250
- module: dep.module,
251
- check: () => collectMcpDependencyStatuses(process.cwd()).some((s) => s.module === dep.module && s.state === "ok"),
252
- });
250
+ if (dep.state !== "ok") {
251
+ sk.declareMissing({
252
+ name: dep.label, // binary name (e.g. "codegraph")
253
+ module: "mcp",
254
+ hint: dep.detail,
255
+ });
256
+ }
253
257
  }
254
258
  const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
255
259
  // Per-server readiness: cache hit → register from cache (fast).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "decorated-pi is a practical enhancement pack for pi coding agent — token-efficient workflow, cache-friendly design, and smarter tools.",
5
5
  "keywords": [
6
6
  "pi",
package/settings.ts CHANGED
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import * as os from "node:os";
9
9
  import type { Model } from "@earendil-works/pi-ai";
10
+ import { which } from "./utils/which.js";
10
11
 
11
12
  const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
12
13
  const CONFIG_FILE = path.join(CONFIG_DIR, "decorated-pi.json");
@@ -69,15 +70,46 @@ export interface UsageIndexEntry {
69
70
  mtime: number;
70
71
  }
71
72
 
73
+ /** Per-binary dependency override.
74
+ *
75
+ * Keyed by binary name (e.g. "rtk", "wakatime-cli", "gopls",
76
+ * "codegraph"). `path` injects an extra search location into which();
77
+ * `dontBother` silences missing-dependency notifications for this binary. */
78
+ export interface DependencySettings {
79
+ /** Absolute path to the binary (file) or a directory to search. Injected
80
+ * into which()'s extendPath, so it's tried before $PATH. */
81
+ path?: string;
82
+ /** When true, skeleton's missing-dependency notification skips this
83
+ * binary. Use for binaries the user doesn't care about. */
84
+ dontBother?: boolean;
85
+ }
86
+
87
+ export interface DependencyView extends DependencySettings {
88
+ /** Runtime-only shadow value: what the resolver actually found.
89
+ * Not persisted to decorated-pi.json and not written by /dp-settings. */
90
+ resolvedPath?: string;
91
+ /** Runtime-only shadow value: whether the resolver checked/found it. */
92
+ resolvedState?: "ok" | "missing";
93
+ }
94
+
72
95
  export interface DecoratedPiConfig {
73
96
  imageModelKey?: string | null;
74
97
  compactModelKey?: string | null;
98
+ dependencies?: Record<string, DependencySettings>;
75
99
  providers?: Record<string, ProviderCache>;
76
100
  modules?: ModuleSettings;
77
101
  mcpServers?: Record<string, McpServerEntry>;
78
102
  usageIndex?: Record<string, UsageIndexEntry>;
79
103
  }
80
104
 
105
+ /** Runtime view = real config plus in-memory shadow fields. The shadow has
106
+ * the same top-level shape as config, but may enrich selected leaves with
107
+ * runtime-only data. It is never persisted and /dp-settings writes only the
108
+ * real config through setter functions. */
109
+ export interface DecoratedPiConfigView extends Omit<DecoratedPiConfig, "dependencies"> {
110
+ dependencies?: Record<string, DependencyView>;
111
+ }
112
+
81
113
  export function loadConfig(): DecoratedPiConfig {
82
114
  try {
83
115
  if (fs.existsSync(CONFIG_FILE)) {
@@ -99,6 +131,11 @@ export function saveConfig(config: Partial<DecoratedPiConfig>) {
99
131
  fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2), "utf-8");
100
132
  }
101
133
 
134
+ // Runtime-only shadow for the whole config. It mirrors DecoratedPiConfig's
135
+ // top-level shape but is never persisted; runtime modules update it, and
136
+ // /dp-settings reads the merged view for display only.
137
+ const configShadow: DecoratedPiConfigView = {};
138
+
102
139
  // ─── 辅助 ──────────────────────────────────────────────────────────────────
103
140
 
104
141
  export function formatModelKey(m: Model<any>): string {
@@ -165,6 +202,77 @@ export function getCompactModelKey(): string | null {
165
202
  return loadConfig().compactModelKey ?? null;
166
203
  }
167
204
 
205
+ /** Look up a binary's configured path override. Returns null when not set. */
206
+ export function getDependencyPath(name: string): string | null {
207
+ return loadConfig().dependencies?.[name]?.path ?? null;
208
+ }
209
+
210
+ /** Whether missing-dependency notifications are silenced for this binary. */
211
+ export function isDontBother(name: string): boolean {
212
+ return loadConfig().dependencies?.[name]?.dontBother === true;
213
+ }
214
+
215
+ export function getConfigView(): DecoratedPiConfigView {
216
+ const real = loadConfig();
217
+ const dependencyNames = new Set([
218
+ ...Object.keys(real.dependencies ?? {}),
219
+ ...Object.keys(configShadow.dependencies ?? {}),
220
+ ]);
221
+ const dependencies: Record<string, DependencyView> = {};
222
+ for (const name of dependencyNames) {
223
+ dependencies[name] = {
224
+ ...(real.dependencies?.[name] ?? {}),
225
+ ...(configShadow.dependencies?.[name] ?? {}),
226
+ };
227
+ }
228
+ return {
229
+ ...real,
230
+ ...configShadow,
231
+ dependencies: Object.keys(dependencies).length ? dependencies : undefined,
232
+ };
233
+ }
234
+
235
+ export function getDependencyView(name: string): DependencyView {
236
+ return getConfigView().dependencies?.[name] ?? {};
237
+ }
238
+
239
+ export function listDependencyViewNames(extraNames: string[] = []): string[] {
240
+ return [...new Set([
241
+ ...extraNames,
242
+ ...Object.keys(getConfigView().dependencies ?? {}),
243
+ ])].sort();
244
+ }
245
+
246
+ export function recordDependencyResolution(name: string, resolvedPath: string | null): void {
247
+ if (!configShadow.dependencies) configShadow.dependencies = {};
248
+ configShadow.dependencies[name] = {
249
+ ...(configShadow.dependencies[name] ?? {}),
250
+ ...(resolvedPath
251
+ ? { resolvedState: "ok" as const, resolvedPath }
252
+ : { resolvedState: "missing" as const, resolvedPath: undefined }),
253
+ };
254
+ }
255
+
256
+ /** Resolve a binary using dependency config plus caller-specific search
257
+ * locations. Modules call this at startup/runtime; it records a shadow
258
+ * view for /dp-settings but does not persist anything.
259
+ *
260
+ * Order:
261
+ * 1. dependencies[name].path (file or directory)
262
+ * 2. opts.extendPath entries (file or directory)
263
+ * 3. $PATH
264
+ */
265
+ export function resolveDependency(name: string, opts?: { extendPath?: string[] }): string | null {
266
+ const override = getDependencyPath(name);
267
+ const extendPath = [
268
+ ...(override ? [override] : []),
269
+ ...(opts?.extendPath ?? []),
270
+ ];
271
+ const resolved = which(name, { extendPath });
272
+ recordDependencyResolution(name, resolved);
273
+ return resolved;
274
+ }
275
+
168
276
  // ─── Setter ─────────────────────────────────────────────────────────────────
169
277
 
170
278
  export function setImageModelKey(key: string | null) {
@@ -175,6 +283,47 @@ export function setCompactModelKey(key: string | null) {
175
283
  saveConfig({ compactModelKey: key });
176
284
  }
177
285
 
286
+ /** Set or clear a binary's path override. Pass null to remove the path.
287
+ * Preserves any dontBother flag on the same entry (精准修改不覆盖). */
288
+ export function setDependencyPath(name: string, path: string | null) {
289
+ const current = loadConfig();
290
+ const deps = { ...(current.dependencies ?? {}) };
291
+ const existing = deps[name] ?? {};
292
+ if (path === null) {
293
+ const { path: _drop, ...rest } = existing;
294
+ if (Object.keys(rest).length === 0) {
295
+ delete deps[name];
296
+ } else {
297
+ deps[name] = rest;
298
+ }
299
+ } else {
300
+ deps[name] = { ...existing, path };
301
+ }
302
+ // Path edits are config writes; invalidate runtime-only resolution for
303
+ // this binary so /dp-settings never shows a stale pre-edit shadow path.
304
+ if (configShadow.dependencies?.[name]) delete configShadow.dependencies[name];
305
+ saveConfig({ dependencies: deps });
306
+ }
307
+
308
+ /** Set or clear a binary's dontBother flag. Pass false to re-enable
309
+ * missing-dependency notifications. Preserves any path override. */
310
+ export function setDontBother(name: string, dontBother: boolean) {
311
+ const current = loadConfig();
312
+ const deps = { ...(current.dependencies ?? {}) };
313
+ const existing = deps[name] ?? {};
314
+ if (dontBother) {
315
+ deps[name] = { ...existing, dontBother: true };
316
+ } else {
317
+ const { dontBother: _drop, ...rest } = existing;
318
+ if (Object.keys(rest).length === 0) {
319
+ delete deps[name];
320
+ } else {
321
+ deps[name] = rest;
322
+ }
323
+ }
324
+ saveConfig({ dependencies: deps });
325
+ }
326
+
178
327
  // ─── Module Switches ──────────────────────────────────────────────────────────
179
328
 
180
329
  const DEFAULT_MODULES: Required<ModuleSettings> = {
@@ -321,14 +470,23 @@ export function getAllModuleSettings(): Required<ModuleSettings> {
321
470
  * reload is actually necessary.
322
471
  */
323
472
  let loadedModuleSnapshot: Required<ModuleSettings> | null = null;
473
+ let loadedDependenciesSnapshot: Record<string, DependencySettings> | null = null;
324
474
 
325
475
  export function captureModuleSnapshot(): void {
326
476
  loadedModuleSnapshot = getAllModuleSettings();
477
+ loadedDependenciesSnapshot = loadConfig().dependencies ?? {};
327
478
  }
328
479
 
329
480
  export function moduleSnapshotChanged(): boolean {
330
481
  if (!loadedModuleSnapshot) return true;
331
- return JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings());
482
+ if (JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings())) return true;
483
+ // Dependencies changes don't strictly require reload (which() reads the
484
+ // config file on every call), but prompt for consistency — the user
485
+ // might be mid-session and expect the new path to be picked up by hooks
486
+ // that captured the binary path at startup (rtk/wakatime).
487
+ const currentDeps = loadConfig().dependencies ?? {};
488
+ if (JSON.stringify(loadedDependenciesSnapshot) !== JSON.stringify(currentDeps)) return true;
489
+ return false;
332
490
  }
333
491
 
334
492
  // ─── Usage index (增量同步元数据) ─────────────────────────────────────────────
@@ -19,9 +19,6 @@ const askQuestionSchema = Type.Object({
19
19
  question: Type.String({ description: "Question text shown to the user." }),
20
20
  options: Type.Optional(Type.Array(Type.String(), { description: "Options for single or multi choice." })),
21
21
  default: Type.Optional(Type.String({ description: "Default answer. For multi, comma-separated values." })),
22
- allowCustom: Type.Optional(Type.Boolean({
23
- description: "For single/multi: append an \"Other\" row that toggles into a free-text input. Lets the user enter a custom answer not in the preset list.",
24
- })),
25
22
  });
26
23
 
27
24
  function formatAnswer(q: AskQuestion, value: string | string[]): string {