@oh-my-pi/pi-coding-agent 3.30.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 (155) hide show
  1. package/CHANGELOG.md +71 -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/edit-diff.ts +11 -4
  16. package/src/core/tools/edit.ts +7 -13
  17. package/src/core/tools/find.ts +111 -50
  18. package/src/core/tools/gemini-image.ts +128 -147
  19. package/src/core/tools/grep.ts +397 -415
  20. package/src/core/tools/index.test.ts +5 -1
  21. package/src/core/tools/index.ts +6 -8
  22. package/src/core/tools/ls.ts +12 -10
  23. package/src/core/tools/lsp/client.ts +58 -9
  24. package/src/core/tools/lsp/config.ts +205 -656
  25. package/src/core/tools/lsp/defaults.json +465 -0
  26. package/src/core/tools/lsp/index.ts +55 -32
  27. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  28. package/src/core/tools/lsp/types.ts +1 -0
  29. package/src/core/tools/lsp/utils.ts +1 -1
  30. package/src/core/tools/read.ts +150 -74
  31. package/src/core/tools/render-utils.ts +70 -10
  32. package/src/core/tools/review.ts +38 -126
  33. package/src/core/tools/task/artifacts.ts +5 -4
  34. package/src/core/tools/task/executor.ts +94 -83
  35. package/src/core/tools/task/index.ts +129 -92
  36. package/src/core/tools/task/parallel.ts +30 -3
  37. package/src/core/tools/task/render.ts +85 -39
  38. package/src/core/tools/task/types.ts +15 -6
  39. package/src/core/tools/task/worker.ts +124 -89
  40. package/src/core/tools/web-fetch.ts +112 -377
  41. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  42. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  43. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  49. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  50. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  51. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  52. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  53. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  54. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  57. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  59. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  60. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  61. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  62. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  63. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  64. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  71. package/src/core/tools/web-scrapers/index.ts +250 -0
  72. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  73. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  74. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  75. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  76. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  79. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  82. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  83. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  84. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  86. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  87. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  90. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  93. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  96. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  99. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  102. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  103. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  104. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  105. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  106. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  107. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  111. package/src/core/tools/web-scrapers/utils.ts +162 -0
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  113. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  114. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  116. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  117. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  118. package/src/core/tools/write.ts +21 -18
  119. package/src/core/voice.ts +3 -2
  120. package/src/lib/worktree/collapse.ts +2 -1
  121. package/src/lib/worktree/git.ts +2 -18
  122. package/src/main.ts +59 -3
  123. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  124. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  125. package/src/modes/interactive/components/hook-editor.ts +2 -1
  126. package/src/modes/interactive/components/model-selector.ts +19 -4
  127. package/src/modes/interactive/interactive-mode.ts +41 -38
  128. package/src/modes/interactive/theme/theme.ts +58 -58
  129. package/src/modes/rpc/rpc-mode.ts +10 -9
  130. package/src/prompts/review-request.md +27 -0
  131. package/src/prompts/reviewer.md +64 -68
  132. package/src/prompts/tools/output.md +22 -3
  133. package/src/prompts/tools/task.md +32 -33
  134. package/src/utils/clipboard.ts +2 -1
  135. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  136. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  137. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  138. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  139. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  140. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -0,0 +1,163 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ type JsonRecord = Record<string, unknown>;
5
+
6
+ function asRecord(value: unknown): JsonRecord | null {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
8
+ return value as JsonRecord;
9
+ }
10
+
11
+ function getString(record: JsonRecord | null, key: string): string | undefined {
12
+ if (!record) return undefined;
13
+ const value = record[key];
14
+ return typeof value === "string" ? value : undefined;
15
+ }
16
+
17
+ function getRecord(record: JsonRecord | null, key: string): JsonRecord | null {
18
+ if (!record) return null;
19
+ return asRecord(record[key]);
20
+ }
21
+
22
+ function getArray(record: JsonRecord | null, key: string): unknown[] | undefined {
23
+ if (!record) return undefined;
24
+ const value = record[key];
25
+ return Array.isArray(value) ? value : undefined;
26
+ }
27
+
28
+ function extractShortname(pathname: string): string | null {
29
+ const trimmed = pathname.replace(/\/+$/g, "");
30
+ const segments = trimmed.split("/").filter(Boolean);
31
+
32
+ if (segments.length < 2 || segments[0] !== "TR") return null;
33
+
34
+ if (segments.length === 2) {
35
+ const shortname = segments[1];
36
+ if (/^\d{4}$/.test(shortname)) return null;
37
+ return decodeURIComponent(shortname);
38
+ }
39
+
40
+ if (segments.length >= 3 && /^\d{4}$/.test(segments[1])) {
41
+ const version = segments[2];
42
+ const match = version.match(/^[A-Za-z]+-(.+)-\d{8}$/);
43
+ if (match?.[1]) return decodeURIComponent(match[1]);
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ function normalizeStatus(status?: string): { code?: string; label?: string } {
50
+ if (!status) return {};
51
+ const lower = status.toLowerCase();
52
+
53
+ if (lower.includes("working draft")) return { code: "WD", label: status };
54
+ if (lower.includes("candidate recommendation")) return { code: "CR", label: status };
55
+ if (lower.includes("proposed recommendation")) return { code: "PR", label: status };
56
+ if (lower.includes("recommendation")) return { code: "REC", label: status };
57
+
58
+ return { label: status };
59
+ }
60
+
61
+ function extractEditors(editorsPayload: JsonRecord | null): string[] {
62
+ const links = getRecord(editorsPayload, "_links");
63
+ const editors = getArray(links, "editors") ?? [];
64
+ const names: string[] = [];
65
+
66
+ for (const entry of editors) {
67
+ const record = asRecord(entry);
68
+ const title = getString(record, "title");
69
+ if (title) names.push(title);
70
+ }
71
+
72
+ return names;
73
+ }
74
+
75
+ export const handleW3c: SpecialHandler = async (
76
+ url: string,
77
+ timeout: number,
78
+ signal?: AbortSignal,
79
+ ): Promise<RenderResult | null> => {
80
+ try {
81
+ const parsed = new URL(url);
82
+ if (parsed.hostname !== "www.w3.org" && parsed.hostname !== "w3.org") return null;
83
+
84
+ const shortname = extractShortname(parsed.pathname);
85
+ if (!shortname) return null;
86
+
87
+ const fetchedAt = new Date().toISOString();
88
+
89
+ const specUrl = `https://api.w3.org/specifications/${encodeURIComponent(shortname)}`;
90
+ const latestUrl = `https://api.w3.org/specifications/${encodeURIComponent(shortname)}/versions/latest`;
91
+
92
+ const [specResult, latestResult] = await Promise.all([
93
+ loadPage(specUrl, { timeout, signal, headers: { Accept: "application/json" } }),
94
+ loadPage(latestUrl, { timeout, signal, headers: { Accept: "application/json" } }),
95
+ ]);
96
+
97
+ if (!specResult.ok || !latestResult.ok) return null;
98
+
99
+ const specPayload = asRecord(JSON.parse(specResult.content));
100
+ const latestPayload = asRecord(JSON.parse(latestResult.content));
101
+ if (!specPayload || !latestPayload) return null;
102
+
103
+ const title = getString(specPayload, "title");
104
+ const shortnameValue = getString(specPayload, "shortname") ?? shortname;
105
+ const description = getString(specPayload, "description") ?? getString(specPayload, "abstract");
106
+ const abstract = description ? htmlToBasicMarkdown(description) : undefined;
107
+
108
+ const latestVersionUrl =
109
+ getString(latestPayload, "uri") ??
110
+ getString(latestPayload, "shortlink") ??
111
+ getString(specPayload, "shortlink");
112
+
113
+ const latestStatus = getString(latestPayload, "status");
114
+ const normalizedStatus = normalizeStatus(latestStatus);
115
+
116
+ const specLinks = getRecord(specPayload, "_links");
117
+ const historyUrl = getString(getRecord(specLinks, "version-history"), "href");
118
+
119
+ const latestLinks = getRecord(latestPayload, "_links");
120
+ const editorsUrl = getString(getRecord(latestLinks, "editors"), "href");
121
+
122
+ let editors: string[] = [];
123
+ if (editorsUrl) {
124
+ const editorsResult = await loadPage(editorsUrl, { timeout: Math.min(timeout, 10), signal });
125
+ if (editorsResult.ok) {
126
+ try {
127
+ const editorsPayload = asRecord(JSON.parse(editorsResult.content));
128
+ editors = editorsPayload ? extractEditors(editorsPayload) : [];
129
+ } catch {}
130
+ }
131
+ }
132
+
133
+ let md = `# ${title ?? shortnameValue}\n\n`;
134
+ if (abstract) md += `## Abstract\n\n${abstract}\n\n`;
135
+
136
+ md += "## Metadata\n\n";
137
+ md += `**Shortname:** ${shortnameValue}\n`;
138
+ if (normalizedStatus.code) {
139
+ md += `**Status:** ${normalizedStatus.code}`;
140
+ if (normalizedStatus.label) md += ` (${normalizedStatus.label})`;
141
+ md += "\n";
142
+ } else if (normalizedStatus.label) {
143
+ md += `**Status:** ${normalizedStatus.label}\n`;
144
+ }
145
+ if (editors.length) md += `**Editors:** ${editors.join(", ")}\n`;
146
+ if (latestVersionUrl) md += `**Latest Version:** ${latestVersionUrl}\n`;
147
+ if (historyUrl) md += `**History:** ${historyUrl}\n`;
148
+
149
+ const output = finalizeOutput(md);
150
+ return {
151
+ url,
152
+ finalUrl: latestVersionUrl ?? url,
153
+ contentType: "text/markdown",
154
+ method: "w3c-api",
155
+ content: output.content,
156
+ fetchedAt,
157
+ truncated: output.truncated,
158
+ notes: ["Fetched via W3C API"],
159
+ };
160
+ } catch {}
161
+
162
+ return null;
163
+ };
@@ -90,7 +90,11 @@ type WikidataValue =
90
90
  /**
91
91
  * Handle Wikidata URLs via EntityData API
92
92
  */
93
- export const handleWikidata: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
93
+ export const handleWikidata: SpecialHandler = async (
94
+ url: string,
95
+ timeout: number,
96
+ signal?: AbortSignal,
97
+ ): Promise<RenderResult | null> => {
94
98
  try {
95
99
  const parsed = new URL(url);
96
100
  if (!parsed.hostname.includes("wikidata.org")) return null;
@@ -104,7 +108,7 @@ export const handleWikidata: SpecialHandler = async (url: string, timeout: numbe
104
108
 
105
109
  // Fetch entity data from API
106
110
  const apiUrl = `https://www.wikidata.org/wiki/Special:EntityData/${qid}.json`;
107
- const result = await loadPage(apiUrl, { timeout });
111
+ const result = await loadPage(apiUrl, { timeout, signal });
108
112
 
109
113
  if (!result.ok) return null;
110
114
 
@@ -149,7 +153,7 @@ export const handleWikidata: SpecialHandler = async (url: string, timeout: numbe
149
153
  }
150
154
 
151
155
  // Fetch labels for referenced entities (limit to 50)
152
- const entityLabels = await resolveEntityLabels(Array.from(entityIdsToResolve).slice(0, 50), timeout);
156
+ const entityLabels = await resolveEntityLabels(Array.from(entityIdsToResolve).slice(0, 50), timeout, signal);
153
157
 
154
158
  // Group claims by property
155
159
  const processedProperties: string[] = [];
@@ -256,7 +260,11 @@ function getLocalizedAliases(
256
260
  /**
257
261
  * Resolve entity IDs to their labels via wbgetentities API
258
262
  */
259
- async function resolveEntityLabels(entityIds: string[], timeout: number): Promise<Record<string, string>> {
263
+ async function resolveEntityLabels(
264
+ entityIds: string[],
265
+ timeout: number,
266
+ signal?: AbortSignal,
267
+ ): Promise<Record<string, string>> {
260
268
  if (entityIds.length === 0) return {};
261
269
 
262
270
  const labels: Record<string, string> = {};
@@ -268,7 +276,7 @@ async function resolveEntityLabels(entityIds: string[], timeout: number): Promis
268
276
  const apiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${batch.join("|")}&props=labels&languages=en&format=json`;
269
277
 
270
278
  try {
271
- const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 10) });
279
+ const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 10), signal });
272
280
  if (result.ok) {
273
281
  const data = JSON.parse(result.content) as {
274
282
  entities: Record<string, { labels?: Record<string, { value: string }> }>;
@@ -5,7 +5,11 @@ import { finalizeOutput, loadPage } from "./types";
5
5
  /**
6
6
  * Handle Wikipedia URLs via Wikipedia API
7
7
  */
8
- export const handleWikipedia: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
8
+ export const handleWikipedia: SpecialHandler = async (
9
+ url: string,
10
+ timeout: number,
11
+ signal?: AbortSignal,
12
+ ): Promise<RenderResult | null> => {
9
13
  try {
10
14
  const parsed = new URL(url);
11
15
  // Match *.wikipedia.org
@@ -21,7 +25,7 @@ export const handleWikipedia: SpecialHandler = async (url: string, timeout: numb
21
25
 
22
26
  // Use Wikipedia API to get plain text extract
23
27
  const apiUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
24
- const summaryResult = await loadPage(apiUrl, { timeout });
28
+ const summaryResult = await loadPage(apiUrl, { timeout, signal });
25
29
 
26
30
  let md = "";
27
31
 
@@ -38,7 +42,7 @@ export const handleWikipedia: SpecialHandler = async (url: string, timeout: numb
38
42
 
39
43
  // Get full article content via mobile-html or parse API
40
44
  const contentUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/mobile-html/${encodeURIComponent(title)}`;
41
- const contentResult = await loadPage(contentUrl, { timeout });
45
+ const contentResult = await loadPage(contentUrl, { timeout, signal });
42
46
 
43
47
  if (contentResult.ok) {
44
48
  const doc = parseHtml(contentResult.content);
@@ -1,6 +1,8 @@
1
1
  import { unlinkSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
+ import type { FileSink } from "bun";
5
+ import { nanoid } from "nanoid";
4
6
  import { ensureTool } from "../../../utils/tools-manager";
5
7
  import type { RenderResult, SpecialHandler } from "./types";
6
8
  import { finalizeOutput } from "./types";
@@ -8,20 +10,44 @@ import { finalizeOutput } from "./types";
8
10
  /**
9
11
  * Execute a command and return stdout
10
12
  */
11
- function exec(
13
+ async function exec(
12
14
  cmd: string,
13
15
  args: string[],
14
- options?: { timeout?: number; input?: string | Buffer },
15
- ): { stdout: string; stderr: string; ok: boolean } {
16
- const result = Bun.spawnSync([cmd, ...args], {
17
- stdin: options?.input ? (options.input as any) : "ignore",
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",
18
20
  stdout: "pipe",
19
21
  stderr: "pipe",
22
+ timeout: options?.timeout,
23
+ signal: options?.signal,
20
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
+
21
46
  return {
22
- stdout: result.stdout?.toString() ?? "",
23
- stderr: result.stderr?.toString() ?? "",
24
- ok: result.exitCode === 0,
47
+ stdout,
48
+ stderr,
49
+ ok: exitCode === 0,
50
+ exitCode,
25
51
  };
26
52
  }
27
53
 
@@ -124,12 +150,18 @@ function formatDuration(seconds: number): string {
124
150
  /**
125
151
  * Handle YouTube URLs - fetch metadata and transcript
126
152
  */
127
- export const handleYouTube: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
153
+ export const handleYouTube: SpecialHandler = async (
154
+ url: string,
155
+ timeout: number,
156
+ signal?: AbortSignal,
157
+ ): Promise<RenderResult | null> => {
158
+ signal?.throwIfAborted();
128
159
  const yt = parseYouTubeUrl(url);
129
160
  if (!yt) return null;
130
161
 
131
162
  // Ensure yt-dlp is available (auto-download if missing)
132
163
  const ytdlp = await ensureTool("yt-dlp", true);
164
+ signal?.throwIfAborted();
133
165
  if (!ytdlp) {
134
166
  return {
135
167
  url,
@@ -148,9 +180,16 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number
148
180
  const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`;
149
181
 
150
182
  // Fetch video metadata
151
- const metaResult = exec(ytdlp, ["--dump-json", "--no-warnings", "--no-playlist", "--skip-download", videoUrl], {
152
- timeout: timeout * 1000,
153
- });
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();
154
193
 
155
194
  let title = "YouTube Video";
156
195
  let channel = "";
@@ -190,21 +229,29 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number
190
229
  let transcriptSource = "";
191
230
 
192
231
  // First, list available subtitles
193
- const listResult = exec(ytdlp, ["--list-subs", "--no-warnings", "--no-playlist", "--skip-download", videoUrl], {
194
- timeout: timeout * 1000,
195
- });
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();
196
242
 
197
243
  const hasManualSubs = listResult.stdout.includes("[info] Available subtitles");
198
244
  const hasAutoSubs = listResult.stdout.includes("[info] Available automatic captions");
199
245
 
200
246
  // Create temp directory for subtitle download
201
247
  const tmpDir = tmpdir();
202
- const tmpBase = path.join(tmpDir, `yt-${yt.videoId}-${Date.now()}`);
248
+ const tmpBase = path.join(tmpDir, `yt-${yt.videoId}-${nanoid()}`);
203
249
 
204
250
  try {
205
251
  // Try manual subtitles first (English preferred)
206
252
  if (hasManualSubs) {
207
- const subResult = exec(
253
+ signal?.throwIfAborted();
254
+ const subResult = await exec(
208
255
  ytdlp,
209
256
  [
210
257
  "--write-sub",
@@ -219,13 +266,15 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number
219
266
  tmpBase,
220
267
  videoUrl,
221
268
  ],
222
- { timeout: timeout * 1000 },
269
+ { timeout: timeout * 1000, signal },
223
270
  );
224
271
 
225
272
  if (subResult.ok) {
226
273
  // Find the downloaded subtitle file using glob
274
+ signal?.throwIfAborted();
227
275
  const subFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*.vtt`).scan({ absolute: true }));
228
276
  if (subFiles.length > 0) {
277
+ signal?.throwIfAborted();
229
278
  const vttContent = await Bun.file(subFiles[0]).text();
230
279
  transcript = cleanVttToText(vttContent);
231
280
  transcriptSource = "manual";
@@ -236,7 +285,8 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number
236
285
 
237
286
  // Fall back to auto-generated captions
238
287
  if (!transcript && hasAutoSubs) {
239
- const autoResult = exec(
288
+ signal?.throwIfAborted();
289
+ const autoResult = await exec(
240
290
  ytdlp,
241
291
  [
242
292
  "--write-auto-sub",
@@ -251,12 +301,14 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number
251
301
  tmpBase,
252
302
  videoUrl,
253
303
  ],
254
- { timeout: timeout * 1000 },
304
+ { timeout: timeout * 1000, signal },
255
305
  );
256
306
 
257
307
  if (autoResult.ok) {
308
+ signal?.throwIfAborted();
258
309
  const subFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*.vtt`).scan({ absolute: true }));
259
310
  if (subFiles.length > 0) {
311
+ signal?.throwIfAborted();
260
312
  const vttContent = await Bun.file(subFiles[0]).text();
261
313
  transcript = cleanVttToText(vttContent);
262
314
  transcriptSource = "auto-generated";
@@ -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;