@oh-my-pi/pi-coding-agent 3.25.0 → 3.31.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 (157) hide show
  1. package/CHANGELOG.md +90 -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 +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  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 +55 -32
  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 +152 -76
  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/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  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 +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -0,0 +1,371 @@
1
+ import { unlinkSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import type { FileSink } from "bun";
5
+ import { nanoid } from "nanoid";
6
+ import { ensureTool } from "../../../utils/tools-manager";
7
+ import type { RenderResult, SpecialHandler } from "./types";
8
+ import { finalizeOutput } from "./types";
9
+
10
+ /**
11
+ * Execute a command and return stdout
12
+ */
13
+ async function exec(
14
+ cmd: string,
15
+ args: string[],
16
+ options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal },
17
+ ): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> {
18
+ const proc = Bun.spawn([cmd, ...args], {
19
+ stdin: options?.input ? "pipe" : "ignore",
20
+ stdout: "pipe",
21
+ stderr: "pipe",
22
+ timeout: options?.timeout,
23
+ signal: options?.signal,
24
+ });
25
+
26
+ if (options?.input && proc.stdin) {
27
+ const stdin = proc.stdin as FileSink;
28
+ const payload = typeof options.input === "string" ? new TextEncoder().encode(options.input) : options.input;
29
+ stdin.write(payload);
30
+ const flushed = stdin.flush();
31
+ if (flushed instanceof Promise) {
32
+ await flushed;
33
+ }
34
+ const ended = stdin.end();
35
+ if (ended instanceof Promise) {
36
+ await ended;
37
+ }
38
+ }
39
+
40
+ const [stdout, stderr] = await Promise.all([
41
+ (proc.stdout as ReadableStream<Uint8Array>).text(),
42
+ (proc.stderr as ReadableStream<Uint8Array>).text(),
43
+ ]);
44
+ const exitCode = await proc.exited;
45
+
46
+ return {
47
+ stdout,
48
+ stderr,
49
+ ok: exitCode === 0,
50
+ exitCode,
51
+ };
52
+ }
53
+
54
+ interface YouTubeUrl {
55
+ videoId: string;
56
+ playlistId?: string;
57
+ }
58
+
59
+ /**
60
+ * Parse YouTube URL into components
61
+ */
62
+ function parseYouTubeUrl(url: string): YouTubeUrl | null {
63
+ try {
64
+ const parsed = new URL(url);
65
+ const hostname = parsed.hostname.replace(/^www\./, "");
66
+
67
+ // youtube.com/watch?v=VIDEO_ID
68
+ if ((hostname === "youtube.com" || hostname === "m.youtube.com") && parsed.pathname === "/watch") {
69
+ const videoId = parsed.searchParams.get("v");
70
+ const playlistId = parsed.searchParams.get("list") || undefined;
71
+ if (videoId) return { videoId, playlistId };
72
+ }
73
+
74
+ // youtube.com/v/VIDEO_ID or youtube.com/embed/VIDEO_ID
75
+ if (hostname === "youtube.com" || hostname === "m.youtube.com") {
76
+ const match = parsed.pathname.match(/^\/(v|embed)\/([a-zA-Z0-9_-]{11})/);
77
+ if (match) return { videoId: match[2] };
78
+ }
79
+
80
+ // youtu.be/VIDEO_ID
81
+ if (hostname === "youtu.be") {
82
+ const videoId = parsed.pathname.slice(1).split("/")[0];
83
+ if (videoId && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
84
+ return { videoId };
85
+ }
86
+ }
87
+
88
+ // youtube.com/shorts/VIDEO_ID
89
+ if (hostname === "youtube.com" && parsed.pathname.startsWith("/shorts/")) {
90
+ const videoId = parsed.pathname.replace("/shorts/", "").split("/")[0];
91
+ if (videoId && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
92
+ return { videoId };
93
+ }
94
+ }
95
+ } catch {}
96
+
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Clean VTT subtitle content to plain text
102
+ */
103
+ function cleanVttToText(vtt: string): string {
104
+ const lines = vtt.split("\n");
105
+ const textLines: string[] = [];
106
+ let lastLine = "";
107
+
108
+ for (const line of lines) {
109
+ // Skip WEBVTT header, timestamps, and metadata
110
+ if (
111
+ line.startsWith("WEBVTT") ||
112
+ line.startsWith("Kind:") ||
113
+ line.startsWith("Language:") ||
114
+ line.match(/^\d{2}:\d{2}/) || // Timestamp lines
115
+ line.match(/^[a-f0-9-]{36}$/) || // UUID cue identifiers
116
+ line.match(/^\d+$/) || // Numeric cue identifiers
117
+ line.includes("-->") ||
118
+ line.trim() === ""
119
+ ) {
120
+ continue;
121
+ }
122
+
123
+ // Remove inline timestamp tags like <00:00:01.520>
124
+ let cleaned = line.replace(/<\d{2}:\d{2}:\d{2}\.\d{3}>/g, "");
125
+ // Remove other VTT tags like <c> </c>
126
+ cleaned = cleaned.replace(/<\/?[^>]+>/g, "");
127
+ cleaned = cleaned.trim();
128
+
129
+ // Skip duplicates (auto-generated captions often repeat)
130
+ if (cleaned && cleaned !== lastLine) {
131
+ textLines.push(cleaned);
132
+ lastLine = cleaned;
133
+ }
134
+ }
135
+
136
+ return textLines.join(" ").replace(/\s+/g, " ").trim();
137
+ }
138
+
139
+ /**
140
+ * Format duration from seconds to human readable
141
+ */
142
+ function formatDuration(seconds: number): string {
143
+ const h = Math.floor(seconds / 3600);
144
+ const m = Math.floor((seconds % 3600) / 60);
145
+ const s = Math.floor(seconds % 60);
146
+ if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
147
+ return `${m}:${s.toString().padStart(2, "0")}`;
148
+ }
149
+
150
+ /**
151
+ * Handle YouTube URLs - fetch metadata and transcript
152
+ */
153
+ export const handleYouTube: SpecialHandler = async (
154
+ url: string,
155
+ timeout: number,
156
+ signal?: AbortSignal,
157
+ ): Promise<RenderResult | null> => {
158
+ signal?.throwIfAborted();
159
+ const yt = parseYouTubeUrl(url);
160
+ if (!yt) return null;
161
+
162
+ // Ensure yt-dlp is available (auto-download if missing)
163
+ const ytdlp = await ensureTool("yt-dlp", true);
164
+ signal?.throwIfAborted();
165
+ if (!ytdlp) {
166
+ return {
167
+ url,
168
+ finalUrl: url,
169
+ contentType: "text/plain",
170
+ method: "youtube-no-ytdlp",
171
+ content: "YouTube video detected but yt-dlp could not be installed.",
172
+ fetchedAt: new Date().toISOString(),
173
+ truncated: false,
174
+ notes: ["yt-dlp installation failed"],
175
+ };
176
+ }
177
+
178
+ const fetchedAt = new Date().toISOString();
179
+ const notes: string[] = [];
180
+ const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`;
181
+
182
+ // Fetch video metadata
183
+ signal?.throwIfAborted();
184
+ const metaResult = await exec(
185
+ ytdlp,
186
+ ["--dump-json", "--no-warnings", "--no-playlist", "--skip-download", videoUrl],
187
+ {
188
+ timeout: timeout * 1000,
189
+ signal,
190
+ },
191
+ );
192
+ signal?.throwIfAborted();
193
+
194
+ let title = "YouTube Video";
195
+ let channel = "";
196
+ let description = "";
197
+ let duration = 0;
198
+ let uploadDate = "";
199
+ let viewCount = 0;
200
+
201
+ if (metaResult.ok && metaResult.stdout.trim()) {
202
+ try {
203
+ const meta = JSON.parse(metaResult.stdout) as {
204
+ title?: string;
205
+ channel?: string;
206
+ uploader?: string;
207
+ description?: string;
208
+ duration?: number;
209
+ upload_date?: string;
210
+ view_count?: number;
211
+ };
212
+ title = meta.title || title;
213
+ channel = meta.channel || meta.uploader || "";
214
+ description = meta.description || "";
215
+ duration = meta.duration || 0;
216
+ uploadDate = meta.upload_date || "";
217
+ viewCount = meta.view_count || 0;
218
+ } catch {}
219
+ }
220
+
221
+ // Format upload date
222
+ let formattedDate = "";
223
+ if (uploadDate && uploadDate.length === 8) {
224
+ formattedDate = `${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}`;
225
+ }
226
+
227
+ // Try to fetch subtitles
228
+ let transcript = "";
229
+ let transcriptSource = "";
230
+
231
+ // First, list available subtitles
232
+ signal?.throwIfAborted();
233
+ const listResult = await exec(
234
+ ytdlp,
235
+ ["--list-subs", "--no-warnings", "--no-playlist", "--skip-download", videoUrl],
236
+ {
237
+ timeout: timeout * 1000,
238
+ signal,
239
+ },
240
+ );
241
+ signal?.throwIfAborted();
242
+
243
+ const hasManualSubs = listResult.stdout.includes("[info] Available subtitles");
244
+ const hasAutoSubs = listResult.stdout.includes("[info] Available automatic captions");
245
+
246
+ // Create temp directory for subtitle download
247
+ const tmpDir = tmpdir();
248
+ const tmpBase = path.join(tmpDir, `yt-${yt.videoId}-${nanoid()}`);
249
+
250
+ try {
251
+ // Try manual subtitles first (English preferred)
252
+ if (hasManualSubs) {
253
+ signal?.throwIfAborted();
254
+ const subResult = await exec(
255
+ ytdlp,
256
+ [
257
+ "--write-sub",
258
+ "--sub-lang",
259
+ "en,en-US,en-GB",
260
+ "--sub-format",
261
+ "vtt",
262
+ "--skip-download",
263
+ "--no-warnings",
264
+ "--no-playlist",
265
+ "-o",
266
+ tmpBase,
267
+ videoUrl,
268
+ ],
269
+ { timeout: timeout * 1000, signal },
270
+ );
271
+
272
+ if (subResult.ok) {
273
+ // Find the downloaded subtitle file using glob
274
+ signal?.throwIfAborted();
275
+ const subFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*.vtt`).scan({ absolute: true }));
276
+ if (subFiles.length > 0) {
277
+ signal?.throwIfAborted();
278
+ const vttContent = await Bun.file(subFiles[0]).text();
279
+ transcript = cleanVttToText(vttContent);
280
+ transcriptSource = "manual";
281
+ notes.push("Using manual subtitles");
282
+ }
283
+ }
284
+ }
285
+
286
+ // Fall back to auto-generated captions
287
+ if (!transcript && hasAutoSubs) {
288
+ signal?.throwIfAborted();
289
+ const autoResult = await exec(
290
+ ytdlp,
291
+ [
292
+ "--write-auto-sub",
293
+ "--sub-lang",
294
+ "en,en-US,en-GB",
295
+ "--sub-format",
296
+ "vtt",
297
+ "--skip-download",
298
+ "--no-warnings",
299
+ "--no-playlist",
300
+ "-o",
301
+ tmpBase,
302
+ videoUrl,
303
+ ],
304
+ { timeout: timeout * 1000, signal },
305
+ );
306
+
307
+ if (autoResult.ok) {
308
+ signal?.throwIfAborted();
309
+ const subFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*.vtt`).scan({ absolute: true }));
310
+ if (subFiles.length > 0) {
311
+ signal?.throwIfAborted();
312
+ const vttContent = await Bun.file(subFiles[0]).text();
313
+ transcript = cleanVttToText(vttContent);
314
+ transcriptSource = "auto-generated";
315
+ notes.push("Using auto-generated captions");
316
+ }
317
+ }
318
+ }
319
+ } finally {
320
+ // Cleanup temp files using sync unlink to avoid leaving handles open
321
+ try {
322
+ const tmpFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*`).scan({ absolute: true }));
323
+ for (const f of tmpFiles) {
324
+ try {
325
+ unlinkSync(f);
326
+ } catch {}
327
+ }
328
+ } catch {}
329
+ }
330
+
331
+ // Build markdown output
332
+ let md = `# ${title}\n\n`;
333
+ if (channel) md += `**Channel:** ${channel}\n`;
334
+ if (formattedDate) md += `**Uploaded:** ${formattedDate}\n`;
335
+ if (duration > 0) md += `**Duration:** ${formatDuration(duration)}\n`;
336
+ if (viewCount > 0) {
337
+ const formatted =
338
+ viewCount >= 1_000_000
339
+ ? `${(viewCount / 1_000_000).toFixed(1)}M`
340
+ : viewCount >= 1_000
341
+ ? `${(viewCount / 1_000).toFixed(1)}K`
342
+ : String(viewCount);
343
+ md += `**Views:** ${formatted}\n`;
344
+ }
345
+ md += `**Video ID:** ${yt.videoId}\n\n`;
346
+
347
+ if (description) {
348
+ // Truncate long descriptions
349
+ const descPreview = description.length > 1000 ? `${description.slice(0, 1000)}...` : description;
350
+ md += `---\n\n## Description\n\n${descPreview}\n\n`;
351
+ }
352
+
353
+ if (transcript) {
354
+ md += `---\n\n## Transcript (${transcriptSource})\n\n${transcript}\n`;
355
+ } else {
356
+ notes.push("No subtitles/captions available");
357
+ md += `---\n\n*No transcript available for this video.*\n`;
358
+ }
359
+
360
+ const output = finalizeOutput(md);
361
+ return {
362
+ url,
363
+ finalUrl: videoUrl,
364
+ contentType: "text/markdown",
365
+ method: "youtube",
366
+ content: output.content,
367
+ fetchedAt,
368
+ truncated: output.truncated,
369
+ notes,
370
+ };
371
+ };
@@ -6,6 +6,7 @@ import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/inte
6
6
  import writeDescription from "../../prompts/tools/write.md" with { type: "text" };
7
7
  import type { RenderResultOptions } from "../custom-tools/types";
8
8
  import type { ToolSession } from "../sdk";
9
+ import { untilAborted } from "../utils";
9
10
  import { createLspWritethrough, type FileDiagnosticsResult } from "./lsp/index";
10
11
  import { resolveToCwd } from "./path-utils";
11
12
  import { formatDiagnostics, replaceTabs, shortenPath } from "./render-utils";
@@ -34,27 +35,29 @@ export function createWriteTool(session: ToolSession): AgentTool<typeof writeSch
34
35
  { path, content }: { path: string; content: string },
35
36
  signal?: AbortSignal,
36
37
  ) => {
37
- const absolutePath = resolveToCwd(path, session.cwd);
38
-
39
- const diagnostics = await writethrough(absolutePath, content, signal);
40
-
41
- let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
42
- if (!diagnostics) {
38
+ return untilAborted(signal, async () => {
39
+ const absolutePath = resolveToCwd(path, session.cwd);
40
+
41
+ const diagnostics = await writethrough(absolutePath, content, signal);
42
+
43
+ let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
44
+ if (!diagnostics) {
45
+ return {
46
+ content: [{ type: "text", text: resultText }],
47
+ details: {},
48
+ };
49
+ }
50
+
51
+ const messages = diagnostics?.messages;
52
+ if (messages && messages.length > 0) {
53
+ resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
54
+ resultText += messages.map((d) => ` ${d}`).join("\n");
55
+ }
43
56
  return {
44
57
  content: [{ type: "text", text: resultText }],
45
- details: {},
58
+ details: { diagnostics },
46
59
  };
47
- }
48
-
49
- const messages = diagnostics?.messages;
50
- if (messages && messages.length > 0) {
51
- resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
52
- resultText += messages.map((d) => ` ${d}`).join("\n");
53
- }
54
- return {
55
- content: [{ type: "text", text: resultText }],
56
- details: { diagnostics },
57
- };
60
+ });
58
61
  },
59
62
  };
60
63
  }
package/src/core/voice.ts CHANGED
@@ -2,6 +2,7 @@ import { unlinkSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { completeSimple, type Model } from "@mariozechner/pi-ai";
5
+ import { nanoid } from "nanoid";
5
6
  import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" };
6
7
  import { logger } from "./logger";
7
8
  import type { ModelRegistry } from "./model-registry";
@@ -99,7 +100,7 @@ function buildRecordingCommand(filePath: string, sampleRate: number, channels: n
99
100
  export async function startVoiceRecording(_settings: VoiceSettings): Promise<VoiceRecordingHandle> {
100
101
  const sampleRate = DEFAULT_SAMPLE_RATE;
101
102
  const channels = DEFAULT_CHANNELS;
102
- const filePath = join(tmpdir(), `omp-voice-${Date.now()}.wav`);
103
+ const filePath = join(tmpdir(), `omp-voice-${nanoid()}.wav`);
103
104
  const command = buildRecordingCommand(filePath, sampleRate, channels);
104
105
  if (!command) {
105
106
  throw new Error("No audio recorder found (install sox, arecord, or ffmpeg).");
@@ -233,7 +234,7 @@ function getPlayerCommand(filePath: string, format: VoiceSynthesisResult["format
233
234
  }
234
235
 
235
236
  export async function playAudio(audio: Uint8Array, format: VoiceSynthesisResult["format"]): Promise<void> {
236
- const filePath = join(tmpdir(), `omp-voice-tts-${Date.now()}.${format}`);
237
+ const filePath = join(tmpdir(), `omp-tts-${nanoid()}.${format}`);
237
238
  await Bun.write(filePath, audio);
238
239
 
239
240
  const command = getPlayerCommand(filePath, format);
@@ -1,3 +1,4 @@
1
+ import { nanoid } from "nanoid";
1
2
  import { WorktreeError, WorktreeErrorCode } from "./errors";
2
3
  import { git, gitWithStdin } from "./git";
3
4
  import { find, remove, type Worktree } from "./operations";
@@ -89,7 +90,7 @@ async function collapseRebase(src: Worktree, dst: Worktree): Promise<string> {
89
90
  throw new WorktreeError("Failed to resolve HEAD", WorktreeErrorCode.COLLAPSE_FAILED);
90
91
  }
91
92
  const originalHead = headResult.stdout.trim();
92
- const tempBranch = `wt-collapse-${Date.now()}`;
93
+ const tempBranch = `wt-collapse-${nanoid()}`;
93
94
 
94
95
  await requireGitSuccess(await git(["checkout", "-b", tempBranch], src.path), "Failed to create temp branch");
95
96
 
@@ -17,22 +17,6 @@ type WritableLike = {
17
17
 
18
18
  const textEncoder = new TextEncoder();
19
19
 
20
- async function readStream(stream: ReadableStream<Uint8Array> | undefined): Promise<string> {
21
- if (!stream) return "";
22
- const reader = stream.getReader();
23
- const chunks: Uint8Array[] = [];
24
- try {
25
- while (true) {
26
- const { done, value } = await reader.read();
27
- if (done) break;
28
- chunks.push(value);
29
- }
30
- } finally {
31
- reader.releaseLock();
32
- }
33
- return Buffer.concat(chunks).toString();
34
- }
35
-
36
20
  async function writeStdin(handle: unknown, stdin: string): Promise<void> {
37
21
  if (!handle || typeof handle === "number") return;
38
22
  if (typeof (handle as WritableStream<Uint8Array>).getWriter === "function") {
@@ -77,8 +61,8 @@ export async function gitWithStdin(args: string[], stdin: string, cwd?: string):
77
61
  await writeStdin(proc.stdin, stdin);
78
62
 
79
63
  const [stdout, stderr, exitCode] = await Promise.all([
80
- readStream(proc.stdout as ReadableStream<Uint8Array>),
81
- readStream(proc.stderr as ReadableStream<Uint8Array>),
64
+ (proc.stdout as ReadableStream<Uint8Array>).text(),
65
+ (proc.stderr as ReadableStream<Uint8Array>).text(),
82
66
  proc.exited,
83
67
  ]);
84
68
 
package/src/main.ts CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
9
9
  import chalk from "chalk";
10
+ import { homedir, tmpdir } from "node:os";
11
+ import { join, resolve } from "node:path";
10
12
  import { type Args, parseArgs, printHelp } from "./cli/args";
11
13
  import { processFileArguments } from "./cli/file-processor";
12
14
  import { listModels } from "./cli/list-models";
@@ -187,6 +189,59 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
187
189
  return undefined;
188
190
  }
189
191
 
192
+ async function maybeAutoChdir(parsed: Args): Promise<void> {
193
+ if (parsed.allowHome || parsed.cwd) {
194
+ return;
195
+ }
196
+
197
+ const home = homedir();
198
+ if (!home) {
199
+ return;
200
+ }
201
+
202
+ const normalizePath = (value: string) => {
203
+ const resolved = resolve(value);
204
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
205
+ };
206
+
207
+ const cwd = normalizePath(process.cwd());
208
+ const normalizedHome = normalizePath(home);
209
+ if (cwd !== normalizedHome) {
210
+ return;
211
+ }
212
+
213
+ const isDirectory = async (path: string) => {
214
+ try {
215
+ const stat = await Bun.file(path).stat();
216
+ return stat.isDirectory();
217
+ } catch {
218
+ return false;
219
+ }
220
+ };
221
+
222
+ const candidates = [join(home, "tmp"), "/tmp", "/var/tmp"];
223
+ for (const candidate of candidates) {
224
+ try {
225
+ if (!(await isDirectory(candidate))) {
226
+ continue;
227
+ }
228
+ process.chdir(candidate);
229
+ return;
230
+ } catch {
231
+ // Try next candidate.
232
+ }
233
+ }
234
+
235
+ try {
236
+ const fallback = tmpdir();
237
+ if (fallback && normalizePath(fallback) !== cwd && (await isDirectory(fallback))) {
238
+ process.chdir(fallback);
239
+ }
240
+ } catch {
241
+ // Ignore fallback errors.
242
+ }
243
+ }
244
+
190
245
  /** Discover SYSTEM.md file if no CLI system prompt was provided */
191
246
  function discoverSystemPromptFile(): string | undefined {
192
247
  // Check project-local first (.omp/SYSTEM.md, .pi/SYSTEM.md legacy)
@@ -318,6 +373,10 @@ export async function main(args: string[]) {
318
373
  return;
319
374
  }
320
375
 
376
+ const parsed = parseArgs(args);
377
+ time("parseArgs");
378
+ await maybeAutoChdir(parsed);
379
+
321
380
  // Run migrations (pass cwd for project-local migrations)
322
381
  const { migratedAuthProviders: migratedProviders, deprecationWarnings } = await runMigrations(process.cwd());
323
382
 
@@ -326,9 +385,6 @@ export async function main(args: string[]) {
326
385
  const modelRegistry = await discoverModels(authStorage);
327
386
  time("discoverModels");
328
387
 
329
- const parsed = parseArgs(args);
330
- time("parseArgs");
331
-
332
388
  if (parsed.version) {
333
389
  console.log(VERSION);
334
390
  return;
@@ -38,30 +38,40 @@ export class ExtensionDashboard extends Container {
38
38
  private inspector: InspectorPanel;
39
39
  private settingsManager: SettingsManager | null;
40
40
  private cwd: string;
41
+ private terminalHeight: number;
41
42
 
42
43
  public onClose?: () => void;
43
44
 
44
- constructor(cwd: string, settingsManager: SettingsManager | null = null) {
45
+ constructor(cwd: string, settingsManager: SettingsManager | null = null, terminalHeight?: number) {
45
46
  super();
46
47
  this.cwd = cwd;
47
48
  this.settingsManager = settingsManager;
49
+ this.terminalHeight = terminalHeight ?? process.stdout.rows ?? 24;
48
50
  const disabledIds = settingsManager?.getDisabledExtensions() ?? [];
49
51
  this.state = createInitialState(cwd, disabledIds);
50
52
 
53
+ // Calculate max visible items based on terminal height
54
+ // Reserve ~10 lines for header, tabs, help text, borders
55
+ const maxVisible = Math.max(5, Math.floor((this.terminalHeight - 10) / 2));
56
+
51
57
  // Create main list - always focused
52
- this.mainList = new ExtensionList(this.state.searchFiltered, {
53
- onSelectionChange: (ext) => {
54
- this.state.selected = ext;
55
- this.inspector.setExtension(ext);
56
- },
57
- onToggle: (extensionId, enabled) => {
58
- this.handleExtensionToggle(extensionId, enabled);
59
- },
60
- onMasterToggle: (providerId) => {
61
- this.handleProviderToggle(providerId);
58
+ this.mainList = new ExtensionList(
59
+ this.state.searchFiltered,
60
+ {
61
+ onSelectionChange: (ext) => {
62
+ this.state.selected = ext;
63
+ this.inspector.setExtension(ext);
64
+ },
65
+ onToggle: (extensionId, enabled) => {
66
+ this.handleExtensionToggle(extensionId, enabled);
67
+ },
68
+ onMasterToggle: (providerId) => {
69
+ this.handleProviderToggle(providerId);
70
+ },
71
+ masterSwitchProvider: this.getActiveProviderId(),
62
72
  },
63
- masterSwitchProvider: this.getActiveProviderId(),
64
- });
73
+ maxVisible,
74
+ );
65
75
  this.mainList.setFocused(true);
66
76
 
67
77
  // Create inspector
@@ -91,9 +101,10 @@ export class ExtensionDashboard extends Container {
91
101
  this.addChild(new Text(this.renderTabBar(), 0, 0));
92
102
  this.addChild(new Spacer(1));
93
103
 
94
- // Help text
95
- // 2-column body
96
- this.addChild(new TwoColumnBody(this.mainList, this.inspector));
104
+ // 2-column body with height limit
105
+ // Reserve ~8 lines for header, tabs, help text, borders
106
+ const bodyMaxHeight = Math.max(5, this.terminalHeight - 8);
107
+ this.addChild(new TwoColumnBody(this.mainList, this.inspector, bodyMaxHeight));
97
108
 
98
109
  this.addChild(new Spacer(1));
99
110
  this.addChild(new Text(theme.fg("dim", " ↑/↓: navigate Space: toggle Tab: next provider Esc: close"), 0, 0));
@@ -262,10 +273,12 @@ export class ExtensionDashboard extends Container {
262
273
  class TwoColumnBody implements Component {
263
274
  private leftPane: ExtensionList;
264
275
  private rightPane: InspectorPanel;
276
+ private maxHeight: number;
265
277
 
266
- constructor(left: ExtensionList, right: InspectorPanel) {
278
+ constructor(left: ExtensionList, right: InspectorPanel, maxHeight: number) {
267
279
  this.leftPane = left;
268
280
  this.rightPane = right;
281
+ this.maxHeight = maxHeight;
269
282
  }
270
283
 
271
284
  render(width: number): string[] {
@@ -275,11 +288,12 @@ class TwoColumnBody implements Component {
275
288
  const leftLines = this.leftPane.render(leftWidth);
276
289
  const rightLines = this.rightPane.render(rightWidth);
277
290
 
278
- const maxLines = Math.max(leftLines.length, rightLines.length);
291
+ // Limit to maxHeight lines
292
+ const numLines = Math.min(this.maxHeight, Math.max(leftLines.length, rightLines.length));
279
293
  const combined: string[] = [];
280
294
  const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
281
295
 
282
- for (let i = 0; i < maxLines; i++) {
296
+ for (let i = 0; i < numLines; i++) {
283
297
  const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
284
298
  const leftPadded = left + " ".repeat(Math.max(0, leftWidth - visibleWidth(left)));
285
299
  const right = truncateToWidth(rightLines[i] ?? "", rightWidth);