@oh-my-pi/pi-coding-agent 12.18.3 → 12.19.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 (231) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +7 -7
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +341 -0
  5. package/src/cli/file-processor.ts +3 -3
  6. package/src/cli/list-models.ts +3 -17
  7. package/src/cli/stats-cli.ts +3 -22
  8. package/src/cli/web-search-cli.ts +8 -16
  9. package/src/commit/agentic/agent.ts +6 -9
  10. package/src/commit/agentic/index.ts +44 -50
  11. package/src/commit/agentic/state.ts +0 -9
  12. package/src/commit/agentic/tools/propose-commit.ts +1 -30
  13. package/src/commit/agentic/tools/schemas.ts +31 -0
  14. package/src/commit/agentic/tools/split-commit.ts +1 -30
  15. package/src/commit/agentic/validation.ts +1 -18
  16. package/src/commit/analysis/conventional.ts +3 -50
  17. package/src/commit/analysis/summary.ts +2 -13
  18. package/src/commit/changelog/detect.ts +4 -1
  19. package/src/commit/changelog/generate.ts +2 -25
  20. package/src/commit/changelog/index.ts +1 -2
  21. package/src/commit/cli.ts +4 -12
  22. package/src/commit/map-reduce/reduce-phase.ts +2 -43
  23. package/src/commit/pipeline.ts +7 -15
  24. package/src/commit/utils.ts +44 -0
  25. package/src/config/prompt-templates.ts +1 -81
  26. package/src/config/settings-schema.ts +20 -1
  27. package/src/config.ts +2 -3
  28. package/src/debug/index.ts +1 -6
  29. package/src/debug/system-info.ts +2 -6
  30. package/src/discovery/builtin.ts +5 -9
  31. package/src/discovery/helpers.ts +0 -26
  32. package/src/discovery/ssh.ts +1 -8
  33. package/src/exa/company.ts +8 -39
  34. package/src/exa/factory.ts +64 -0
  35. package/src/exa/index.ts +0 -16
  36. package/src/exa/linkedin.ts +8 -39
  37. package/src/exa/mcp-client.ts +0 -64
  38. package/src/exa/researcher.ts +17 -59
  39. package/src/exa/search.ts +30 -154
  40. package/src/extensibility/custom-tools/loader.ts +3 -41
  41. package/src/extensibility/extensions/loader.ts +2 -9
  42. package/src/extensibility/hooks/loader.ts +3 -20
  43. package/src/extensibility/hooks/runner.ts +3 -19
  44. package/src/extensibility/plugins/installer.ts +2 -1
  45. package/src/extensibility/plugins/loader.ts +29 -117
  46. package/src/extensibility/skills.ts +2 -89
  47. package/src/extensibility/slash-commands.ts +1 -63
  48. package/src/extensibility/utils.ts +38 -0
  49. package/src/index.ts +9 -25
  50. package/src/internal-urls/index.ts +1 -0
  51. package/src/internal-urls/jobs-protocol.ts +118 -0
  52. package/src/ipy/kernel.ts +2 -0
  53. package/src/lsp/config.ts +1 -5
  54. package/src/lsp/lspmux.ts +0 -17
  55. package/src/lsp/utils.ts +2 -24
  56. package/src/main.ts +16 -24
  57. package/src/mcp/client.ts +1 -46
  58. package/src/mcp/render.ts +8 -1
  59. package/src/mcp/tool-cache.ts +1 -5
  60. package/src/mcp/transports/http.ts +2 -7
  61. package/src/mcp/transports/stdio.ts +2 -7
  62. package/src/modes/components/bash-execution.ts +2 -16
  63. package/src/modes/components/extensions/inspector-panel.ts +8 -18
  64. package/src/modes/components/footer.ts +10 -50
  65. package/src/modes/components/model-selector.ts +2 -21
  66. package/src/modes/components/python-execution.ts +2 -16
  67. package/src/modes/components/settings-selector.ts +1 -10
  68. package/src/modes/components/status-line/segments.ts +8 -25
  69. package/src/modes/components/status-line.ts +14 -31
  70. package/src/modes/components/tool-execution.ts +8 -2
  71. package/src/modes/controllers/command-controller.ts +71 -30
  72. package/src/modes/controllers/event-controller.ts +34 -4
  73. package/src/modes/controllers/mcp-command-controller.ts +3 -34
  74. package/src/modes/controllers/selector-controller.ts +2 -2
  75. package/src/modes/controllers/ssh-command-controller.ts +3 -34
  76. package/src/modes/interactive-mode.ts +6 -2
  77. package/src/modes/rpc/rpc-client.ts +1 -5
  78. package/src/modes/shared.ts +73 -0
  79. package/src/modes/types.ts +1 -0
  80. package/src/modes/utils/ui-helpers.ts +26 -2
  81. package/src/patch/index.ts +4 -4
  82. package/src/patch/normalize.ts +22 -65
  83. package/src/patch/shared.ts +16 -16
  84. package/src/prompts/system/custom-system-prompt.md +0 -10
  85. package/src/prompts/system/system-prompt.md +69 -89
  86. package/src/prompts/tools/async-result.md +5 -0
  87. package/src/prompts/tools/bash.md +5 -0
  88. package/src/prompts/tools/cancel-job.md +7 -0
  89. package/src/prompts/tools/poll-jobs.md +7 -0
  90. package/src/prompts/tools/task.md +4 -0
  91. package/src/sdk.ts +70 -6
  92. package/src/session/agent-session.ts +40 -6
  93. package/src/session/agent-storage.ts +69 -278
  94. package/src/session/auth-storage.ts +14 -1430
  95. package/src/session/session-manager.ts +69 -5
  96. package/src/session/session-storage.ts +1 -5
  97. package/src/session/streaming-output.ts +637 -76
  98. package/src/slash-commands/builtin-registry.ts +8 -0
  99. package/src/ssh/connection-manager.ts +4 -12
  100. package/src/ssh/sshfs-mount.ts +3 -7
  101. package/src/ssh/utils.ts +8 -0
  102. package/src/system-prompt.ts +24 -90
  103. package/src/task/executor.ts +11 -1
  104. package/src/task/index.ts +258 -13
  105. package/src/task/parallel.ts +32 -0
  106. package/src/task/render.ts +15 -7
  107. package/src/task/types.ts +5 -0
  108. package/src/tools/ask.ts +4 -7
  109. package/src/tools/bash-interactive.ts +4 -5
  110. package/src/tools/bash.ts +125 -41
  111. package/src/tools/cancel-job.ts +93 -0
  112. package/src/tools/fetch.ts +7 -27
  113. package/src/tools/find.ts +3 -3
  114. package/src/tools/gemini-image.ts +15 -14
  115. package/src/tools/grep.ts +3 -3
  116. package/src/tools/index.ts +13 -29
  117. package/src/tools/json-tree.ts +12 -1
  118. package/src/tools/jtd-to-json-schema.ts +10 -74
  119. package/src/tools/jtd-to-typescript.ts +10 -72
  120. package/src/tools/jtd-utils.ts +102 -0
  121. package/src/tools/notebook.ts +4 -9
  122. package/src/tools/output-meta.ts +52 -26
  123. package/src/tools/path-utils.ts +13 -7
  124. package/src/tools/poll-jobs.ts +178 -0
  125. package/src/tools/python.ts +32 -35
  126. package/src/tools/read.ts +61 -82
  127. package/src/tools/render-utils.ts +8 -159
  128. package/src/tools/ssh.ts +7 -20
  129. package/src/tools/submit-result.ts +1 -1
  130. package/src/tools/tool-errors.ts +0 -30
  131. package/src/tools/tool-result.ts +1 -2
  132. package/src/tools/write.ts +8 -10
  133. package/src/tui/code-cell.ts +8 -3
  134. package/src/tui/status-line.ts +4 -4
  135. package/src/tui/types.ts +0 -1
  136. package/src/tui/utils.ts +1 -14
  137. package/src/utils/command-args.ts +76 -0
  138. package/src/utils/file-mentions.ts +15 -19
  139. package/src/utils/frontmatter.ts +5 -10
  140. package/src/utils/shell-snapshot.ts +0 -11
  141. package/src/utils/title-generator.ts +0 -12
  142. package/src/web/scrapers/artifacthub.ts +7 -16
  143. package/src/web/scrapers/arxiv.ts +3 -8
  144. package/src/web/scrapers/aur.ts +8 -22
  145. package/src/web/scrapers/biorxiv.ts +5 -14
  146. package/src/web/scrapers/bluesky.ts +13 -36
  147. package/src/web/scrapers/brew.ts +5 -10
  148. package/src/web/scrapers/cheatsh.ts +2 -12
  149. package/src/web/scrapers/chocolatey.ts +63 -26
  150. package/src/web/scrapers/choosealicense.ts +3 -18
  151. package/src/web/scrapers/cisa-kev.ts +4 -18
  152. package/src/web/scrapers/clojars.ts +6 -33
  153. package/src/web/scrapers/coingecko.ts +25 -33
  154. package/src/web/scrapers/crates-io.ts +7 -26
  155. package/src/web/scrapers/crossref.ts +4 -18
  156. package/src/web/scrapers/devto.ts +11 -41
  157. package/src/web/scrapers/discogs.ts +7 -10
  158. package/src/web/scrapers/discourse.ts +6 -31
  159. package/src/web/scrapers/dockerhub.ts +12 -35
  160. package/src/web/scrapers/fdroid.ts +8 -33
  161. package/src/web/scrapers/firefox-addons.ts +10 -34
  162. package/src/web/scrapers/flathub.ts +7 -24
  163. package/src/web/scrapers/github-gist.ts +2 -12
  164. package/src/web/scrapers/github.ts +9 -47
  165. package/src/web/scrapers/gitlab.ts +130 -185
  166. package/src/web/scrapers/go-pkg.ts +12 -22
  167. package/src/web/scrapers/hackage.ts +88 -43
  168. package/src/web/scrapers/hackernews.ts +25 -45
  169. package/src/web/scrapers/hex.ts +19 -36
  170. package/src/web/scrapers/huggingface.ts +26 -91
  171. package/src/web/scrapers/iacr.ts +3 -8
  172. package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
  173. package/src/web/scrapers/lemmy.ts +5 -23
  174. package/src/web/scrapers/lobsters.ts +16 -28
  175. package/src/web/scrapers/mastodon.ts +24 -43
  176. package/src/web/scrapers/maven.ts +6 -21
  177. package/src/web/scrapers/mdn.ts +7 -11
  178. package/src/web/scrapers/metacpan.ts +9 -41
  179. package/src/web/scrapers/musicbrainz.ts +4 -28
  180. package/src/web/scrapers/npm.ts +8 -25
  181. package/src/web/scrapers/nuget.ts +14 -37
  182. package/src/web/scrapers/nvd.ts +6 -28
  183. package/src/web/scrapers/ollama.ts +7 -34
  184. package/src/web/scrapers/open-vsx.ts +5 -19
  185. package/src/web/scrapers/opencorporates.ts +30 -14
  186. package/src/web/scrapers/openlibrary.ts +49 -33
  187. package/src/web/scrapers/orcid.ts +4 -18
  188. package/src/web/scrapers/osv.ts +7 -24
  189. package/src/web/scrapers/packagist.ts +9 -24
  190. package/src/web/scrapers/pub-dev.ts +7 -50
  191. package/src/web/scrapers/pubmed.ts +54 -21
  192. package/src/web/scrapers/pypi.ts +8 -26
  193. package/src/web/scrapers/rawg.ts +11 -19
  194. package/src/web/scrapers/readthedocs.ts +4 -9
  195. package/src/web/scrapers/reddit.ts +5 -15
  196. package/src/web/scrapers/repology.ts +8 -20
  197. package/src/web/scrapers/rfc.ts +5 -14
  198. package/src/web/scrapers/rubygems.ts +6 -21
  199. package/src/web/scrapers/searchcode.ts +8 -36
  200. package/src/web/scrapers/sec-edgar.ts +4 -18
  201. package/src/web/scrapers/semantic-scholar.ts +15 -35
  202. package/src/web/scrapers/snapcraft.ts +5 -19
  203. package/src/web/scrapers/sourcegraph.ts +5 -43
  204. package/src/web/scrapers/spdx.ts +4 -18
  205. package/src/web/scrapers/spotify.ts +4 -23
  206. package/src/web/scrapers/stackoverflow.ts +8 -13
  207. package/src/web/scrapers/terraform.ts +9 -37
  208. package/src/web/scrapers/tldr.ts +3 -7
  209. package/src/web/scrapers/twitter.ts +3 -7
  210. package/src/web/scrapers/types.ts +105 -27
  211. package/src/web/scrapers/utils.ts +97 -103
  212. package/src/web/scrapers/vimeo.ts +7 -27
  213. package/src/web/scrapers/vscode-marketplace.ts +8 -17
  214. package/src/web/scrapers/w3c.ts +6 -14
  215. package/src/web/scrapers/wikidata.ts +5 -19
  216. package/src/web/scrapers/wikipedia.ts +2 -12
  217. package/src/web/scrapers/youtube.ts +5 -34
  218. package/src/web/search/index.ts +0 -9
  219. package/src/web/search/providers/anthropic.ts +3 -2
  220. package/src/web/search/providers/brave.ts +3 -18
  221. package/src/web/search/providers/exa.ts +1 -12
  222. package/src/web/search/providers/kimi.ts +5 -44
  223. package/src/web/search/providers/perplexity.ts +1 -12
  224. package/src/web/search/providers/synthetic.ts +3 -26
  225. package/src/web/search/providers/utils.ts +36 -0
  226. package/src/web/search/providers/zai.ts +9 -50
  227. package/src/web/search/types.ts +0 -28
  228. package/src/web/search/utils.ts +17 -0
  229. package/src/tools/output-utils.ts +0 -63
  230. package/src/tools/truncate.ts +0 -385
  231. package/src/web/search/auth.ts +0 -178
@@ -176,6 +176,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
176
176
  runtime.ctx.editor.setText("");
177
177
  },
178
178
  },
179
+ {
180
+ name: "jobs",
181
+ description: "Show async background jobs status",
182
+ handle: async (_command, runtime) => {
183
+ await runtime.ctx.handleJobsCommand();
184
+ runtime.ctx.editor.setText("");
185
+ },
186
+ },
179
187
  {
180
188
  name: "usage",
181
189
  description: "Show provider usage and limits",
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { isEnoent, logger } from "@oh-my-pi/pi-utils";
4
4
  import { getRemoteHostDir, getSshControlDir } from "@oh-my-pi/pi-utils/dirs";
5
5
  import { $ } from "bun";
6
+ import { buildSshTarget, sanitizeHostName } from "./utils";
6
7
 
7
8
  export interface SSHConnectionTarget {
8
9
  name: string;
@@ -42,11 +43,6 @@ function ensureControlDir() {
42
43
  }
43
44
  }
44
45
 
45
- function sanitizeHostName(name: string): string {
46
- const sanitized = name.replace(/[^a-zA-Z0-9._-]+/g, "_");
47
- return sanitized.length > 0 ? sanitized : "host";
48
- }
49
-
50
46
  function getHostInfoPath(name: string): string {
51
47
  return path.join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`);
52
48
  }
@@ -71,10 +67,6 @@ async function validateKeyPermissions(keyPath?: string): Promise<void> {
71
67
  }
72
68
  }
73
69
 
74
- function buildSshTarget(host: SSHConnectionTarget): string {
75
- return host.username ? `${host.username}@${host.host}` : host.host;
76
- }
77
-
78
70
  function buildCommonArgs(host: SSHConnectionTarget): string[] {
79
71
  const args = [
80
72
  "-o",
@@ -367,7 +359,7 @@ export async function ensureHostInfo(host: SSHConnectionTarget): Promise<SSHHost
367
359
 
368
360
  export async function buildRemoteCommand(host: SSHConnectionTarget, command: string): Promise<string[]> {
369
361
  await validateKeyPermissions(host.keyPath);
370
- return [...buildCommonArgs(host), buildSshTarget(host), command];
362
+ return [...buildCommonArgs(host), buildSshTarget(host.username, host.host), command];
371
363
  }
372
364
 
373
365
  export async function ensureConnection(host: SSHConnectionTarget): Promise<void> {
@@ -383,7 +375,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise<void>
383
375
  ensureControlDir();
384
376
  await validateKeyPermissions(host.keyPath);
385
377
 
386
- const target = buildSshTarget(host);
378
+ const target = buildSshTarget(host.username, host.host);
387
379
  const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
388
380
  if (check.exitCode === 0) {
389
381
  activeHosts.set(key, host);
@@ -414,7 +406,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise<void>
414
406
  }
415
407
 
416
408
  async function closeConnectionInternal(host: SSHConnectionTarget): Promise<void> {
417
- const target = buildSshTarget(host);
409
+ const target = buildSshTarget(host.username, host.host);
418
410
  await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
419
411
  }
420
412
 
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { getRemoteDir } from "@oh-my-pi/pi-utils/dirs";
4
4
  import { $ } from "bun";
5
5
  import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager";
6
+ import { buildSshTarget, sanitizeHostName } from "./utils";
6
7
 
7
8
  const REMOTE_DIR = getRemoteDir();
8
9
  const CONTROL_DIR = getControlDir();
@@ -20,18 +21,13 @@ async function ensureDir(path: string, mode = 0o700): Promise<void> {
20
21
 
21
22
  function getMountName(host: SSHConnectionTarget): string {
22
23
  const raw = (host.name ?? host.host).trim();
23
- const sanitized = raw.replace(/[^a-zA-Z0-9._-]+/g, "_");
24
- return sanitized.length > 0 ? sanitized : "remote";
24
+ return sanitizeHostName(raw);
25
25
  }
26
26
 
27
27
  function getMountPath(host: SSHConnectionTarget): string {
28
28
  return path.join(REMOTE_DIR, getMountName(host));
29
29
  }
30
30
 
31
- function buildSshTarget(host: SSHConnectionTarget): string {
32
- return host.username ? `${host.username}@${host.host}` : host.host;
33
- }
34
-
35
31
  function buildSshfsArgs(host: SSHConnectionTarget): string[] {
36
32
  const args = [
37
33
  "-o",
@@ -98,7 +94,7 @@ export async function mountRemote(host: SSHConnectionTarget, remotePath = "/"):
98
94
  return mountPath;
99
95
  }
100
96
 
101
- const target = `${buildSshTarget(host)}:${remotePath}`;
97
+ const target = `${buildSshTarget(host.username, host.host)}:${remotePath}`;
102
98
  const args = buildSshfsArgs(host);
103
99
  const result = await $`sshfs ${args} ${target} ${mountPath}`.nothrow();
104
100
 
@@ -0,0 +1,8 @@
1
+ export function sanitizeHostName(name: string): string {
2
+ const sanitized = name.replace(/[^a-zA-Z0-9._-]+/g, "_");
3
+ return sanitized.length > 0 ? sanitized : "remote";
4
+ }
5
+
6
+ export function buildSshTarget(username: string | undefined, host: string): string {
7
+ return username ? `${username}@${host}` : host;
8
+ }
@@ -5,7 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import { $env, hasFsCode, isEnoent, logger, untilAborted } from "@oh-my-pi/pi-utils";
8
+ import { $env, hasFsCode, isEnoent, logger } from "@oh-my-pi/pi-utils";
9
9
  import { getGpuCachePath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
10
10
  import { $ } from "bun";
11
11
  import { contextFileCapability } from "./capability/context-file";
@@ -18,14 +18,6 @@ import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md
18
18
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
19
19
  import type { ToolName } from "./tools";
20
20
 
21
- interface GitContext {
22
- isRepo: boolean;
23
- currentBranch: string;
24
- mainBranch: string;
25
- status: string;
26
- commits: string;
27
- }
28
-
29
21
  type PreloadedSkill = { name: string; content: string };
30
22
 
31
23
  async function loadPreloadedSkillContents(preloadedSkills: Skill[]): Promise<PreloadedSkill[]> {
@@ -44,54 +36,6 @@ async function loadPreloadedSkillContents(preloadedSkills: Skill[]): Promise<Pre
44
36
  return contents;
45
37
  }
46
38
 
47
- /**
48
- * Load git context for the system prompt.
49
- * Returns structured git data or null if not in a git repo.
50
- */
51
- export async function loadGitContext(cwd: string): Promise<GitContext | null> {
52
- const timeout = 3000;
53
- const abortSignal = AbortSignal.timeout(timeout);
54
-
55
- const git = async (...args: string[]): Promise<string | null> => {
56
- const proc = Bun.spawn(["git", ...args], {
57
- cwd,
58
- stdout: "pipe",
59
- stderr: "ignore",
60
- timeout: timeout,
61
- killSignal: "SIGKILL",
62
- });
63
- return untilAborted(abortSignal, async () => {
64
- const exitCode = await proc.exited;
65
- const stdout = await proc.stdout.text();
66
- return exitCode === 0 ? stdout.trim() : null;
67
- });
68
- };
69
-
70
- // Check if inside a git repo
71
- const isGitRepo = await git("rev-parse", "--is-inside-work-tree");
72
- if (isGitRepo !== "true") return null;
73
- const currentBranch = await git("rev-parse", "--abbrev-ref", "HEAD");
74
- if (!currentBranch) return null;
75
- let mainBranch = "main";
76
- const mainExists = await git("rev-parse", "--verify", "main");
77
- if (mainExists === null) {
78
- const masterExists = await git("rev-parse", "--verify", "master");
79
- if (masterExists !== null) mainBranch = "master";
80
- }
81
-
82
- const [status, commits] = await Promise.all([
83
- git("status", "--porcelain", "--untracked-files=no"),
84
- git("log", "--oneline", "-5"),
85
- ]);
86
- return {
87
- isRepo: true,
88
- currentBranch,
89
- mainBranch,
90
- status: status === "" ? "(clean)" : (status ?? "(status unavailable)"),
91
- commits: commits && commits.length > 0 ? commits : "(no commits)",
92
- };
93
- }
94
-
95
39
  function firstNonEmpty(...values: (string | undefined | null)[]): string | null {
96
40
  for (const value of values) {
97
41
  const trimmed = value?.trim();
@@ -333,9 +277,7 @@ function getSystemInfoCachePath(): string {
333
277
  async function loadGpuCache(): Promise<GpuCache | null> {
334
278
  try {
335
279
  const cachePath = getSystemInfoCachePath();
336
- const file = Bun.file(cachePath);
337
- if (!(await file.exists())) return null;
338
- const content = await file.json();
280
+ const content = await Bun.file(cachePath).json();
339
281
  return content as GpuCache;
340
282
  } catch {
341
283
  return null;
@@ -501,7 +443,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
501
443
  const resolvedCwd = cwd ?? getProjectDir();
502
444
  const preloadedSkills = providedPreloadedSkills;
503
445
 
504
- const prepPromise = (async () => {
446
+ const prepPromise = (() => {
505
447
  const systemPromptCustomizationPromise = logger.timeAsync("loadSystemPromptFiles", loadSystemPromptFiles, {
506
448
  cwd: resolvedCwd,
507
449
  });
@@ -516,20 +458,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
516
458
  ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
517
459
  : Promise.resolve([]);
518
460
  const preloadedSkillContentsPromise = preloadedSkills
519
- ? await logger.timeAsync("loadPreloadedSkills", loadPreloadedSkillContents, preloadedSkills)
461
+ ? logger.timeAsync("loadPreloadedSkills", loadPreloadedSkillContents, preloadedSkills)
520
462
  : [];
521
- const gitPromise = logger.timeAsync("loadGitContext", loadGitContext, resolvedCwd);
522
463
 
523
- const [
524
- resolvedCustomPrompt,
525
- resolvedAppendPrompt,
526
- systemPromptCustomization,
527
- contextFiles,
528
- agentsMdSearch,
529
- skills,
530
- preloadedSkillContents,
531
- git,
532
- ] = await Promise.all([
464
+ return Promise.all([
533
465
  resolvePromptInput(customPrompt, "system prompt"),
534
466
  resolvePromptInput(appendSystemPrompt, "append system prompt"),
535
467
  systemPromptCustomizationPromise,
@@ -537,19 +469,25 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
537
469
  agentsMdSearchPromise,
538
470
  skillsPromise,
539
471
  preloadedSkillContentsPromise,
540
- gitPromise,
541
- ]);
542
-
543
- return {
544
- resolvedCustomPrompt,
545
- resolvedAppendPrompt,
546
- systemPromptCustomization,
547
- contextFiles,
548
- agentsMdSearch,
549
- skills,
550
- preloadedSkillContents,
551
- git,
552
- };
472
+ ]).then(
473
+ ([
474
+ resolvedCustomPrompt,
475
+ resolvedAppendPrompt,
476
+ systemPromptCustomization,
477
+ contextFiles,
478
+ agentsMdSearch,
479
+ skills,
480
+ preloadedSkillContents,
481
+ ]) => ({
482
+ resolvedCustomPrompt,
483
+ resolvedAppendPrompt,
484
+ systemPromptCustomization,
485
+ contextFiles,
486
+ agentsMdSearch,
487
+ skills,
488
+ preloadedSkillContents,
489
+ }),
490
+ );
553
491
  })();
554
492
 
555
493
  const prepResult = await Promise.race([
@@ -571,7 +509,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
571
509
  };
572
510
  let skills: Skill[] = providedSkills ?? [];
573
511
  let preloadedSkillContents: PreloadedSkill[] = [];
574
- let git: GitContext | null = null;
575
512
 
576
513
  if (prepResult.type === "timeout") {
577
514
  logger.warn("System prompt preparation timed out; using minimal startup context", {
@@ -595,7 +532,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
595
532
  agentsMdSearch = prepResult.value.agentsMdSearch;
596
533
  skills = prepResult.value.skills;
597
534
  preloadedSkillContents = prepResult.value.preloadedSkillContents;
598
- git = prepResult.value.git;
599
535
  }
600
536
 
601
537
  const now = new Date();
@@ -648,7 +584,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
648
584
  appendPrompt: resolvedAppendPrompt ?? "",
649
585
  contextFiles,
650
586
  agentsMdSearch,
651
- git,
652
587
  skills: filteredSkills,
653
588
  preloadedSkills: preloadedSkillContents,
654
589
  rules: rules ?? [],
@@ -667,7 +602,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
667
602
  systemPromptCustomization: systemPromptCustomization ?? "",
668
603
  contextFiles,
669
604
  agentsMdSearch,
670
- git,
671
605
  skills: filteredSkills,
672
606
  preloadedSkills: preloadedSkillContents,
673
607
  rules: rules ?? [],
@@ -13,6 +13,7 @@ import { ModelRegistry } from "../config/model-registry";
13
13
  import { resolveModelOverride } from "../config/model-resolver";
14
14
  import { type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
15
15
  import { Settings } from "../config/settings";
16
+ import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
16
17
  import type { CustomTool } from "../extensibility/custom-tools/types";
17
18
  import type { Skill } from "../extensibility/skills";
18
19
  import { callTool } from "../mcp/client";
@@ -442,6 +443,14 @@ function createMCPProxyTools(mcpManager: MCPManager): CustomTool<TSchema>[] {
442
443
  });
443
444
  }
444
445
 
446
+ function createSubagentSettings(baseSettings: Settings): Settings {
447
+ const snapshot: Partial<Record<SettingPath, unknown>> = {};
448
+ for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
449
+ snapshot[key] = baseSettings.get(key);
450
+ }
451
+ return Settings.isolated({ ...snapshot, "async.enabled": false });
452
+ }
453
+
445
454
  /**
446
455
  * Run a single agent in-process.
447
456
  */
@@ -507,6 +516,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
507
516
  }
508
517
 
509
518
  const settings = options.settings ?? Settings.isolated();
519
+ const subagentSettings = createSubagentSettings(settings);
510
520
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
511
521
  const parentDepth = options.taskDepth ?? 0;
512
522
  const childDepth = parentDepth + 1;
@@ -932,7 +942,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
932
942
  cwd: worktree ?? cwd,
933
943
  authStorage,
934
944
  modelRegistry,
935
- settings,
945
+ settings: subagentSettings,
936
946
  model,
937
947
  thinkingLevel: effectiveThinkingLevel,
938
948
  toolNames,
package/src/task/index.ts CHANGED
@@ -26,13 +26,13 @@ import type { Theme } from "../modes/theme/theme";
26
26
  import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
27
27
  import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
28
28
  import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
29
- import { formatDuration } from "../tools/render-utils";
29
+ import { formatBytes, formatDuration } from "../tools/render-utils";
30
30
  // Import review tools for side effects (registers subagent tool handlers)
31
31
  import "../tools/review";
32
32
  import { discoverAgents, getAgent } from "./discovery";
33
33
  import { runSubprocess } from "./executor";
34
34
  import { AgentOutputManager } from "./output-manager";
35
- import { mapWithConcurrencyLimit } from "./parallel";
35
+ import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
36
36
  import { renderCall, renderResult } from "./render";
37
37
  import { renderTemplate } from "./template";
38
38
  import {
@@ -55,13 +55,6 @@ import {
55
55
  type WorktreeBaseline,
56
56
  } from "./worktree";
57
57
 
58
- /** Format byte count for display */
59
- function formatBytes(bytes: number): string {
60
- if (bytes < 1024) return `${bytes}B`;
61
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
62
- return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
63
- }
64
-
65
58
  function createUsageTotals(): Usage {
66
59
  return {
67
60
  input: 0,
@@ -116,6 +109,7 @@ function renderDescription(
116
109
  agents: AgentDefinition[],
117
110
  maxConcurrency: number,
118
111
  isolationEnabled: boolean,
112
+ asyncEnabled: boolean,
119
113
  disabledAgents: string[],
120
114
  ): string {
121
115
  const filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
@@ -123,6 +117,7 @@ function renderDescription(
123
117
  agents: filteredAgents,
124
118
  MAX_CONCURRENCY: maxConcurrency,
125
119
  isolationEnabled,
120
+ asyncEnabled,
126
121
  });
127
122
  }
128
123
 
@@ -150,7 +145,13 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
150
145
  const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
151
146
  const maxConcurrency = this.session.settings.get("task.maxConcurrency");
152
147
  const isolationEnabled = this.session.settings.get("task.isolation.enabled");
153
- return renderDescription(this.#discoveredAgents, maxConcurrency, isolationEnabled, disabledAgents);
148
+ return renderDescription(
149
+ this.#discoveredAgents,
150
+ maxConcurrency,
151
+ isolationEnabled,
152
+ this.session.settings.get("async.enabled"),
153
+ disabledAgents,
154
+ );
154
155
  }
155
156
  private constructor(
156
157
  private readonly session: ToolSession,
@@ -176,6 +177,245 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
176
177
  params: TaskParams,
177
178
  signal?: AbortSignal,
178
179
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
180
+ ): Promise<AgentToolResult<TaskToolDetails>> {
181
+ const asyncEnabled = this.session.settings.get("async.enabled");
182
+ if (!asyncEnabled) {
183
+ return this.#executeSync(_toolCallId, params, signal, onUpdate);
184
+ }
185
+
186
+ const manager = this.session.asyncJobManager;
187
+ if (!manager) {
188
+ return {
189
+ content: [{ type: "text", text: "Async execution is enabled but no async job manager is available." }],
190
+ details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
191
+ };
192
+ }
193
+
194
+ const taskItems = params.tasks ?? [];
195
+ if (taskItems.length === 0) {
196
+ return this.#executeSync(_toolCallId, params, signal, onUpdate);
197
+ }
198
+
199
+ const outputManager =
200
+ this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
201
+ const uniqueIds = await outputManager.allocateBatch(taskItems.map(t => t.id));
202
+ const fallbackAgentSource =
203
+ this.#discoveredAgents.find(agent => agent.name === params.agent)?.source ?? "bundled";
204
+ const renderedTasks = taskItems.map(taskItem => renderTemplate(params.context, taskItem));
205
+ const progressByTaskId = new Map<string, AgentProgress>();
206
+ for (let index = 0; index < renderedTasks.length; index++) {
207
+ const renderedTask = renderedTasks[index];
208
+ progressByTaskId.set(renderedTask.id, {
209
+ index,
210
+ id: renderedTask.id,
211
+ agent: params.agent,
212
+ agentSource: fallbackAgentSource,
213
+ status: "pending",
214
+ task: renderedTask.task,
215
+ description: renderedTask.description,
216
+ recentTools: [],
217
+ recentOutput: [],
218
+ toolCount: 0,
219
+ tokens: 0,
220
+ durationMs: 0,
221
+ });
222
+ }
223
+
224
+ const startedJobs: Array<{ jobId: string; taskId: string }> = [];
225
+ const failedSchedules: string[] = [];
226
+ let completedJobs = 0;
227
+ let failedJobs = 0;
228
+
229
+ const getProgressSnapshot = (): AgentProgress[] => {
230
+ return Array.from(progressByTaskId.values())
231
+ .sort((a, b) => a.index - b.index)
232
+ .map(progress => structuredClone(progress));
233
+ };
234
+
235
+ const buildAsyncDetails = (state: "running" | "completed" | "failed", jobId: string): TaskToolDetails => ({
236
+ projectAgentsDir: null,
237
+ results: [],
238
+ totalDurationMs: 0,
239
+ progress: getProgressSnapshot(),
240
+ async: { state, jobId, type: "task" },
241
+ });
242
+
243
+ const emitAsyncUpdate = (state: "running" | "completed" | "failed", text: string): void => {
244
+ const primaryJobId = startedJobs[0]?.jobId ?? "task";
245
+ onUpdate?.({
246
+ content: [{ type: "text", text }],
247
+ details: buildAsyncDetails(state, primaryJobId),
248
+ });
249
+ };
250
+
251
+ const maxConcurrency = this.session.settings.get("task.maxConcurrency");
252
+ const semaphore = new Semaphore(maxConcurrency);
253
+
254
+ for (let i = 0; i < taskItems.length; i++) {
255
+ const taskItem = taskItems[i];
256
+ if (signal?.aborted) {
257
+ failedSchedules.push(`${taskItem.id}: cancelled before scheduling`);
258
+ const progress = progressByTaskId.get(taskItem.id);
259
+ if (progress) {
260
+ progress.status = "aborted";
261
+ }
262
+ continue;
263
+ }
264
+
265
+ const uniqueId = uniqueIds[i];
266
+ const singleParams: TaskParams = { ...params, tasks: [taskItem] };
267
+ const label = uniqueId;
268
+ try {
269
+ const jobId = manager.register(
270
+ "task",
271
+ label,
272
+ async ({ signal: runSignal, reportProgress }) => {
273
+ const startedAt = Date.now();
274
+ const progress = progressByTaskId.get(taskItem.id);
275
+ await semaphore.acquire();
276
+ if (runSignal.aborted) {
277
+ semaphore.release();
278
+ if (progress) {
279
+ progress.status = "aborted";
280
+ }
281
+ throw new Error("Aborted before execution");
282
+ }
283
+ if (progress) {
284
+ progress.status = "running";
285
+ }
286
+ await reportProgress(
287
+ `Running background task ${taskItem.id}...`,
288
+ buildAsyncDetails("running", startedJobs[0]?.jobId ?? label) as unknown as Record<string, unknown>,
289
+ );
290
+ try {
291
+ const result = await this.#executeSync(_toolCallId, singleParams, runSignal, undefined, [
292
+ uniqueId,
293
+ ]);
294
+ const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
295
+ const singleResult = result.details?.results[0];
296
+ if (progress) {
297
+ progress.status = singleResult?.aborted
298
+ ? "aborted"
299
+ : (singleResult?.exitCode ?? 0) === 0
300
+ ? "completed"
301
+ : "failed";
302
+ progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
303
+ progress.tokens = singleResult?.tokens ?? 0;
304
+ progress.extractedToolData = singleResult?.extractedToolData;
305
+ }
306
+ completedJobs += 1;
307
+ if (singleResult && ((singleResult.aborted ?? false) || singleResult.exitCode !== 0)) {
308
+ failedJobs += 1;
309
+ }
310
+ const remaining = taskItems.length - completedJobs;
311
+ const isDone = remaining === 0;
312
+ await reportProgress(
313
+ isDone
314
+ ? `Background task batch complete: ${completedJobs}/${taskItems.length} finished.`
315
+ : `Background task batch progress: ${completedJobs}/${taskItems.length} finished (${remaining} running).`,
316
+ buildAsyncDetails(
317
+ isDone ? (failedJobs > 0 || failedSchedules.length > 0 ? "failed" : "completed") : "running",
318
+ startedJobs[0]?.jobId ?? label,
319
+ ) as unknown as Record<string, unknown>,
320
+ );
321
+ if (isDone) {
322
+ emitAsyncUpdate(
323
+ failedJobs > 0 || failedSchedules.length > 0 ? "failed" : "completed",
324
+ `Background task batch complete: ${completedJobs}/${taskItems.length} finished.`,
325
+ );
326
+ }
327
+ return finalText;
328
+ } catch (error) {
329
+ if (progress) {
330
+ progress.status = "failed";
331
+ progress.durationMs = Math.max(0, Date.now() - startedAt);
332
+ }
333
+ completedJobs += 1;
334
+ failedJobs += 1;
335
+ const remaining = taskItems.length - completedJobs;
336
+ const isDone = remaining === 0;
337
+ await reportProgress(
338
+ isDone
339
+ ? `Background task batch complete with failures: ${failedJobs} failed.`
340
+ : `Background task batch progress: ${completedJobs}/${taskItems.length} finished (${remaining} running).`,
341
+ buildAsyncDetails(
342
+ isDone ? "failed" : "running",
343
+ startedJobs[0]?.jobId ?? label,
344
+ ) as unknown as Record<string, unknown>,
345
+ );
346
+ if (isDone) {
347
+ emitAsyncUpdate(
348
+ "failed",
349
+ `Background task batch complete with failures: ${failedJobs} failed.`,
350
+ );
351
+ }
352
+ throw error;
353
+ } finally {
354
+ semaphore.release();
355
+ }
356
+ },
357
+ {
358
+ id: label,
359
+ onProgress: (text, details) => {
360
+ const progressDetails =
361
+ (details as TaskToolDetails | undefined) ??
362
+ buildAsyncDetails("running", startedJobs[0]?.jobId ?? label);
363
+ onUpdate?.({ content: [{ type: "text", text }], details: progressDetails });
364
+ },
365
+ },
366
+ );
367
+ startedJobs.push({ jobId, taskId: taskItem.id });
368
+ } catch (error) {
369
+ const message = error instanceof Error ? error.message : String(error);
370
+ failedSchedules.push(`${taskItem.id}: ${message}`);
371
+ const progress = progressByTaskId.get(taskItem.id);
372
+ if (progress) {
373
+ progress.status = "failed";
374
+ }
375
+ }
376
+ }
377
+
378
+ if (startedJobs.length === 0) {
379
+ const failureText = `Failed to start background task jobs: ${failedSchedules.join("; ")}`;
380
+ return {
381
+ content: [{ type: "text", text: failureText }],
382
+ details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
383
+ };
384
+ }
385
+
386
+ emitAsyncUpdate(
387
+ "running",
388
+ `Launching ${startedJobs.length} background ${startedJobs.length === 1 ? "task" : "tasks"}...`,
389
+ );
390
+
391
+ const scheduleFailureSummary =
392
+ failedSchedules.length > 0
393
+ ? ` Failed to schedule ${failedSchedules.length} task${failedSchedules.length === 1 ? "" : "s"}.`
394
+ : "";
395
+
396
+ return {
397
+ content: [
398
+ {
399
+ type: "text",
400
+ text: `Started ${startedJobs.length} background task job${startedJobs.length === 1 ? "" : "s"} using ${params.agent}.${scheduleFailureSummary} Results will be delivered when complete.`,
401
+ },
402
+ ],
403
+ details: {
404
+ projectAgentsDir: null,
405
+ results: [],
406
+ totalDurationMs: 0,
407
+ progress: getProgressSnapshot(),
408
+ async: { state: "running", jobId: startedJobs[0].jobId, type: "task" },
409
+ },
410
+ };
411
+ }
412
+
413
+ async #executeSync(
414
+ _toolCallId: string,
415
+ params: TaskParams,
416
+ signal?: AbortSignal,
417
+ onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
418
+ preAllocatedIds?: string[],
179
419
  ): Promise<AgentToolResult<TaskToolDetails>> {
180
420
  const startTime = Date.now();
181
421
  const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
@@ -427,9 +667,14 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
427
667
 
428
668
  // Build full prompts with context prepended
429
669
  // Allocate unique IDs across the session to prevent artifact collisions
430
- const outputManager =
431
- this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
432
- const uniqueIds = await outputManager.allocateBatch(tasks.map(t => t.id));
670
+ let uniqueIds: string[];
671
+ if (preAllocatedIds && preAllocatedIds.length === tasks.length) {
672
+ uniqueIds = preAllocatedIds;
673
+ } else {
674
+ const outputManager =
675
+ this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
676
+ uniqueIds = await outputManager.allocateBatch(tasks.map(t => t.id));
677
+ }
433
678
  const tasksWithUniqueIds = tasks.map((t, i) => ({ ...t, id: uniqueIds[i] }));
434
679
 
435
680
  // Build full prompts with context prepended