decorated-pi 0.5.4 → 0.6.0

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 (68) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/hooks/session-title.ts +54 -0
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
  43. package/{extensions/io.ts → tools/patch/index.ts} +29 -205
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/session-title.ts +0 -40
  59. package/extensions/slash.ts +0 -458
  60. package/extensions/smart-at.ts +0 -481
  61. package/extensions/subdir-agents.ts +0 -151
  62. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  64. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  65. /package/{extensions → tools}/lsp/env.ts +0 -0
  66. /package/{extensions → tools}/lsp/manager.ts +0 -0
  67. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  68. /package/{extensions → tools}/lsp/protocol.ts +0 -0
@@ -1,55 +1,20 @@
1
1
  /**
2
- * RTK integration Rewrite bash commands through system-installed RTK
2
+ * rtkrewrite bash commands through system-installed RTK.
3
3
  *
4
- * Uses `rtk rewrite` as a preflight step. If RTK is not installed on PATH,
5
- * this module stays inactive. When a rewritten RTK command fails, the original
6
- * command is executed once as a fallback.
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.
7
6
  */
8
7
 
9
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
- import { createBashToolDefinition, createLocalBashOperations, isToolCallEventType } from "@earendil-works/pi-coding-agent";
9
+ import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
11
10
  import { Text } from "@earendil-works/pi-tui";
12
11
  import { execFileSync, spawnSync } from "node:child_process";
13
12
  import * as fs from "node:fs";
14
13
  import * as os from "node:os";
15
14
  import * as path from "node:path";
15
+ import type { Module, Skeleton } from "./skeleton.js";
16
16
 
17
- let rtkBinary: string | null = null;
18
-
19
- interface PiShellSettings {
20
- shellPath?: string;
21
- shellCommandPrefix?: string;
22
- }
23
-
24
- export interface DependencyStatus {
25
- module: string;
26
- label: string;
27
- state: "ok" | "missing" | "n/a";
28
- detail?: string;
29
- }
30
-
31
- function readJsonObject(filePath: string): Record<string, unknown> {
32
- try {
33
- if (!fs.existsSync(filePath)) return {};
34
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
35
- return parsed && typeof parsed === "object" ? parsed : {};
36
- } catch {
37
- return {};
38
- }
39
- }
40
-
41
- function loadPiShellSettings(cwd: string): PiShellSettings {
42
- const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
43
- const globalSettings = readJsonObject(path.join(agentDir, "settings.json"));
44
- const projectSettings = readJsonObject(path.join(cwd, ".pi", "settings.json"));
45
- const merged = { ...globalSettings, ...projectSettings } as Record<string, unknown>;
46
- const result: PiShellSettings = {};
47
- if (typeof merged.shellPath === "string" && merged.shellPath.trim()) result.shellPath = merged.shellPath;
48
- if (typeof merged.shellCommandPrefix === "string" && merged.shellCommandPrefix.trim()) {
49
- result.shellCommandPrefix = merged.shellCommandPrefix;
50
- }
51
- return result;
52
- }
17
+ // ─── Locating RTK ─────────────────────────────────────────────────────────
53
18
 
54
19
  export function findSystemRtk(): string | null {
55
20
  try {
@@ -74,27 +39,47 @@ export function buildRtkCommand(raw: string, rtkBinaryPath: string): string {
74
39
  }
75
40
 
76
41
  export function rewriteWithRtk(command: string, rtkPath: string): string | null {
77
-
78
- // NOTE:
79
- // Some RTK versions return a non-zero exit code even when `rtk rewrite`
80
- // successfully prints a rewritten command to stdout (observed locally with
81
- // RTK 0.42.0 returning exit code 3 on success). Because of that, we treat
82
- // non-empty stdout as the source of truth and ignore the process exit code
83
- // here. Empty stdout still means “no rewrite available”.
84
- const result = spawnSync(rtkPath, ["rewrite", command], {
85
- encoding: "utf-8",
86
- timeout: 2000,
87
- });
42
+ const result = spawnSync(rtkPath, ["rewrite", command], { encoding: "utf-8", timeout: 2000 });
88
43
  const raw = (result.stdout ?? "").trim();
89
44
  if (!raw) return null;
90
45
  return buildRtkCommand(raw, rtkPath);
91
46
  }
92
47
 
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
+ }
77
+
93
78
  export function appendStatus(text: string, status: string): string {
94
79
  return text ? `${text}\n\n${status}` : status;
95
80
  }
96
81
 
97
- function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
82
+ export function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
98
83
  const command = typeof args?.command === "string" ? args.command : null;
99
84
  const timeout = typeof args?.timeout === "number" ? args.timeout : undefined;
100
85
  const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
@@ -114,56 +99,75 @@ export async function executeOriginalBash(command: string, cwd: string, timeout:
114
99
  const output = getOutput() || "(no output)";
115
100
  if (result.exitCode !== 0 && result.exitCode !== null) {
116
101
  return {
117
- content: [{ type: "text" as const, text: appendStatus(output, `Command exited with code ${result.exitCode}`) }],
102
+ content: [{ type: "text" as const, text: output ? `${output}\n\nCommand exited with code ${result.exitCode}` : `Command exited with code ${result.exitCode}` }],
118
103
  details: undefined,
119
104
  isError: true,
120
105
  };
121
106
  }
122
- return {
123
- content: [{ type: "text" as const, text: output }],
124
- details: undefined,
125
- isError: false,
126
- };
107
+ return { content: [{ type: "text" as const, text: output }], details: undefined, isError: false };
127
108
  } catch (err) {
128
109
  const output = getOutput();
129
110
  if (err instanceof Error && err.message === "aborted") {
130
- return {
131
- content: [{ type: "text" as const, text: appendStatus(output, "Command aborted") }],
132
- details: undefined,
133
- isError: true,
134
- };
111
+ return { content: [{ type: "text" as const, text: output ? `${output}\n\nCommand aborted` : "Command aborted" }], details: undefined, isError: true };
135
112
  }
136
113
  if (err instanceof Error && err.message.startsWith("timeout:")) {
137
114
  const timeoutSecs = err.message.split(":")[1];
138
- return {
139
- content: [{ type: "text" as const, text: appendStatus(output, `Command timed out after ${timeoutSecs} seconds`) }],
140
- details: undefined,
141
- isError: true,
142
- };
115
+ return { content: [{ type: "text" as const, text: output ? `${output}\n\nCommand timed out after ${timeoutSecs} seconds` : `Command timed out after ${timeoutSecs} seconds` }], details: undefined, isError: true };
143
116
  }
144
- return {
145
- content: [{ type: "text" as const, text: appendStatus(output, err instanceof Error ? err.message : "Command failed") }],
146
- details: undefined,
147
- isError: true,
148
- };
117
+ return { content: [{ type: "text" as const, text: output ? `${output}\n\n${err instanceof Error ? err.message : "Command failed"}` : (err instanceof Error ? err.message : "Command failed") }], details: undefined, isError: true };
149
118
  }
150
119
  }
151
120
 
152
- export function getRtkDependencyStatuses(): DependencyStatus[] {
153
- return [{
154
- module: "rtk",
155
- label: "rtk",
156
- state: findSystemRtk() ? "ok" : "missing",
157
- detail: "Install RTK so bash rewrite/tagging can activate.",
158
- }];
159
- }
121
+ // ─── Module + setup ──────────────────────────────────────────────────────
160
122
 
161
- export function setupRtkIntegration(pi: ExtensionAPI) {
123
+ let rtkBinary: string | null = null;
124
+ const rewrittenCommands = new Map<string, { originalCommand: string; timeout?: number }>();
125
+ const rewriteabilityCache = new Map<string, boolean>();
126
+
127
+ export const rtkModule: Module = {
128
+ name: "rtk",
129
+ hooks: {
130
+ tool_call: [
131
+ (event) => {
132
+ if (event.toolName !== "bash") return;
133
+ const command = event.input?.command;
134
+ if (!command || typeof command !== "string" || !command.trim()) return;
135
+ const rewritten = rewriteWithRtk(command, rtkBinary!);
136
+ rewriteabilityCache.set(command, rewritten !== null);
137
+ if (!rewritten) return;
138
+ rewrittenCommands.set(event.toolCallId, { originalCommand: command, timeout: event.input?.timeout });
139
+ event.input.command = rewritten;
140
+ },
141
+ ],
142
+ tool_result: [
143
+ async (event, ctx) => {
144
+ if (event.toolName !== "bash") return;
145
+ const pending = rewrittenCommands.get(event.toolCallId);
146
+ if (!pending) return;
147
+ rewrittenCommands.delete(event.toolCallId);
148
+ if (!event.isError) return;
149
+ return executeOriginalBash(pending.originalCommand, ctx.cwd, pending.timeout, ctx.signal);
150
+ },
151
+ ],
152
+ session_shutdown: [
153
+ () => {
154
+ rewrittenCommands.clear();
155
+ rewriteabilityCache.clear();
156
+ },
157
+ ],
158
+ },
159
+ };
160
+
161
+ export function setupRtk(sk: Skeleton, pi: ExtensionAPI): void {
162
162
  rtkBinary = findSystemRtk();
163
+ sk.declareDependency({
164
+ label: "rtk",
165
+ check: () => findSystemRtk() !== null,
166
+ hint: "Install RTK so bash rewrite/tagging can activate.",
167
+ });
163
168
  if (!rtkBinary) return;
164
169
 
165
- const rewrittenCommands = new Map<string, { originalCommand: string; timeout?: number }>();
166
- const rewriteabilityCache = new Map<string, boolean>();
170
+ // Register a wrapped bash tool that shows [RTK] tag in TUI.
167
171
  const shellSettings = loadPiShellSettings(process.cwd());
168
172
  const bashTool = createBashToolDefinition(process.cwd(), {
169
173
  shellPath: shellSettings.shellPath,
@@ -172,9 +176,7 @@ export function setupRtkIntegration(pi: ExtensionAPI) {
172
176
  const baseRenderCall = bashTool.renderCall?.bind(bashTool);
173
177
 
174
178
  if (baseRenderCall) {
175
- bashTool.renderCall = (args, theme, context) => {
176
- // Avoid calling baseRenderCall when args.command is not yet available
177
- // to prevent flashing "<invalid command>" in TUI
179
+ bashTool.renderCall = (args: any, theme: any, context: any) => {
178
180
  const command = typeof args?.command === "string" ? args.command : "";
179
181
  if (!command) {
180
182
  const text = context.lastComponent ?? new Text("", 0, 0);
@@ -182,7 +184,6 @@ export function setupRtkIntegration(pi: ExtensionAPI) {
182
184
  text.setText(theme.fg("toolTitle", theme.bold(`$ ${placeholder}`)));
183
185
  return text;
184
186
  }
185
-
186
187
  const component = baseRenderCall(args, theme, context);
187
188
  const predicted = command
188
189
  ? (rewriteabilityCache.get(command) ?? (() => {
@@ -193,36 +194,12 @@ export function setupRtkIntegration(pi: ExtensionAPI) {
193
194
  : false;
194
195
  const rewritten = rewrittenCommands.has(context.toolCallId) || predicted;
195
196
  if (component instanceof Text) {
196
- component.setText(formatBashCallWithTag(args as any, theme, rewritten));
197
+ component.setText(formatBashCallWithTag(args, theme, rewritten));
197
198
  }
198
199
  return component;
199
200
  };
200
201
  }
201
202
 
202
203
  pi.registerTool(bashTool);
203
-
204
- pi.on("tool_call", (event) => {
205
- if (!isToolCallEventType("bash", event)) return;
206
- const command = event.input.command;
207
- if (!command.trim()) return;
208
- const rewritten = rewriteWithRtk(command, rtkBinary!);
209
- rewriteabilityCache.set(command, rewritten !== null);
210
- if (!rewritten) return;
211
- rewrittenCommands.set(event.toolCallId, { originalCommand: command, timeout: event.input.timeout });
212
- event.input.command = rewritten;
213
- });
214
-
215
- pi.on("tool_result", async (event, ctx) => {
216
- if (event.toolName !== "bash") return;
217
- const pending = rewrittenCommands.get(event.toolCallId);
218
- if (!pending) return;
219
- rewrittenCommands.delete(event.toolCallId);
220
- if (!event.isError) return;
221
- return executeOriginalBash(pending.originalCommand, ctx.cwd, pending.timeout, ctx.signal);
222
- });
223
-
224
- pi.on("session_shutdown", () => {
225
- rewrittenCommands.clear();
226
- rewriteabilityCache.clear();
227
- });
204
+ sk.register(rtkModule);
228
205
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * session-title — auto-derive session name from the first user message.
3
+ * Skips if user already manually /renamed.
4
+ */
5
+
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import type { Module } from "./skeleton.js";
8
+
9
+ export const MAX_SESSION_TITLE_LENGTH = 80;
10
+
11
+ interface SessionEntryLike {
12
+ type: string;
13
+ message?: { role: string; content?: unknown };
14
+ }
15
+
16
+ function pickFirstUserText(content: unknown): string | undefined {
17
+ if (typeof content === "string" && content.trim()) return content.trim();
18
+ if (Array.isArray(content)) {
19
+ for (const part of content) {
20
+ if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.trim()) {
21
+ return part.text.trim();
22
+ }
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ /** Extract a single-line title from the first user message. */
29
+ export function extractFirstMessage(entries: SessionEntryLike[]): string | undefined {
30
+ for (const entry of entries) {
31
+ if (entry.type !== "message" || !entry.message || entry.message.role !== "user") continue;
32
+ const raw = pickFirstUserText(entry.message.content);
33
+ if (!raw) continue;
34
+ const nl = raw.indexOf("\n");
35
+ const oneLine = (nl === -1 ? raw : raw.slice(0, nl)).trim();
36
+ if (!oneLine) continue;
37
+ if (oneLine.length <= MAX_SESSION_TITLE_LENGTH) return oneLine;
38
+ return oneLine.slice(0, MAX_SESSION_TITLE_LENGTH - 1) + "…";
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ export const sessionTitleModule: Module = {
44
+ name: "session-title",
45
+ hooks: {
46
+ session_start: [
47
+ (_event, ctx, pi) => {
48
+ if (ctx.sessionManager.getSessionName()) return;
49
+ const title = extractFirstMessage(ctx.sessionManager.getBranch());
50
+ if (title) (pi as any).setSessionName(title);
51
+ },
52
+ ],
53
+ },
54
+ };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Skeleton — the only place that calls pi.on(...) for hooks.
3
+ *
4
+ * sk.register(module) → installs module's hook handlers
5
+ * sk.declareDependency({...}) → skeleton checks on session_start
6
+ * sk.declareGuideline("...") → skeleton injects on before_agent_start
7
+ * sk.install(pi) → call once, after all setup<X> calls
8
+ *
9
+ * Handlers receive (event, ctx, pi) so they can call pi.* APIs
10
+ * (setSessionName, registerTool, appendEntry, etc.).
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ // ─── Event union ───────────────────────────────────────────────────────────
16
+
17
+ export type HookEvent =
18
+ | "session_start"
19
+ | "session_shutdown"
20
+ | "session_compact"
21
+ | "session_before_compact"
22
+ | "before_agent_start"
23
+ | "agent_start"
24
+ | "agent_end"
25
+ | "input"
26
+ | "tool_call"
27
+ | "tool_result";
28
+
29
+ // ─── Handler modes ─────────────────────────────────────────────────────────
30
+
31
+ /** Parallel: all handlers run, return values ignored. */
32
+ export type ParallelHandler<E extends HookEvent> = (
33
+ event: any,
34
+ ctx: ExtensionContext,
35
+ pi: ExtensionAPI,
36
+ ) => void | Promise<void>;
37
+
38
+ /** Compose: next handler receives previous return value. */
39
+ export type ComposeHandler<E extends HookEvent> = (
40
+ event: any,
41
+ ctx: ExtensionContext,
42
+ pi: ExtensionAPI,
43
+ ) => any | Promise<any>;
44
+
45
+ export interface Module {
46
+ readonly name: string;
47
+ readonly hooks: {
48
+ session_start?: ParallelHandler<"session_start">[];
49
+ session_shutdown?: ParallelHandler<"session_shutdown">[];
50
+ session_compact?: ParallelHandler<"session_compact">[];
51
+ session_before_compact?: ParallelHandler<"session_before_compact">[];
52
+ before_agent_start?: ComposeHandler<"before_agent_start">[];
53
+ agent_start?: ParallelHandler<"agent_start">[];
54
+ agent_end?: ParallelHandler<"agent_end">[];
55
+ input?: ParallelHandler<"input">[];
56
+ tool_call?: ComposeHandler<"tool_call">[];
57
+ tool_result?: ComposeHandler<"tool_result">[];
58
+ };
59
+ }
60
+
61
+ // ─── Declarations ──────────────────────────────────────────────────────────
62
+
63
+ export interface Dependency {
64
+ label: string;
65
+ check: () => boolean;
66
+ hint?: string;
67
+ }
68
+
69
+ /** Collected result shape for a module's declared dependency. Modules
70
+ * expose a function that returns a list of these so the UI can show
71
+ * "what's currently broken" for the user. The skeleton's own
72
+ * `session_start` handler also walks declared dependencies, but it
73
+ * uses `Dependency.check` directly rather than collecting statuses. */
74
+ export interface DependencyStatus {
75
+ module: string;
76
+ label: string;
77
+ state: "ok" | "missing";
78
+ detail: string;
79
+ }
80
+
81
+ // ─── Skeleton ──────────────────────────────────────────────────────────────
82
+
83
+ const COMPOSE_EVENTS = new Set<HookEvent>([
84
+ "before_agent_start",
85
+ "tool_call",
86
+ "tool_result",
87
+ ]);
88
+
89
+ export interface Skeleton {
90
+ register(module: Module): void;
91
+ declareDependency(dep: Dependency): void;
92
+ install(pi: ExtensionAPI): void;
93
+ inspect(): Inspection;
94
+ }
95
+
96
+ export interface Inspection {
97
+ modules: string[];
98
+ events: Record<string, Array<{ module: string; order: number }>>;
99
+ dependencies: Array<{ label: string; hint?: string }>;
100
+ }
101
+
102
+ export function createSkeleton(): Skeleton {
103
+ const modules: Module[] = [];
104
+ const registry = new Map<HookEvent, Array<{ module: string; handler: (event: any, ctx: ExtensionContext, pi: ExtensionAPI) => any }>>();
105
+ const dependencies: Dependency[] = [];
106
+
107
+ function collect(mod: Module) {
108
+ for (const [event, handlers] of Object.entries(mod.hooks)) {
109
+ if (!handlers?.length) continue;
110
+ const list = registry.get(event as HookEvent) ?? [];
111
+ for (const handler of handlers) {
112
+ list.push({ module: mod.name, handler: handler as any });
113
+ }
114
+ registry.set(event as HookEvent, list);
115
+ }
116
+ }
117
+
118
+ return {
119
+ register(mod) {
120
+ modules.push(mod);
121
+ collect(mod);
122
+ },
123
+
124
+ declareDependency(dep) {
125
+ dependencies.push(dep);
126
+ },
127
+
128
+ install(pi) {
129
+ // Install one pi.on per event, walking registered handlers in order.
130
+ for (const [event, handlers] of registry) {
131
+ if (handlers.length === 0) continue;
132
+ if (COMPOSE_EVENTS.has(event)) {
133
+ pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
134
+ let current = event;
135
+ for (const { handler } of handlers) {
136
+ const result = await handler(current, ctx, pi);
137
+ if (result !== undefined) current = result;
138
+ }
139
+ return current === event ? undefined : current;
140
+ });
141
+ } else {
142
+ pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
143
+ for (const { handler } of handlers) await handler(event, ctx, pi);
144
+ });
145
+ }
146
+ }
147
+
148
+ // Skeleton-owned: dependency check on session_start.
149
+ pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
150
+ 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}`);
159
+ }
160
+ }
161
+ if (anyMissing) {
162
+ ctx.ui.notify(`[decorated-pi] missing dependencies:\n${hints.join("\n")}`, "info");
163
+ }
164
+ });
165
+
166
+ // Skeleton-owned: system-prompt options sort for cache stability.
167
+ // Guideline injection is owned by `index.ts`.
168
+ pi.on("before_agent_start", async (event: any) => {
169
+ if (event.systemPromptOptions) sortSystemPromptOptions(event.systemPromptOptions);
170
+ return undefined;
171
+ });
172
+ },
173
+
174
+ inspect() {
175
+ const events: Inspection["events"] = {};
176
+ for (const [event, handlers] of registry) {
177
+ events[event] = handlers.map((h, i) => ({ module: h.module, order: i }));
178
+ }
179
+ return {
180
+ modules: modules.map((m) => m.name),
181
+ events,
182
+ dependencies: dependencies.map((d) => ({ label: d.label, hint: d.hint })),
183
+ };
184
+ },
185
+ };
186
+ }
187
+
188
+ // ─── System-prompt option sorting ─────────────────────────────────────────
189
+
190
+ /** Sort all fields in systemPromptOptions alphabetically for stable system prompt. */
191
+ export function sortSystemPromptOptions(opts: {
192
+ toolSnippets?: Record<string, string>;
193
+ selectedTools?: string[];
194
+ promptGuidelines?: string[];
195
+ skills?: Array<{ name: string; description: string; filePath: string }>;
196
+ }) {
197
+ const sortedToolNames = Object.keys(opts.toolSnippets ?? {}).sort((a, b) => a.localeCompare(b));
198
+ const sortedToolSnippets: Record<string, string> = {};
199
+ for (const name of sortedToolNames) {
200
+ sortedToolSnippets[name] = opts.toolSnippets![name];
201
+ }
202
+ opts.toolSnippets = sortedToolSnippets;
203
+ if (opts.selectedTools) {
204
+ opts.selectedTools = sortedToolNames;
205
+ }
206
+ if (opts.promptGuidelines) {
207
+ opts.promptGuidelines = [...opts.promptGuidelines].sort((a, b) => a.localeCompare(b));
208
+ }
209
+ if (opts.skills) {
210
+ opts.skills = [...opts.skills].sort((a, b) => a.name.localeCompare(b.name));
211
+ }
212
+ }