@oh-my-pi/pi-coding-agent 3.30.0 → 3.32.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 (158) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +367 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/sdk.ts +10 -2
  9. package/src/core/session-manager.ts +158 -246
  10. package/src/core/session-storage.ts +379 -0
  11. package/src/core/settings-manager.ts +155 -4
  12. package/src/core/slash-commands.ts +39 -13
  13. package/src/core/system-prompt.ts +62 -64
  14. package/src/core/tools/ask.ts +5 -4
  15. package/src/core/tools/bash-interceptor.ts +26 -61
  16. package/src/core/tools/bash.ts +13 -8
  17. package/src/core/tools/edit-diff.ts +11 -4
  18. package/src/core/tools/edit.ts +7 -13
  19. package/src/core/tools/find.ts +111 -50
  20. package/src/core/tools/gemini-image.ts +128 -147
  21. package/src/core/tools/grep.ts +397 -415
  22. package/src/core/tools/index.test.ts +5 -1
  23. package/src/core/tools/index.ts +8 -4
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +84 -19
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +72 -35
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +150 -74
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/commands.ts +4 -0
  37. package/src/core/tools/task/executor.ts +94 -83
  38. package/src/core/tools/task/index.ts +130 -92
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +15 -6
  42. package/src/core/tools/task/worker.ts +124 -89
  43. package/src/core/tools/web-fetch.ts +112 -377
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  49. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  50. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  51. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  52. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  53. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  54. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  57. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  59. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  60. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  61. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  62. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  63. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  64. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  71. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  72. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  73. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  74. package/src/core/tools/web-scrapers/index.ts +250 -0
  75. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  76. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  79. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  82. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  83. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  84. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  86. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  87. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  90. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  93. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  96. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  99. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  102. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  103. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  104. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  105. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  106. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  107. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  111. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  113. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  114. package/src/core/tools/web-scrapers/utils.ts +162 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  116. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  117. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  118. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  119. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  120. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  121. package/src/core/tools/write.ts +21 -18
  122. package/src/core/voice.ts +3 -2
  123. package/src/lib/worktree/collapse.ts +2 -1
  124. package/src/lib/worktree/git.ts +2 -18
  125. package/src/main.ts +59 -3
  126. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  127. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  128. package/src/modes/interactive/components/hook-editor.ts +2 -1
  129. package/src/modes/interactive/components/model-selector.ts +19 -4
  130. package/src/modes/interactive/interactive-mode.ts +41 -63
  131. package/src/modes/interactive/theme/theme.ts +58 -58
  132. package/src/modes/rpc/rpc-mode.ts +10 -9
  133. package/src/prompts/review-request.md +27 -0
  134. package/src/prompts/reviewer.md +64 -68
  135. package/src/prompts/tools/output.md +22 -3
  136. package/src/prompts/tools/task.md +32 -33
  137. package/src/utils/clipboard.ts +2 -1
  138. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  139. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  140. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  156. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  157. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  158. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -0,0 +1,379 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ readSync,
9
+ statSync,
10
+ writeFileSync,
11
+ writeSync,
12
+ } from "node:fs";
13
+ import { rename as renameAsync } from "node:fs/promises";
14
+ import { dirname, join } from "node:path";
15
+
16
+ export interface SessionStorageStat {
17
+ size: number;
18
+ mtimeMs: number;
19
+ mtime: Date;
20
+ }
21
+
22
+ export interface SessionStorageWriter {
23
+ writeLine(line: string): Promise<void>;
24
+ flush(): Promise<void>;
25
+ fsync(): Promise<void>;
26
+ close(): Promise<void>;
27
+ getError(): Error | undefined;
28
+ }
29
+
30
+ export interface SessionStorage {
31
+ ensureDirSync(dir: string): void;
32
+ existsSync(path: string): boolean;
33
+ readTextSync(path: string): string;
34
+ readTextPrefixSync(path: string, buf: Buffer): number;
35
+ writeTextSync(path: string, content: string): void;
36
+ statSync(path: string): SessionStorageStat;
37
+ listFilesSync(dir: string, pattern: string): string[];
38
+
39
+ exists(path: string): Promise<boolean>;
40
+ readText(path: string): Promise<string>;
41
+ writeText(path: string, content: string): Promise<void>;
42
+ rename(path: string, nextPath: string): Promise<void>;
43
+ unlink(path: string): Promise<void>;
44
+ fsyncDirSync(dir: string): void;
45
+ openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter;
46
+ }
47
+
48
+ function toError(value: unknown): Error {
49
+ return value instanceof Error ? value : new Error(String(value));
50
+ }
51
+
52
+ // FinalizationRegistry to clean up leaked file descriptors
53
+ const writerRegistry = new FinalizationRegistry<number>((fd) => {
54
+ try {
55
+ closeSync(fd);
56
+ } catch {
57
+ // Ignore - fd may already be closed or invalid
58
+ }
59
+ });
60
+
61
+ class FileSessionStorageWriter implements SessionStorageWriter {
62
+ private fd: number;
63
+ private closed = false;
64
+ private error: Error | undefined;
65
+ private onError: ((err: Error) => void) | undefined;
66
+
67
+ constructor(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
68
+ this.onError = options?.onError;
69
+ const flags = options?.flags ?? "a";
70
+ // Ensure parent directory exists
71
+ const dir = dirname(path);
72
+ if (!existsSync(dir)) {
73
+ mkdirSync(dir, { recursive: true });
74
+ }
75
+ // Open file once, keep fd for lifetime
76
+ this.fd = openSync(path, flags === "w" ? "w" : "a");
77
+ // Register for cleanup if abandoned without close()
78
+ writerRegistry.register(this, this.fd, this);
79
+ }
80
+
81
+ private recordError(err: unknown): Error {
82
+ const error = toError(err);
83
+ if (!this.error) this.error = error;
84
+ this.onError?.(error);
85
+ return error;
86
+ }
87
+
88
+ async writeLine(line: string): Promise<void> {
89
+ if (this.closed) throw new Error("Writer closed");
90
+ if (this.error) throw this.error;
91
+ try {
92
+ const buf = Buffer.from(line, "utf-8");
93
+ let offset = 0;
94
+ while (offset < buf.length) {
95
+ const written = writeSync(this.fd, buf, offset, buf.length - offset);
96
+ if (written === 0) {
97
+ throw new Error("Short write");
98
+ }
99
+ offset += written;
100
+ }
101
+ } catch (err) {
102
+ throw this.recordError(err);
103
+ }
104
+ }
105
+
106
+ async flush(): Promise<void> {
107
+ if (this.error) throw this.error;
108
+ // OS buffers are flushed on fsync, nothing to do here
109
+ }
110
+
111
+ async fsync(): Promise<void> {
112
+ if (this.closed) throw new Error("Writer closed");
113
+ if (this.error) throw this.error;
114
+ try {
115
+ fsyncSync(this.fd);
116
+ } catch (err) {
117
+ throw this.recordError(err);
118
+ }
119
+ }
120
+
121
+ async close(): Promise<void> {
122
+ if (this.closed) return;
123
+ this.closed = true;
124
+ // Unregister from finalization - we're closing properly
125
+ writerRegistry.unregister(this);
126
+ try {
127
+ closeSync(this.fd);
128
+ } catch {
129
+ // Ignore close errors
130
+ }
131
+ }
132
+
133
+ getError(): Error | undefined {
134
+ return this.error;
135
+ }
136
+ }
137
+
138
+ export class FileSessionStorage implements SessionStorage {
139
+ ensureDirSync(dir: string): void {
140
+ if (!existsSync(dir)) {
141
+ mkdirSync(dir, { recursive: true });
142
+ }
143
+ }
144
+
145
+ existsSync(path: string): boolean {
146
+ return existsSync(path);
147
+ }
148
+
149
+ readTextSync(path: string): string {
150
+ return readFileSync(path, "utf-8");
151
+ }
152
+
153
+ readTextPrefixSync(path: string, buf: Buffer): number {
154
+ const fd = openSync(path, "r");
155
+ try {
156
+ const bytesRead = readSync(fd, buf, 0, buf.length, 0);
157
+ return bytesRead;
158
+ } finally {
159
+ closeSync(fd);
160
+ }
161
+ }
162
+
163
+ writeTextSync(path: string, content: string): void {
164
+ this.ensureDirSync(dirname(path));
165
+ writeFileSync(path, content);
166
+ }
167
+
168
+ statSync(path: string): SessionStorageStat {
169
+ const stats = statSync(path);
170
+ return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
171
+ }
172
+
173
+ listFilesSync(dir: string, pattern: string): string[] {
174
+ try {
175
+ return Array.from(new Bun.Glob(pattern).scanSync(dir)).map((name) => join(dir, name));
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+
181
+ exists(path: string): Promise<boolean> {
182
+ return Bun.file(path).exists();
183
+ }
184
+
185
+ readText(path: string): Promise<string> {
186
+ return Bun.file(path).text();
187
+ }
188
+
189
+ async writeText(path: string, content: string): Promise<void> {
190
+ await Bun.write(path, content, { createPath: true });
191
+ }
192
+
193
+ async rename(path: string, nextPath: string): Promise<void> {
194
+ try {
195
+ await renameAsync(path, nextPath);
196
+ } catch (err) {
197
+ throw toError(err);
198
+ }
199
+ }
200
+
201
+ unlink(path: string): Promise<void> {
202
+ return Bun.file(path).unlink();
203
+ }
204
+
205
+ fsyncDirSync(dir: string): void {
206
+ try {
207
+ const fd = openSync(dir, "r");
208
+ try {
209
+ fsyncSync(fd);
210
+ } finally {
211
+ closeSync(fd);
212
+ }
213
+ } catch {
214
+ // Best-effort: some platforms/filesystems don't support fsync on directories.
215
+ }
216
+ }
217
+
218
+ openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
219
+ return new FileSessionStorageWriter(path, options);
220
+ }
221
+ }
222
+
223
+ function matchesPattern(name: string, pattern: string): boolean {
224
+ if (pattern === "*") return true;
225
+ if (pattern.startsWith("*.")) {
226
+ return name.endsWith(pattern.slice(1));
227
+ }
228
+ return name === pattern;
229
+ }
230
+
231
+ class MemorySessionStorageWriter implements SessionStorageWriter {
232
+ private storage: MemorySessionStorage;
233
+ private path: string;
234
+ private closed = false;
235
+ private error: Error | undefined;
236
+ private onError: ((err: Error) => void) | undefined;
237
+ private ready: Promise<void>;
238
+
239
+ constructor(
240
+ storage: MemorySessionStorage,
241
+ path: string,
242
+ options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
243
+ ) {
244
+ this.storage = storage;
245
+ this.path = path;
246
+ this.onError = options?.onError;
247
+ this.ready = this.initialize(options?.flags ?? "a");
248
+ }
249
+
250
+ private async initialize(flags: "a" | "w"): Promise<void> {
251
+ if (flags === "w") {
252
+ await this.storage.writeText(this.path, "");
253
+ }
254
+ }
255
+
256
+ private recordError(err: unknown): Error {
257
+ const error = toError(err);
258
+ if (!this.error) this.error = error;
259
+ this.onError?.(error);
260
+ return error;
261
+ }
262
+
263
+ async writeLine(line: string): Promise<void> {
264
+ if (this.closed) throw new Error("Writer closed");
265
+ await this.ready;
266
+ if (this.error) throw this.error;
267
+ try {
268
+ const existing = this.storage.existsSync(this.path) ? this.storage.readTextSync(this.path) : "";
269
+ await this.storage.writeText(this.path, `${existing}${line}`);
270
+ } catch (err) {
271
+ throw this.recordError(err);
272
+ }
273
+ }
274
+
275
+ async flush(): Promise<void> {
276
+ await this.ready;
277
+ if (this.error) throw this.error;
278
+ }
279
+
280
+ async fsync(): Promise<void> {
281
+ // No-op for in-memory storage
282
+ await this.ready;
283
+ if (this.error) throw this.error;
284
+ }
285
+
286
+ async close(): Promise<void> {
287
+ if (this.closed) return;
288
+ await this.ready;
289
+ this.closed = true;
290
+ }
291
+
292
+ getError(): Error | undefined {
293
+ return this.error;
294
+ }
295
+ }
296
+
297
+ export class MemorySessionStorage implements SessionStorage {
298
+ private files = new Map<string, { content: string; mtimeMs: number }>();
299
+
300
+ ensureDirSync(_dir: string): void {
301
+ // No-op for in-memory storage.
302
+ }
303
+
304
+ existsSync(path: string): boolean {
305
+ return this.files.has(path);
306
+ }
307
+
308
+ readTextSync(path: string): string {
309
+ const entry = this.files.get(path);
310
+ if (!entry) throw new Error(`File not found: ${path}`);
311
+ return entry.content;
312
+ }
313
+
314
+ readTextPrefixSync(path: string, buf: Buffer): number {
315
+ const content = this.readTextSync(path);
316
+ return buf.write(content, 0, buf.length, "utf-8");
317
+ }
318
+
319
+ writeTextSync(path: string, content: string): void {
320
+ this.files.set(path, { content, mtimeMs: Date.now() });
321
+ }
322
+
323
+ statSync(path: string): SessionStorageStat {
324
+ const entry = this.files.get(path);
325
+ if (!entry) throw new Error(`File not found: ${path}`);
326
+ return {
327
+ size: entry.content.length,
328
+ mtimeMs: entry.mtimeMs,
329
+ mtime: new Date(entry.mtimeMs),
330
+ };
331
+ }
332
+
333
+ listFilesSync(dir: string, pattern: string): string[] {
334
+ const prefix = dir.endsWith("/") ? dir : `${dir}/`;
335
+ const files: string[] = [];
336
+ for (const path of this.files.keys()) {
337
+ if (!path.startsWith(prefix)) continue;
338
+ const name = path.slice(prefix.length);
339
+ if (name.includes("/") || name.includes("\\")) continue;
340
+ if (!matchesPattern(name, pattern)) continue;
341
+ files.push(path);
342
+ }
343
+ return files;
344
+ }
345
+
346
+ exists(path: string): Promise<boolean> {
347
+ return Promise.resolve(this.existsSync(path));
348
+ }
349
+
350
+ readText(path: string): Promise<string> {
351
+ return Promise.resolve(this.readTextSync(path));
352
+ }
353
+
354
+ writeText(path: string, content: string): Promise<void> {
355
+ this.writeTextSync(path, content);
356
+ return Promise.resolve();
357
+ }
358
+
359
+ rename(path: string, nextPath: string): Promise<void> {
360
+ const entry = this.files.get(path);
361
+ if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
362
+ this.files.set(nextPath, entry);
363
+ this.files.delete(path);
364
+ return Promise.resolve();
365
+ }
366
+
367
+ unlink(path: string): Promise<void> {
368
+ this.files.delete(path);
369
+ return Promise.resolve();
370
+ }
371
+
372
+ fsyncDirSync(_dir: string): void {
373
+ // No-op for in-memory storage.
374
+ }
375
+
376
+ openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
377
+ return new MemorySessionStorageWriter(this, path, options);
378
+ }
379
+ }
@@ -70,8 +70,17 @@ export interface ProviderSettings {
70
70
  image?: ImageProviderOption; // default: "auto" (openrouter > gemini)
71
71
  }
72
72
 
73
+ export interface BashInterceptorRule {
74
+ pattern: string;
75
+ flags?: string;
76
+ tool: string;
77
+ message: string;
78
+ }
79
+
73
80
  export interface BashInterceptorSettings {
74
81
  enabled?: boolean; // default: false (blocks shell commands that have dedicated tools)
82
+ simpleLs?: boolean; // default: true (intercept bare ls commands)
83
+ patterns?: BashInterceptorRule[]; // default: built-in rules
75
84
  }
76
85
 
77
86
  export interface GitSettings {
@@ -191,6 +200,140 @@ export interface Settings {
191
200
  statusLine?: StatusLineSettings; // Status line configuration
192
201
  }
193
202
 
203
+ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
204
+ {
205
+ pattern: "^\\s*(cat|head|tail|less|more)\\s+",
206
+ tool: "read",
207
+ message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
208
+ },
209
+ {
210
+ pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
211
+ tool: "grep",
212
+ message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
213
+ },
214
+ {
215
+ pattern: "^\\s*git(\\s+|$)",
216
+ tool: "git",
217
+ message:
218
+ "Use the `git` tool instead of running git in bash. It provides structured output and safety confirmations.",
219
+ },
220
+ {
221
+ pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
222
+ tool: "find",
223
+ message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
224
+ },
225
+ {
226
+ pattern: "^\\s*sed\\s+(-i|--in-place)",
227
+ tool: "edit",
228
+ message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
229
+ },
230
+ {
231
+ pattern: "^\\s*perl\\s+.*-[pn]?i",
232
+ tool: "edit",
233
+ message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
234
+ },
235
+ {
236
+ pattern: "^\\s*awk\\s+.*-i\\s+inplace",
237
+ tool: "edit",
238
+ message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
239
+ },
240
+ {
241
+ pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
242
+ tool: "write",
243
+ message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
244
+ },
245
+ ];
246
+
247
+ const DEFAULT_BASH_INTERCEPTOR_SETTINGS: Required<BashInterceptorSettings> = {
248
+ enabled: false,
249
+ simpleLs: true,
250
+ patterns: DEFAULT_BASH_INTERCEPTOR_RULES,
251
+ };
252
+
253
+ const DEFAULT_SETTINGS: Settings = {
254
+ compaction: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
255
+ branchSummary: { enabled: false, reserveTokens: 16384 },
256
+ retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
257
+ skills: {
258
+ enabled: true,
259
+ enableCodexUser: true,
260
+ enableClaudeUser: true,
261
+ enableClaudeProject: true,
262
+ enablePiUser: true,
263
+ enablePiProject: true,
264
+ customDirectories: [],
265
+ ignoredSkills: [],
266
+ includeSkills: [],
267
+ },
268
+ commands: { enableClaudeUser: true, enableClaudeProject: true },
269
+ terminal: { showImages: true },
270
+ images: { autoResize: true },
271
+ notifications: { onComplete: "auto" },
272
+ exa: {
273
+ enabled: true,
274
+ enableSearch: true,
275
+ enableLinkedin: false,
276
+ enableCompany: false,
277
+ enableResearcher: false,
278
+ enableWebsets: false,
279
+ },
280
+ bashInterceptor: DEFAULT_BASH_INTERCEPTOR_SETTINGS,
281
+ git: { enabled: false },
282
+ mcp: { enableProjectConfig: true },
283
+ lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false },
284
+ edit: { fuzzyMatch: true },
285
+ ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 },
286
+ voice: {
287
+ enabled: false,
288
+ transcriptionModel: "whisper-1",
289
+ ttsModel: "gpt-4o-mini-tts",
290
+ ttsVoice: "alloy",
291
+ ttsFormat: "wav",
292
+ },
293
+ providers: { webSearch: "auto", image: "auto" },
294
+ } satisfies Settings;
295
+
296
+ function normalizeBashInterceptorRule(rule: unknown): BashInterceptorRule | null {
297
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) return null;
298
+
299
+ const candidate = rule as Record<string, unknown>;
300
+ const pattern = typeof candidate.pattern === "string" ? candidate.pattern : "";
301
+ const tool = typeof candidate.tool === "string" ? candidate.tool : "";
302
+ const message = typeof candidate.message === "string" ? candidate.message : "";
303
+ const flags = typeof candidate.flags === "string" && candidate.flags.length > 0 ? candidate.flags : undefined;
304
+
305
+ if (!pattern || !tool || !message) return null;
306
+ return { pattern, flags, tool, message };
307
+ }
308
+
309
+ function normalizeBashInterceptorSettings(
310
+ settings: BashInterceptorSettings | undefined,
311
+ ): Required<BashInterceptorSettings> {
312
+ const enabled = settings?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled;
313
+ const simpleLs = settings?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs;
314
+ const rawPatterns = settings?.patterns;
315
+ let patterns: BashInterceptorRule[];
316
+ if (rawPatterns === undefined) {
317
+ patterns = DEFAULT_BASH_INTERCEPTOR_RULES;
318
+ } else if (Array.isArray(rawPatterns)) {
319
+ patterns = rawPatterns
320
+ .map((rule) => normalizeBashInterceptorRule(rule))
321
+ .filter((rule): rule is BashInterceptorRule => rule !== null);
322
+ } else {
323
+ patterns = DEFAULT_BASH_INTERCEPTOR_RULES;
324
+ }
325
+
326
+ return { enabled, simpleLs, patterns };
327
+ }
328
+
329
+ function normalizeSettings(settings: Settings): Settings {
330
+ const merged = deepMergeSettings(DEFAULT_SETTINGS, settings);
331
+ return {
332
+ ...merged,
333
+ bashInterceptor: normalizeBashInterceptorSettings(merged.bashInterceptor),
334
+ };
335
+ }
336
+
194
337
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
195
338
  function deepMergeSettings(base: Settings, overrides: Settings): Settings {
196
339
  const result: Settings = { ...base };
@@ -235,7 +378,7 @@ export class SettingsManager {
235
378
  this.persist = persist;
236
379
  this.globalSettings = initialSettings;
237
380
  const projectSettings = this.loadProjectSettings();
238
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
381
+ this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
239
382
  }
240
383
 
241
384
  /** Create a SettingsManager that loads from files */
@@ -308,7 +451,7 @@ export class SettingsManager {
308
451
 
309
452
  /** Apply additional overrides on top of current settings */
310
453
  applyOverrides(overrides: Partial<Settings>): void {
311
- this.settings = deepMergeSettings(this.settings, overrides);
454
+ this.settings = normalizeSettings(deepMergeSettings(this.settings, overrides));
312
455
  }
313
456
 
314
457
  private save(): void {
@@ -325,7 +468,7 @@ export class SettingsManager {
325
468
 
326
469
  // Re-merge project settings into active settings
327
470
  const projectSettings = this.loadProjectSettings();
328
- this.settings = deepMergeSettings(this.globalSettings, projectSettings);
471
+ this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
329
472
  } catch (error) {
330
473
  console.error(`Warning: Could not save settings file: ${error}`);
331
474
  }
@@ -680,7 +823,15 @@ export class SettingsManager {
680
823
  }
681
824
 
682
825
  getBashInterceptorEnabled(): boolean {
683
- return this.settings.bashInterceptor?.enabled ?? false;
826
+ return this.settings.bashInterceptor?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled;
827
+ }
828
+
829
+ getBashInterceptorSimpleLsEnabled(): boolean {
830
+ return this.settings.bashInterceptor?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs;
831
+ }
832
+
833
+ getBashInterceptorRules(): BashInterceptorRule[] {
834
+ return [...(this.settings.bashInterceptor?.patterns ?? DEFAULT_BASH_INTERCEPTOR_RULES)];
684
835
  }
685
836
 
686
837
  setBashInterceptorEnabled(enabled: boolean): void {
@@ -2,6 +2,7 @@ import { slashCommandCapability } from "../capability/slash-command";
2
2
  import type { SlashCommand } from "../discovery";
3
3
  import { loadSync } from "../discovery";
4
4
  import { parseFrontmatter } from "../discovery/helpers";
5
+ import { EMBEDDED_COMMAND_TEMPLATES } from "./tools/task/commands";
5
6
 
6
7
  /**
7
8
  * Represents a custom slash command loaded from a file
@@ -15,6 +16,25 @@ export interface FileSlashCommand {
15
16
  _source?: { providerName: string; level: "user" | "project" | "native" };
16
17
  }
17
18
 
19
+ const EMBEDDED_SLASH_COMMANDS = EMBEDDED_COMMAND_TEMPLATES;
20
+
21
+ function parseCommandTemplate(content: string): { description: string; body: string } {
22
+ const { frontmatter, body } = parseFrontmatter(content);
23
+ const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
24
+
25
+ // Get description from frontmatter or first non-empty line
26
+ let description = frontmatterDesc;
27
+ if (!description) {
28
+ const firstLine = body.split("\n").find((line) => line.trim());
29
+ if (firstLine) {
30
+ description = firstLine.slice(0, 60);
31
+ if (firstLine.length > 60) description += "...";
32
+ }
33
+ }
34
+
35
+ return { description, body };
36
+ }
37
+
18
38
  /**
19
39
  * Parse command arguments respecting quoted strings (bash-style)
20
40
  * Returns array of arguments
@@ -90,19 +110,8 @@ export interface LoadSlashCommandsOptions {
90
110
  export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
91
111
  const result = loadSync<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
92
112
 
93
- return result.items.map((cmd) => {
94
- const { frontmatter, body } = parseFrontmatter(cmd.content);
95
- const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
96
-
97
- // Get description from frontmatter or first non-empty line
98
- let description = frontmatterDesc;
99
- if (!description) {
100
- const firstLine = body.split("\n").find((line) => line.trim());
101
- if (firstLine) {
102
- description = firstLine.slice(0, 60);
103
- if (firstLine.length > 60) description += "...";
104
- }
105
- }
113
+ const fileCommands: FileSlashCommand[] = result.items.map((cmd) => {
114
+ const { description, body } = parseCommandTemplate(cmd.content);
106
115
 
107
116
  // Format source label: "via ProviderName Level"
108
117
  const capitalizedLevel = cmd.level.charAt(0).toUpperCase() + cmd.level.slice(1);
@@ -116,6 +125,23 @@ export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileS
116
125
  _source: { providerName: cmd._source.providerName, level: cmd.level },
117
126
  };
118
127
  });
128
+
129
+ const seenNames = new Set(fileCommands.map((cmd) => cmd.name));
130
+ for (const cmd of EMBEDDED_SLASH_COMMANDS) {
131
+ const name = cmd.name.replace(/\.md$/, "");
132
+ if (seenNames.has(name)) continue;
133
+
134
+ const { description, body } = parseCommandTemplate(cmd.content);
135
+ fileCommands.push({
136
+ name,
137
+ description,
138
+ content: body,
139
+ source: "bundled",
140
+ });
141
+ seenNames.add(name);
142
+ }
143
+
144
+ return fileCommands;
119
145
  }
120
146
 
121
147
  /**