@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
@@ -1,16 +1,16 @@
1
1
  /**
2
- * Review tools - report_finding and submit_review
2
+ * Review tools - report_finding for structured code review.
3
3
  *
4
4
  * Used by the reviewer agent to report findings in a structured way.
5
- * Both tools are hidden by default - only enabled when explicitly listed in agent's tools.
5
+ * Hidden by default - only enabled when explicitly listed in agent's tools.
6
+ * Reviewers finish via `complete` tool with SubmitReviewDetails schema.
6
7
  */
7
8
 
8
9
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
9
10
  import type { Component } from "@oh-my-pi/pi-tui";
10
- import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
11
+ import { Container, Text } from "@oh-my-pi/pi-tui";
11
12
  import { Type } from "@sinclair/typebox";
12
- import type { Theme } from "../../modes/interactive/theme/theme";
13
- import { theme } from "../../modes/interactive/theme/theme";
13
+ import type { Theme, ThemeColor } from "../../modes/interactive/theme/theme";
14
14
 
15
15
  const PRIORITY_LABELS: Record<number, string> = {
16
16
  0: "P0",
@@ -19,6 +19,24 @@ const PRIORITY_LABELS: Record<number, string> = {
19
19
  3: "P3",
20
20
  };
21
21
 
22
+ const PRIORITY_META: Record<number, { symbol: "status.error" | "status.warning" | "status.info"; color: ThemeColor }> =
23
+ {
24
+ 0: { symbol: "status.error", color: "error" },
25
+ 1: { symbol: "status.warning", color: "warning" },
26
+ 2: { symbol: "status.warning", color: "muted" },
27
+ 3: { symbol: "status.info", color: "accent" },
28
+ };
29
+
30
+ function getPriorityDisplay(priority: number, theme: Theme): { label: string; icon: string; color: ThemeColor } {
31
+ const label = PRIORITY_LABELS[priority] ?? "P?";
32
+ const meta = PRIORITY_META[priority] ?? { symbol: "status.info", color: "muted" as const };
33
+ return {
34
+ label,
35
+ icon: theme.styledSymbol(meta.symbol, meta.color),
36
+ color: meta.color,
37
+ };
38
+ }
39
+
22
40
  // report_finding schema
23
41
  const ReportFindingParams = Type.Object({
24
42
  title: Type.String({
@@ -53,7 +71,7 @@ interface ReportFindingDetails {
53
71
  export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFindingDetails, Theme> = {
54
72
  name: "report_finding",
55
73
  label: "Report Finding",
56
- description: "Report a code review finding. Use this for each issue found. Call submit_review when done.",
74
+ description: "Report a code review finding. Use this for each issue found. Call complete when done.",
57
75
  parameters: ReportFindingParams,
58
76
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
59
77
  const { title, body, priority, confidence, file_path, line_start, line_end } = params;
@@ -73,11 +91,10 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
73
91
  },
74
92
 
75
93
  renderCall(args, theme): Component {
76
- const priority = PRIORITY_LABELS[args.priority as number] ?? "P?";
77
- const color = args.priority === 0 ? "error" : args.priority === 1 ? "warning" : "muted";
94
+ const { label, icon, color } = getPriorityDisplay(args.priority as number, theme);
78
95
  const titleText = String(args.title).replace(/^\[P\d\]\s*/, "");
79
96
  return new Text(
80
- `${theme.fg("toolTitle", theme.bold("report_finding "))}${theme.fg(color, `[${priority}]`)} ${theme.fg(
97
+ `${theme.fg("toolTitle", theme.bold("report_finding "))}${icon} ${theme.fg(color, `[${label}]`)} ${theme.fg(
81
98
  "dim",
82
99
  titleText,
83
100
  )}`,
@@ -93,111 +110,31 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
93
110
  return new Text(text?.type === "text" ? text.text : "", 0, 0);
94
111
  }
95
112
 
96
- const priority = PRIORITY_LABELS[details.priority] ?? "P?";
97
- const color = details.priority === 0 ? "error" : details.priority === 1 ? "warning" : "muted";
113
+ const { label, icon, color } = getPriorityDisplay(details.priority, theme);
98
114
  const location = `${details.file_path}:${details.line_start}${
99
115
  details.line_end !== details.line_start ? `-${details.line_end}` : ""
100
116
  }`;
101
117
 
102
118
  return new Text(
103
- `${theme.fg("success", theme.status.success)} ${theme.fg(color, `[${priority}]`)} ${theme.fg("dim", location)}`,
119
+ `${theme.fg("success", theme.status.success)} ${icon} ${theme.fg(color, `[${label}]`)} ${theme.fg(
120
+ "dim",
121
+ location,
122
+ )}`,
104
123
  0,
105
124
  0,
106
125
  );
107
126
  },
108
127
  };
109
128
 
110
- // submit_review schema
111
- const SubmitReviewParams = Type.Object({
112
- overall_correctness: Type.Union([Type.Literal("correct"), Type.Literal("incorrect")], {
113
- description: "Whether the patch is correct (no bugs, tests won't break)",
114
- }),
115
- explanation: Type.String({
116
- description: "1-3 sentence explanation justifying the verdict",
117
- }),
118
- confidence: Type.Number({
119
- minimum: 0,
120
- maximum: 1,
121
- description: "Overall confidence score 0.0-1.0",
122
- }),
123
- });
124
-
125
- interface SubmitReviewDetails {
129
+ /** SubmitReviewDetails - used for rendering review results from complete tool */
130
+ export interface SubmitReviewDetails {
126
131
  overall_correctness: "correct" | "incorrect";
127
132
  explanation: string;
128
133
  confidence: number;
129
134
  }
130
135
 
131
- export const submitReviewTool: AgentTool<typeof SubmitReviewParams, SubmitReviewDetails, Theme> = {
132
- name: "submit_review",
133
- label: "Submit Review",
134
- description: "Submit the final review verdict. Call this after all findings have been reported.",
135
- parameters: SubmitReviewParams,
136
-
137
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
138
- const { overall_correctness, explanation, confidence } = params;
139
-
140
- let summary = `## Review Summary\n\n`;
141
- summary += `**Verdict:** ${
142
- overall_correctness === "correct"
143
- ? `${theme.status.success} Patch is correct`
144
- : `${theme.status.error} Patch is incorrect`
145
- }\n`;
146
- summary += `**Confidence:** ${(confidence * 100).toFixed(0)}%\n\n`;
147
- summary += explanation;
148
-
149
- return {
150
- content: [{ type: "text", text: summary }],
151
- details: { overall_correctness, explanation, confidence },
152
- };
153
- },
154
-
155
- renderCall(args, theme): Component {
156
- const verdict = args.overall_correctness === "correct" ? "correct" : "incorrect";
157
- const color = args.overall_correctness === "correct" ? "success" : "error";
158
- return new Text(
159
- `${theme.fg("toolTitle", theme.bold("submit_review "))}${theme.fg(color, verdict)} ${theme.fg(
160
- "dim",
161
- `(${((args.confidence as number) * 100).toFixed(0)}%)`,
162
- )}`,
163
- 0,
164
- 0,
165
- );
166
- },
167
-
168
- renderResult(result, { expanded }, theme): Component {
169
- const { details } = result;
170
- if (!details) {
171
- const text = result.content[0];
172
- return new Text(text?.type === "text" ? text.text : "", 0, 0);
173
- }
174
-
175
- const container = new Container();
176
- const verdictColor = details.overall_correctness === "correct" ? "success" : "error";
177
- const verdictIcon = details.overall_correctness === "correct" ? theme.status.success : theme.status.error;
178
-
179
- container.addChild(
180
- new Text(
181
- `${theme.fg(verdictColor, verdictIcon)} Patch is ${theme.fg(
182
- verdictColor,
183
- details.overall_correctness,
184
- )} ${theme.fg("dim", `(${(details.confidence * 100).toFixed(0)}% confidence)`)}`,
185
- 0,
186
- 0,
187
- ),
188
- );
189
-
190
- if (expanded) {
191
- container.addChild(new Spacer(1));
192
- container.addChild(new Text(theme.fg("dim", details.explanation), 0, 0));
193
- }
194
-
195
- return container;
196
- },
197
- };
198
-
199
136
  // Re-export types for external use
200
- export type { ReportFindingDetails, SubmitReviewDetails };
137
+ export type { ReportFindingDetails };
201
138
 
202
139
  // ─────────────────────────────────────────────────────────────────────────────
203
140
  // Subprocess tool handlers - registered for extraction/rendering in task tool
@@ -211,11 +148,10 @@ subprocessToolRegistry.register<ReportFindingDetails>("report_finding", {
211
148
  extractData: (event) => event.result?.details as ReportFindingDetails | undefined,
212
149
 
213
150
  renderInline: (data, theme) => {
214
- const priority = PRIORITY_LABELS[data.priority] ?? "P?";
215
- const color = data.priority === 0 ? "error" : data.priority === 1 ? "warning" : "muted";
151
+ const { label, icon, color } = getPriorityDisplay(data.priority, theme);
216
152
  const titleText = data.title.replace(/^\[P\d\]\s*/, "");
217
153
  const loc = `${path.basename(data.file_path)}:${data.line_start}`;
218
- return new Text(`${theme.fg(color, `[${priority}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0);
154
+ return new Text(`${icon} ${theme.fg(color, `[${label}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0);
219
155
  },
220
156
 
221
157
  renderFinal: (allData, theme, expanded) => {
@@ -224,13 +160,12 @@ subprocessToolRegistry.register<ReportFindingDetails>("report_finding", {
224
160
 
225
161
  for (let i = 0; i < displayCount; i++) {
226
162
  const data = allData[i];
227
- const priority = PRIORITY_LABELS[data.priority] ?? "P?";
228
- const color = data.priority === 0 ? "error" : data.priority === 1 ? "warning" : "muted";
163
+ const { label, icon, color } = getPriorityDisplay(data.priority, theme);
229
164
  const titleText = data.title.replace(/^\[P\d\]\s*/, "");
230
165
  const loc = `${path.basename(data.file_path)}:${data.line_start}`;
231
166
 
232
167
  container.addChild(
233
- new Text(` ${theme.fg(color, `[${priority}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0),
168
+ new Text(` ${icon} ${theme.fg(color, `[${label}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0),
234
169
  );
235
170
 
236
171
  if (expanded && data.body) {
@@ -251,26 +186,3 @@ subprocessToolRegistry.register<ReportFindingDetails>("report_finding", {
251
186
  return container;
252
187
  },
253
188
  });
254
-
255
- // Register submit_review handler
256
- subprocessToolRegistry.register<SubmitReviewDetails>("submit_review", {
257
- extractData: (event) => event.result?.details as SubmitReviewDetails | undefined,
258
-
259
- // Terminate subprocess after review is submitted
260
- shouldTerminate: () => true,
261
-
262
- renderInline: (data, theme) => {
263
- const verdictColor = data.overall_correctness === "correct" ? "success" : "error";
264
- const verdictIcon = data.overall_correctness === "correct" ? theme.status.success : theme.status.error;
265
- return new Text(
266
- `${theme.fg(verdictColor, verdictIcon)} Review: ${theme.fg(verdictColor, data.overall_correctness)} (${(
267
- data.confidence * 100
268
- ).toFixed(0)}%)`,
269
- 0,
270
- 0,
271
- );
272
- },
273
-
274
- // Note: renderFinal is NOT used for submit_review - we use the combined
275
- // renderReviewResult in render.ts to show verdict + findings together
276
- });
@@ -8,6 +8,7 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
+ import { nanoid } from "nanoid";
11
12
 
12
13
  /**
13
14
  * Derive artifacts directory from session file path.
@@ -62,14 +63,14 @@ export async function writeArtifacts(
62
63
  const paths = getArtifactPaths(dir, taskId);
63
64
 
64
65
  // Write input
65
- await fs.promises.writeFile(paths.inputPath, input, "utf-8");
66
+ await Bun.write(paths.inputPath, input);
66
67
 
67
68
  // Write output
68
- await fs.promises.writeFile(paths.outputPath, output, "utf-8");
69
+ await Bun.write(paths.outputPath, output);
69
70
 
70
71
  // Write JSONL if events provided
71
72
  if (jsonlEvents && jsonlEvents.length > 0) {
72
- await fs.promises.writeFile(paths.jsonlPath, jsonlEvents.join("\n"), "utf-8");
73
+ await Bun.write(paths.jsonlPath, jsonlEvents.join("\n"));
73
74
  return paths;
74
75
  }
75
76
 
@@ -80,7 +81,7 @@ export async function writeArtifacts(
80
81
  * Create a temporary artifacts directory.
81
82
  */
82
83
  export function createTempArtifactsDir(runId?: string): string {
83
- const id = runId || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
84
+ const id = runId || nanoid();
84
85
  const dir = path.join(os.tmpdir(), `omp-task-${id}`);
85
86
  ensureArtifactsDir(dir);
86
87
  return dir;
@@ -4,7 +4,6 @@
4
4
  * Runs each subagent in a Bun Worker and forwards AgentEvents for progress tracking.
5
5
  */
6
6
 
7
- import { writeFileSync } from "node:fs";
8
7
  import type { AgentEvent } from "@oh-my-pi/pi-agent-core";
9
8
  import type { EventBus } from "../../event-bus";
10
9
  import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
@@ -50,20 +49,26 @@ function truncateOutput(output: string): { text: string; truncated: boolean } {
50
49
 
51
50
  let i = 0;
52
51
  let lastNewlineIndex = -1;
53
- while (i < output.length && byteBudget > 0) {
54
- const ch = output.charCodeAt(i);
55
- byteBudget--;
52
+ while (i < output.length) {
53
+ const codePoint = output.codePointAt(i);
54
+ if (codePoint === undefined) break;
55
+ const codeUnitLength = codePoint > 0xffff ? 2 : 1;
56
+ const byteLen = codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4;
57
+ if (byteBudget - byteLen < 0) {
58
+ truncated = true;
59
+ break;
60
+ }
61
+ byteBudget -= byteLen;
62
+ i += codeUnitLength;
56
63
 
57
- if (ch === 10 /* \n */) {
64
+ if (codePoint === 0x0a) {
58
65
  lineBudget--;
59
- lastNewlineIndex = i;
66
+ lastNewlineIndex = i - 1;
60
67
  if (lineBudget <= 0) {
61
68
  truncated = true;
62
69
  break;
63
70
  }
64
71
  }
65
-
66
- i++;
67
72
  }
68
73
 
69
74
  if (i < output.length) {
@@ -186,7 +191,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
186
191
 
187
192
  // Write input file immediately (real-time visibility)
188
193
  try {
189
- writeFileSync(artifactPaths.inputPath, fullTask, "utf-8");
194
+ await Bun.write(artifactPaths.inputPath, fullTask);
190
195
  } catch {
191
196
  // Non-fatal, continue without input artifact
192
197
  }
@@ -207,13 +212,40 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
207
212
  const sessionFile = subtaskSessionFile ?? options.sessionFile ?? null;
208
213
  const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
209
214
 
210
- const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
215
+ let worker: Worker;
216
+ try {
217
+ worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
218
+ } catch (err) {
219
+ return {
220
+ index,
221
+ taskId,
222
+ agent: agent.name,
223
+ agentSource: agent.source,
224
+ task,
225
+ description: options.description,
226
+ exitCode: 1,
227
+ output: "",
228
+ stderr: `Failed to create worker: ${err instanceof Error ? err.message : String(err)}`,
229
+ truncated: false,
230
+ durationMs: Date.now() - startTime,
231
+ tokens: 0,
232
+ modelOverride,
233
+ error: `Failed to create worker: ${err instanceof Error ? err.message : String(err)}`,
234
+ };
235
+ }
211
236
 
212
- let output = "";
237
+ const outputChunks: string[] = [];
238
+ const finalOutputChunks: string[] = [];
213
239
  let stderr = "";
214
- let finalOutput = "";
215
240
  let resolved = false;
216
- let pendingTermination = false; // Set when shouldTerminate fires, wait for message_end
241
+ type AbortReason = "signal" | "terminate";
242
+ let abortSent = false;
243
+ let abortReason: AbortReason | undefined;
244
+ let terminationScheduled = false;
245
+ let pendingTerminationController: AbortController | null = null;
246
+ let finalize: ((message: Extract<SubagentWorkerResponse, { type: "done" }>) => void) | null = null;
247
+ const listenerController = new AbortController();
248
+ const listenerSignal = listenerController.signal;
217
249
 
218
250
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
219
251
  const accumulatedUsage = {
@@ -226,25 +258,80 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
226
258
  };
227
259
  let hasUsage = false;
228
260
 
229
- let abortSent = false;
230
- const requestAbort = () => {
231
- if (abortSent) return;
261
+ const scheduleTermination = () => {
262
+ if (terminationScheduled) return;
263
+ terminationScheduled = true;
264
+ const timeoutSignal = AbortSignal.timeout(2000);
265
+ timeoutSignal.addEventListener(
266
+ "abort",
267
+ () => {
268
+ if (resolved) return;
269
+ try {
270
+ worker.terminate();
271
+ } catch {
272
+ // Ignore termination errors
273
+ }
274
+ if (finalize && !resolved) {
275
+ finalize({
276
+ type: "done",
277
+ exitCode: 1,
278
+ durationMs: Date.now() - startTime,
279
+ error: abortReason === "signal" ? "Aborted" : "Worker terminated after tool completion",
280
+ aborted: abortReason === "signal",
281
+ });
282
+ }
283
+ },
284
+ { once: true, signal: listenerSignal },
285
+ );
286
+ };
287
+
288
+ const requestAbort = (reason: AbortReason) => {
289
+ if (abortSent) {
290
+ if (reason === "signal" && abortReason !== "signal") {
291
+ abortReason = "signal";
292
+ }
293
+ return;
294
+ }
295
+ if (resolved) return;
232
296
  abortSent = true;
297
+ abortReason = reason;
233
298
  const abortMessage: SubagentWorkerRequest = { type: "abort" };
234
- worker.postMessage(abortMessage);
235
- setTimeout(() => {
236
- if (!resolved) {
237
- worker.terminate();
238
- }
239
- }, 2000);
299
+ try {
300
+ worker.postMessage(abortMessage);
301
+ } catch {
302
+ // Worker already terminated, nothing to do
303
+ }
304
+ // Cancel pending termination if it exists
305
+ if (pendingTerminationController) {
306
+ pendingTerminationController.abort();
307
+ pendingTerminationController = null;
308
+ }
309
+ scheduleTermination();
310
+ };
311
+
312
+ const schedulePendingTermination = () => {
313
+ if (pendingTerminationController || abortSent || terminationScheduled || resolved) return;
314
+ const readyController = new AbortController();
315
+ pendingTerminationController = readyController;
316
+ const pendingSignal = AbortSignal.any([AbortSignal.timeout(2000), readyController.signal]);
317
+ pendingSignal.addEventListener(
318
+ "abort",
319
+ () => {
320
+ pendingTerminationController = null;
321
+ if (!resolved) {
322
+ requestAbort("terminate");
323
+ }
324
+ },
325
+ { once: true, signal: listenerSignal },
326
+ );
240
327
  };
241
328
 
242
329
  // Handle abort signal
243
330
  const onAbort = () => {
244
- if (!resolved) requestAbort();
331
+ if (!resolved) requestAbort("signal");
245
332
  };
246
333
  if (signal) {
247
- signal.addEventListener("abort", onAbort, { once: true });
334
+ signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
248
335
  }
249
336
 
250
337
  const emitProgress = () => {
@@ -347,13 +434,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
347
434
  })
348
435
  ) {
349
436
  // Don't terminate immediately - wait for message_end to get token counts
350
- pendingTermination = true;
351
- // Safety timeout in case message_end never arrives
352
- setTimeout(() => {
353
- if (!resolved) {
354
- requestAbort();
355
- }
356
- }, 2000);
437
+ schedulePendingTermination();
357
438
  }
358
439
  }
359
440
  break;
@@ -386,7 +467,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
386
467
  if (messageContent && Array.isArray(messageContent)) {
387
468
  for (const block of messageContent) {
388
469
  if (block.type === "text" && block.text) {
389
- output += block.text;
470
+ outputChunks.push(block.text);
390
471
  }
391
472
  }
392
473
  }
@@ -395,33 +476,29 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
395
476
  const messageUsage = getMessageUsage(event.message) || (event as AgentEvent & { usage?: unknown }).usage;
396
477
  if (messageUsage && typeof messageUsage === "object") {
397
478
  // Only count assistant messages (not tool results, etc.)
398
- if (
399
- role === "assistant" &&
400
- event.message?.stopReason !== "aborted" &&
401
- event.message?.stopReason !== "error"
402
- ) {
403
- const usageRecord = messageUsage as Record<string, number | undefined>;
404
- const costRecord = (messageUsage as { cost?: Record<string, number | undefined> }).cost;
479
+ if (role === "assistant") {
480
+ const usageRecord = messageUsage as Record<string, unknown>;
481
+ const costRecord = (messageUsage as { cost?: Record<string, unknown> }).cost;
405
482
  hasUsage = true;
406
- accumulatedUsage.input += usageRecord.input ?? 0;
407
- accumulatedUsage.output += usageRecord.output ?? 0;
408
- accumulatedUsage.cacheRead += usageRecord.cacheRead ?? 0;
409
- accumulatedUsage.cacheWrite += usageRecord.cacheWrite ?? 0;
410
- accumulatedUsage.totalTokens += usageRecord.totalTokens ?? 0;
483
+ accumulatedUsage.input += getNumberField(usageRecord, "input") ?? 0;
484
+ accumulatedUsage.output += getNumberField(usageRecord, "output") ?? 0;
485
+ accumulatedUsage.cacheRead += getNumberField(usageRecord, "cacheRead") ?? 0;
486
+ accumulatedUsage.cacheWrite += getNumberField(usageRecord, "cacheWrite") ?? 0;
487
+ accumulatedUsage.totalTokens += getNumberField(usageRecord, "totalTokens") ?? 0;
411
488
  if (costRecord) {
412
- accumulatedUsage.cost.input += costRecord.input ?? 0;
413
- accumulatedUsage.cost.output += costRecord.output ?? 0;
414
- accumulatedUsage.cost.cacheRead += costRecord.cacheRead ?? 0;
415
- accumulatedUsage.cost.cacheWrite += costRecord.cacheWrite ?? 0;
416
- accumulatedUsage.cost.total += costRecord.total ?? 0;
489
+ accumulatedUsage.cost.input += getNumberField(costRecord, "input") ?? 0;
490
+ accumulatedUsage.cost.output += getNumberField(costRecord, "output") ?? 0;
491
+ accumulatedUsage.cost.cacheRead += getNumberField(costRecord, "cacheRead") ?? 0;
492
+ accumulatedUsage.cost.cacheWrite += getNumberField(costRecord, "cacheWrite") ?? 0;
493
+ accumulatedUsage.cost.total += getNumberField(costRecord, "total") ?? 0;
417
494
  }
418
495
  }
419
496
  // Accumulate tokens for progress display
420
497
  progress.tokens += getUsageTokens(messageUsage);
421
498
  }
422
499
  // If pending termination, now we have tokens - terminate
423
- if (pendingTermination && !resolved) {
424
- requestAbort();
500
+ if (pendingTerminationController) {
501
+ pendingTerminationController.abort();
425
502
  }
426
503
  break;
427
504
  }
@@ -435,7 +512,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
435
512
  if (messageContent && Array.isArray(messageContent)) {
436
513
  for (const block of messageContent) {
437
514
  if (block.type === "text" && block.text) {
438
- finalOutput += block.text;
515
+ finalOutputChunks.push(block.text);
439
516
  }
440
517
  }
441
518
  }
@@ -469,38 +546,90 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
469
546
  }
470
547
 
471
548
  const done = await new Promise<Extract<SubagentWorkerResponse, { type: "done" }>>((resolve) => {
549
+ const cleanup = () => {
550
+ pendingTerminationController = null;
551
+ listenerController.abort();
552
+ };
553
+ finalize = (message) => {
554
+ if (resolved) return;
555
+ resolved = true;
556
+ cleanup();
557
+ resolve(message);
558
+ };
472
559
  const onMessage = (event: WorkerMessageEvent<SubagentWorkerResponse>) => {
473
560
  const message = event.data;
474
561
  if (!message || resolved) return;
475
562
  if (message.type === "event") {
476
- processEvent(message.event);
563
+ try {
564
+ processEvent(message.event);
565
+ } catch (err) {
566
+ finalize?.({
567
+ type: "done",
568
+ exitCode: 1,
569
+ durationMs: Date.now() - startTime,
570
+ error: `Failed to process worker event: ${err instanceof Error ? err.message : String(err)}`,
571
+ });
572
+ }
477
573
  return;
478
574
  }
479
575
  if (message.type === "done") {
480
- resolved = true;
481
- resolve(message);
576
+ finalize?.(message);
482
577
  }
483
578
  };
484
579
  const onError = (event: WorkerErrorEvent) => {
485
- if (resolved) return;
486
- resolved = true;
487
- resolve({
580
+ finalize?.({
488
581
  type: "done",
489
582
  exitCode: 1,
490
583
  durationMs: Date.now() - startTime,
491
584
  error: event.message,
492
585
  });
493
586
  };
494
- worker.addEventListener("message", onMessage);
495
- worker.addEventListener("error", onError);
496
- worker.postMessage(startMessage);
587
+ const onMessageError = () => {
588
+ finalize?.({
589
+ type: "done",
590
+ exitCode: 1,
591
+ durationMs: Date.now() - startTime,
592
+ error: "Worker message deserialization failed",
593
+ });
594
+ };
595
+ const onClose = () => {
596
+ // Worker terminated unexpectedly (crashed or was killed without sending done)
597
+ const abortMessage =
598
+ abortSent && abortReason === "signal"
599
+ ? "Worker terminated after abort"
600
+ : abortSent
601
+ ? "Worker terminated after tool completion"
602
+ : "Worker terminated unexpectedly";
603
+ finalize?.({
604
+ type: "done",
605
+ exitCode: 1,
606
+ durationMs: Date.now() - startTime,
607
+ error: abortMessage,
608
+ aborted: abortReason === "signal",
609
+ });
610
+ };
611
+ worker.addEventListener("message", onMessage, { signal: listenerSignal });
612
+ worker.addEventListener("error", onError, { signal: listenerSignal });
613
+ worker.addEventListener("close", onClose, { signal: listenerSignal });
614
+ worker.addEventListener("messageerror", onMessageError, { signal: listenerSignal });
615
+ try {
616
+ worker.postMessage(startMessage);
617
+ } catch (err) {
618
+ finalize({
619
+ type: "done",
620
+ exitCode: 1,
621
+ durationMs: Date.now() - startTime,
622
+ error: `Failed to start worker: ${err instanceof Error ? err.message : String(err)}`,
623
+ });
624
+ }
497
625
  });
498
626
 
499
627
  // Cleanup
500
- if (signal) {
501
- signal.removeEventListener("abort", onAbort);
628
+ try {
629
+ worker.terminate();
630
+ } catch {
631
+ // Ignore termination errors
502
632
  }
503
- worker.terminate();
504
633
 
505
634
  let exitCode = done.exitCode;
506
635
  if (done.error) {
@@ -508,7 +637,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
508
637
  }
509
638
 
510
639
  // Use final output if available, otherwise accumulated output
511
- let rawOutput = finalOutput || output;
640
+ let rawOutput = finalOutputChunks.length > 0 ? finalOutputChunks.join("") : outputChunks.join("");
512
641
  let abortedViaComplete = false;
513
642
  const completeItems = progress.extractedToolData?.complete as
514
643
  | Array<{ data?: unknown; status?: "success" | "aborted"; error?: string }>
@@ -528,7 +657,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
528
657
  }
529
658
  } else {
530
659
  // Normal successful completion
531
- const completeData = lastComplete?.data ?? null;
660
+ let completeData = lastComplete?.data ?? null;
661
+ // Handle double-stringified JSON (subagent returned JSON string instead of object)
662
+ if (typeof completeData === "string" && (completeData.startsWith("{") || completeData.startsWith("["))) {
663
+ try {
664
+ completeData = JSON.parse(completeData);
665
+ } catch {
666
+ // Not valid JSON, keep as string
667
+ }
668
+ }
532
669
  try {
533
670
  rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
534
671
  } catch (err) {
@@ -549,7 +686,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
549
686
  let outputMeta: { lineCount: number; charCount: number } | undefined;
550
687
  if (artifactPaths) {
551
688
  try {
552
- writeFileSync(artifactPaths.outputPath, rawOutput, "utf-8");
689
+ await Bun.write(artifactPaths.outputPath, rawOutput);
553
690
  outputMeta = {
554
691
  lineCount: rawOutput.split("\n").length,
555
692
  charCount: rawOutput.length,