@oh-my-pi/pi-coding-agent 12.18.3 → 12.19.2

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 (233) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +35 -27
  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/hashline.ts +6 -286
  82. package/src/patch/index.ts +6 -57
  83. package/src/patch/normalize.ts +22 -65
  84. package/src/patch/shared.ts +16 -16
  85. package/src/prompts/system/custom-system-prompt.md +0 -10
  86. package/src/prompts/system/system-prompt.md +69 -89
  87. package/src/prompts/tools/async-result.md +5 -0
  88. package/src/prompts/tools/bash.md +5 -0
  89. package/src/prompts/tools/cancel-job.md +7 -0
  90. package/src/prompts/tools/hashline.md +0 -16
  91. package/src/prompts/tools/poll-jobs.md +7 -0
  92. package/src/prompts/tools/task.md +4 -0
  93. package/src/sdk.ts +70 -6
  94. package/src/session/agent-session.ts +43 -6
  95. package/src/session/agent-storage.ts +69 -278
  96. package/src/session/auth-storage.ts +14 -1430
  97. package/src/session/session-manager.ts +69 -5
  98. package/src/session/session-storage.ts +1 -5
  99. package/src/session/streaming-output.ts +637 -76
  100. package/src/slash-commands/builtin-registry.ts +8 -0
  101. package/src/ssh/connection-manager.ts +4 -12
  102. package/src/ssh/sshfs-mount.ts +3 -7
  103. package/src/ssh/utils.ts +8 -0
  104. package/src/system-prompt.ts +24 -90
  105. package/src/task/executor.ts +11 -1
  106. package/src/task/index.ts +258 -13
  107. package/src/task/parallel.ts +32 -0
  108. package/src/task/render.ts +15 -7
  109. package/src/task/types.ts +5 -0
  110. package/src/tools/ask.ts +4 -7
  111. package/src/tools/bash-interactive.ts +4 -5
  112. package/src/tools/bash.ts +125 -41
  113. package/src/tools/cancel-job.ts +93 -0
  114. package/src/tools/fetch.ts +7 -27
  115. package/src/tools/find.ts +3 -3
  116. package/src/tools/gemini-image.ts +15 -14
  117. package/src/tools/grep.ts +3 -3
  118. package/src/tools/index.ts +13 -29
  119. package/src/tools/json-tree.ts +12 -1
  120. package/src/tools/jtd-to-json-schema.ts +10 -74
  121. package/src/tools/jtd-to-typescript.ts +10 -72
  122. package/src/tools/jtd-utils.ts +102 -0
  123. package/src/tools/notebook.ts +4 -9
  124. package/src/tools/output-meta.ts +52 -26
  125. package/src/tools/path-utils.ts +13 -7
  126. package/src/tools/poll-jobs.ts +178 -0
  127. package/src/tools/python.ts +32 -35
  128. package/src/tools/read.ts +61 -82
  129. package/src/tools/render-utils.ts +8 -159
  130. package/src/tools/ssh.ts +7 -20
  131. package/src/tools/submit-result.ts +1 -1
  132. package/src/tools/tool-errors.ts +0 -30
  133. package/src/tools/tool-result.ts +1 -2
  134. package/src/tools/write.ts +8 -10
  135. package/src/tui/code-cell.ts +8 -3
  136. package/src/tui/status-line.ts +4 -4
  137. package/src/tui/types.ts +0 -1
  138. package/src/tui/utils.ts +1 -14
  139. package/src/utils/command-args.ts +76 -0
  140. package/src/utils/file-mentions.ts +15 -19
  141. package/src/utils/frontmatter.ts +5 -10
  142. package/src/utils/shell-snapshot.ts +0 -11
  143. package/src/utils/title-generator.ts +0 -12
  144. package/src/web/scrapers/artifacthub.ts +7 -16
  145. package/src/web/scrapers/arxiv.ts +3 -8
  146. package/src/web/scrapers/aur.ts +8 -22
  147. package/src/web/scrapers/biorxiv.ts +5 -14
  148. package/src/web/scrapers/bluesky.ts +13 -36
  149. package/src/web/scrapers/brew.ts +5 -10
  150. package/src/web/scrapers/cheatsh.ts +2 -12
  151. package/src/web/scrapers/chocolatey.ts +63 -26
  152. package/src/web/scrapers/choosealicense.ts +3 -18
  153. package/src/web/scrapers/cisa-kev.ts +4 -18
  154. package/src/web/scrapers/clojars.ts +6 -33
  155. package/src/web/scrapers/coingecko.ts +25 -33
  156. package/src/web/scrapers/crates-io.ts +7 -26
  157. package/src/web/scrapers/crossref.ts +4 -18
  158. package/src/web/scrapers/devto.ts +11 -41
  159. package/src/web/scrapers/discogs.ts +7 -10
  160. package/src/web/scrapers/discourse.ts +6 -31
  161. package/src/web/scrapers/dockerhub.ts +12 -35
  162. package/src/web/scrapers/fdroid.ts +8 -33
  163. package/src/web/scrapers/firefox-addons.ts +10 -34
  164. package/src/web/scrapers/flathub.ts +7 -24
  165. package/src/web/scrapers/github-gist.ts +2 -12
  166. package/src/web/scrapers/github.ts +9 -47
  167. package/src/web/scrapers/gitlab.ts +130 -185
  168. package/src/web/scrapers/go-pkg.ts +12 -22
  169. package/src/web/scrapers/hackage.ts +88 -43
  170. package/src/web/scrapers/hackernews.ts +25 -45
  171. package/src/web/scrapers/hex.ts +19 -36
  172. package/src/web/scrapers/huggingface.ts +26 -91
  173. package/src/web/scrapers/iacr.ts +3 -8
  174. package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
  175. package/src/web/scrapers/lemmy.ts +5 -23
  176. package/src/web/scrapers/lobsters.ts +16 -28
  177. package/src/web/scrapers/mastodon.ts +24 -43
  178. package/src/web/scrapers/maven.ts +6 -21
  179. package/src/web/scrapers/mdn.ts +7 -11
  180. package/src/web/scrapers/metacpan.ts +9 -41
  181. package/src/web/scrapers/musicbrainz.ts +4 -28
  182. package/src/web/scrapers/npm.ts +8 -25
  183. package/src/web/scrapers/nuget.ts +14 -37
  184. package/src/web/scrapers/nvd.ts +6 -28
  185. package/src/web/scrapers/ollama.ts +7 -34
  186. package/src/web/scrapers/open-vsx.ts +5 -19
  187. package/src/web/scrapers/opencorporates.ts +30 -14
  188. package/src/web/scrapers/openlibrary.ts +49 -33
  189. package/src/web/scrapers/orcid.ts +4 -18
  190. package/src/web/scrapers/osv.ts +7 -24
  191. package/src/web/scrapers/packagist.ts +9 -24
  192. package/src/web/scrapers/pub-dev.ts +7 -50
  193. package/src/web/scrapers/pubmed.ts +54 -21
  194. package/src/web/scrapers/pypi.ts +8 -26
  195. package/src/web/scrapers/rawg.ts +11 -19
  196. package/src/web/scrapers/readthedocs.ts +4 -9
  197. package/src/web/scrapers/reddit.ts +5 -15
  198. package/src/web/scrapers/repology.ts +8 -20
  199. package/src/web/scrapers/rfc.ts +5 -14
  200. package/src/web/scrapers/rubygems.ts +6 -21
  201. package/src/web/scrapers/searchcode.ts +8 -36
  202. package/src/web/scrapers/sec-edgar.ts +4 -18
  203. package/src/web/scrapers/semantic-scholar.ts +15 -35
  204. package/src/web/scrapers/snapcraft.ts +5 -19
  205. package/src/web/scrapers/sourcegraph.ts +5 -43
  206. package/src/web/scrapers/spdx.ts +4 -18
  207. package/src/web/scrapers/spotify.ts +4 -23
  208. package/src/web/scrapers/stackoverflow.ts +8 -13
  209. package/src/web/scrapers/terraform.ts +9 -37
  210. package/src/web/scrapers/tldr.ts +3 -7
  211. package/src/web/scrapers/twitter.ts +3 -7
  212. package/src/web/scrapers/types.ts +105 -27
  213. package/src/web/scrapers/utils.ts +97 -103
  214. package/src/web/scrapers/vimeo.ts +7 -27
  215. package/src/web/scrapers/vscode-marketplace.ts +8 -17
  216. package/src/web/scrapers/w3c.ts +6 -14
  217. package/src/web/scrapers/wikidata.ts +5 -19
  218. package/src/web/scrapers/wikipedia.ts +2 -12
  219. package/src/web/scrapers/youtube.ts +5 -34
  220. package/src/web/search/index.ts +0 -9
  221. package/src/web/search/providers/anthropic.ts +3 -2
  222. package/src/web/search/providers/brave.ts +3 -18
  223. package/src/web/search/providers/exa.ts +1 -12
  224. package/src/web/search/providers/kimi.ts +5 -44
  225. package/src/web/search/providers/perplexity.ts +1 -12
  226. package/src/web/search/providers/synthetic.ts +3 -26
  227. package/src/web/search/providers/utils.ts +36 -0
  228. package/src/web/search/providers/zai.ts +9 -50
  229. package/src/web/search/types.ts +0 -28
  230. package/src/web/search/utils.ts +17 -0
  231. package/src/tools/output-utils.ts +0 -63
  232. package/src/tools/truncate.ts +0 -385
  233. package/src/web/search/auth.ts +0 -178
package/CHANGELOG.md CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.19.1] - 2026-02-22
6
+ ### Removed
7
+
8
+ - Removed `replaceText` edit operation from hashline mode (substring-based text replacement)
9
+ - Removed autocorrect heuristics that attempted to detect and fix line merges and formatting rewrites in hashline edits
10
+
11
+ ## [12.19.0] - 2026-02-22
12
+ ### Added
13
+
14
+ - Added `poll_jobs` tool to block until background jobs complete, providing an alternative to polling `read jobs://` in loops
15
+ - Added `task.maxConcurrency` setting to limit the number of concurrently executing subagent tasks
16
+ - Added support for rendering markdown output from Python cells with proper formatting and theme styling
17
+ - Added async background job execution for bash commands and tasks with `async: true` parameter
18
+ - Added `cancel_job` tool to cancel running background jobs
19
+ - Added `jobs://` internal protocol to inspect background job status and results
20
+ - Added `/jobs` slash command to display running and recent background jobs in interactive mode
21
+ - Added `async.enabled` and `async.maxJobs` settings to control background job execution
22
+ - Added background job status indicator in status line showing count of running jobs
23
+ - Added support for GitLab Duo authentication provider
24
+ - Added clearer truncation notices across tools with consistent line/size context and continuation hints
25
+
26
+ ### Changed
27
+
28
+ - Updated bash and task tool guidance to recommend `poll_jobs` instead of polling `read jobs://` in loops when waiting for async results
29
+ - Improved parallel task execution to schedule multiple background jobs independently instead of batching all tasks into a single job, enabling true concurrent execution
30
+ - Enhanced task progress tracking to report per-task status (pending, running, completed, failed, aborted) with individual timing and token metrics for each background task
31
+ - Updated background task messaging to provide real-time progress counts (e.g., '2/5 finished') and distinguish between single and multiple task jobs
32
+ - Hid internal `agent__intent` parameter from tool argument displays in UI and logs to reduce visual clutter
33
+ - Updated Python tool to detect and handle markdown display output separately from plain text
34
+ - Updated bash tool to support async execution mode with streaming progress updates
35
+ - Updated task tool to support async execution mode for parallel subagent execution
36
+ - Modified subagent settings to disable async execution in child agents to prevent nesting
37
+ - Updated tool execution component to handle background async task state without spinner animation
38
+ - Changed event controller to keep background tool calls pending until async completion
39
+ - Updated status line width calculation to accommodate background job indicator
40
+ - Updated the system prompt pipeline to reduce injected environment noise and make instructions more focused on execution quality
41
+ - Updated system prompt/workflow guidance to emphasize root-cause fixes, code quality, and explicit handoff/testing expectations
42
+ - Changed default value of `todo.reminders` setting from false to true to enable todo reminders by default
43
+ - Improved truncation/output handling for large command results to reduce memory pressure and keep previews responsive
44
+ - Updated internal artifact handling so tool output artifacts stay consistent across session switches and resumes
45
+
46
+ ### Removed
47
+
48
+ - Removed git context (branch, status, commit history) from system prompt — version control information is no longer injected into agent instructions
49
+
50
+ ### Fixed
51
+
52
+ - Fixed task progress display to hide tool count and token metrics when zero, reducing visual clutter in status output
53
+ - Fixed Lobsters scraper to correctly parse API responses where user fields are strings instead of objects, resolving undefined user display in story listings
54
+ - Fixed artifact manager caching to properly invalidate when session file changes, preventing stale artifact references
55
+ - Fixed truncation behavior around UTF-8 boundaries and chunked output accounting
56
+ - Fixed `submit_result` schema generation to use valid JSON Schema when no explicit output schema is provided
57
+
5
58
  ## [12.18.1] - 2026-02-21
6
59
  ### Added
7
60
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "12.18.3",
4
+ "version": "12.19.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
- "author": "Can Bölük",
7
+ "author": "Can Boluk",
8
8
  "contributors": [
9
9
  "Mario Zechner"
10
10
  ],
@@ -40,33 +40,33 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@mozilla/readability": "0.6.0",
44
- "@oh-my-pi/omp-stats": "12.18.3",
45
- "@oh-my-pi/pi-agent-core": "12.18.3",
46
- "@oh-my-pi/pi-ai": "12.18.3",
47
- "@oh-my-pi/pi-natives": "12.18.3",
48
- "@oh-my-pi/pi-tui": "12.18.3",
49
- "@oh-my-pi/pi-utils": "12.18.3",
50
- "@sinclair/typebox": "^0.34.48",
51
- "@xterm/headless": "^6.0.0",
52
- "ajv": "^8.18.0",
53
- "chalk": "^5.6.2",
54
- "diff": "^8.0.3",
55
- "file-type": "^21.3.0",
56
- "glob": "^13.0.3",
57
- "handlebars": "^4.7.8",
58
- "ignore": "^7.0.5",
59
- "linkedom": "^0.18.12",
60
- "marked": "^17.0.2",
61
- "node-html-parser": "^7.0.2",
62
- "puppeteer": "^24.37.3",
63
- "smol-toml": "^1.6.0",
64
- "zod": "^4.3.6"
43
+ "@mozilla/readability": "^0.6",
44
+ "@oh-my-pi/omp-stats": "12.19.2",
45
+ "@oh-my-pi/pi-agent-core": "12.19.2",
46
+ "@oh-my-pi/pi-ai": "12.19.2",
47
+ "@oh-my-pi/pi-natives": "12.19.2",
48
+ "@oh-my-pi/pi-tui": "12.19.2",
49
+ "@oh-my-pi/pi-utils": "12.19.2",
50
+ "@sinclair/typebox": "^0.34",
51
+ "@xterm/headless": "^6.0",
52
+ "ajv": "^8.18",
53
+ "chalk": "^5.6",
54
+ "diff": "^8.0",
55
+ "file-type": "^21.3",
56
+ "glob": "^13.0",
57
+ "handlebars": "^4.7",
58
+ "ignore": "^7.0",
59
+ "linkedom": "^0.18",
60
+ "marked": "^17.0",
61
+ "node-html-parser": "^7.0",
62
+ "puppeteer": "^24.37",
63
+ "smol-toml": "^1.6",
64
+ "zod": "^4.3"
65
65
  },
66
66
  "devDependencies": {
67
- "@types/bun": "^1.3.9",
68
- "@types/ms": "^2.1.0",
69
- "ms": "^2.1.3"
67
+ "@types/bun": "^1.3",
68
+ "@types/ms": "^2.1",
69
+ "ms": "^2.1"
70
70
  },
71
71
  "engines": {
72
72
  "bun": ">=1.3.7"
@@ -87,6 +87,14 @@
87
87
  "types": "./src/*.ts",
88
88
  "import": "./src/*.ts"
89
89
  },
90
+ "./async": {
91
+ "types": "./src/async/index.ts",
92
+ "import": "./src/async/index.ts"
93
+ },
94
+ "./async/*": {
95
+ "types": "./src/async/*.ts",
96
+ "import": "./src/async/*.ts"
97
+ },
90
98
  "./capability": {
91
99
  "types": "./src/capability/index.ts",
92
100
  "import": "./src/capability/index.ts"
@@ -0,0 +1 @@
1
+ export * from "./job-manager";
@@ -0,0 +1,341 @@
1
+ import { logger, Snowflake } from "@oh-my-pi/pi-utils";
2
+
3
+ const DELIVERY_RETRY_BASE_MS = 500;
4
+ const DELIVERY_RETRY_MAX_MS = 30_000;
5
+ const DELIVERY_RETRY_JITTER_MS = 200;
6
+ const DEFAULT_RETENTION_MS = 5 * 60 * 1000;
7
+ const DEFAULT_MAX_RUNNING_JOBS = 15;
8
+
9
+ export interface AsyncJob {
10
+ id: string;
11
+ type: "bash" | "task";
12
+ status: "running" | "completed" | "failed" | "cancelled";
13
+ startTime: number;
14
+ label: string;
15
+ abortController: AbortController;
16
+ promise: Promise<void>;
17
+ resultText?: string;
18
+ errorText?: string;
19
+ }
20
+
21
+ export interface AsyncJobManagerOptions {
22
+ onJobComplete: (jobId: string, text: string, job?: AsyncJob) => void | Promise<void>;
23
+ maxRunningJobs?: number;
24
+ retentionMs?: number;
25
+ }
26
+
27
+ interface AsyncJobDelivery {
28
+ jobId: string;
29
+ text: string;
30
+ attempt: number;
31
+ nextAttemptAt: number;
32
+ lastError?: string;
33
+ }
34
+
35
+ export interface AsyncJobDeliveryState {
36
+ queued: number;
37
+ delivering: boolean;
38
+ nextRetryAt?: number;
39
+ pendingJobIds: string[];
40
+ }
41
+
42
+ export interface AsyncJobRegisterOptions {
43
+ id?: string;
44
+ onProgress?: (text: string, details?: Record<string, unknown>) => void | Promise<void>;
45
+ }
46
+
47
+ export class AsyncJobManager {
48
+ readonly #jobs = new Map<string, AsyncJob>();
49
+ readonly #deliveries: AsyncJobDelivery[] = [];
50
+ readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
51
+ readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
52
+ readonly #maxRunningJobs: number;
53
+ readonly #retentionMs: number;
54
+ #deliveryLoop: Promise<void> | undefined;
55
+ #disposed = false;
56
+
57
+ constructor(options: AsyncJobManagerOptions) {
58
+ this.#onJobComplete = options.onJobComplete;
59
+ this.#maxRunningJobs = Math.max(1, Math.floor(options.maxRunningJobs ?? DEFAULT_MAX_RUNNING_JOBS));
60
+ this.#retentionMs = Math.max(0, Math.floor(options.retentionMs ?? DEFAULT_RETENTION_MS));
61
+ }
62
+
63
+ register(
64
+ type: "bash" | "task",
65
+ label: string,
66
+ run: (ctx: {
67
+ jobId: string;
68
+ signal: AbortSignal;
69
+ reportProgress: (text: string, details?: Record<string, unknown>) => Promise<void>;
70
+ }) => Promise<string>,
71
+ options?: AsyncJobRegisterOptions,
72
+ ): string {
73
+ if (this.#disposed) {
74
+ throw new Error("Async job manager is disposed");
75
+ }
76
+ const runningCount = this.getRunningJobs().length;
77
+ if (runningCount >= this.#maxRunningJobs) {
78
+ throw new Error(
79
+ `Background job limit reached (${this.#maxRunningJobs}). Wait for running jobs to finish or cancel one.`,
80
+ );
81
+ }
82
+
83
+ const id = this.#resolveJobId(options?.id);
84
+ const abortController = new AbortController();
85
+ const startTime = Date.now();
86
+
87
+ const job: AsyncJob = {
88
+ id,
89
+ type,
90
+ status: "running",
91
+ startTime,
92
+ label,
93
+ abortController,
94
+ promise: Promise.resolve(),
95
+ };
96
+
97
+ const reportProgress = async (text: string, details?: Record<string, unknown>): Promise<void> => {
98
+ if (!options?.onProgress) return;
99
+ try {
100
+ await options.onProgress(text, details);
101
+ } catch (error) {
102
+ logger.warn("Async job progress callback failed", {
103
+ jobId: id,
104
+ error: error instanceof Error ? error.message : String(error),
105
+ });
106
+ }
107
+ };
108
+ job.promise = (async () => {
109
+ try {
110
+ const text = await run({ jobId: id, signal: abortController.signal, reportProgress });
111
+ if (job.status === "cancelled") {
112
+ job.resultText = text;
113
+ this.#scheduleEviction(id);
114
+ return;
115
+ }
116
+ job.status = "completed";
117
+ job.resultText = text;
118
+ this.#enqueueDelivery(id, text);
119
+ this.#scheduleEviction(id);
120
+ } catch (error) {
121
+ if (job.status === "cancelled") {
122
+ job.errorText = error instanceof Error ? error.message : String(error);
123
+ this.#scheduleEviction(id);
124
+ return;
125
+ }
126
+ const errorText = error instanceof Error ? error.message : String(error);
127
+ job.status = "failed";
128
+ job.errorText = errorText;
129
+ this.#enqueueDelivery(id, errorText);
130
+ this.#scheduleEviction(id);
131
+ }
132
+ })();
133
+
134
+ this.#jobs.set(id, job);
135
+ return id;
136
+ }
137
+
138
+ cancel(id: string): boolean {
139
+ const job = this.#jobs.get(id);
140
+ if (!job) return false;
141
+ if (job.status !== "running") return false;
142
+ job.status = "cancelled";
143
+ job.abortController.abort();
144
+ this.#scheduleEviction(id);
145
+ return true;
146
+ }
147
+
148
+ getJob(id: string): AsyncJob | undefined {
149
+ return this.#jobs.get(id);
150
+ }
151
+
152
+ getRunningJobs(): AsyncJob[] {
153
+ return Array.from(this.#jobs.values()).filter(job => job.status === "running");
154
+ }
155
+
156
+ getRecentJobs(limit = 10): AsyncJob[] {
157
+ return Array.from(this.#jobs.values())
158
+ .filter(job => job.status !== "running")
159
+ .sort((a, b) => b.startTime - a.startTime)
160
+ .slice(0, limit);
161
+ }
162
+
163
+ getAllJobs(): AsyncJob[] {
164
+ return Array.from(this.#jobs.values());
165
+ }
166
+
167
+ getDeliveryState(): AsyncJobDeliveryState {
168
+ const nextRetryAt = this.#deliveries.reduce<number | undefined>((next, delivery) => {
169
+ if (next === undefined) return delivery.nextAttemptAt;
170
+ return Math.min(next, delivery.nextAttemptAt);
171
+ }, undefined);
172
+
173
+ return {
174
+ queued: this.#deliveries.length,
175
+ delivering: this.#deliveryLoop !== undefined,
176
+ nextRetryAt,
177
+ pendingJobIds: this.#deliveries.map(delivery => delivery.jobId),
178
+ };
179
+ }
180
+
181
+ hasPendingDeliveries(): boolean {
182
+ return this.#deliveries.length > 0;
183
+ }
184
+
185
+ cancelAll(): void {
186
+ for (const job of this.getRunningJobs()) {
187
+ job.status = "cancelled";
188
+ job.abortController.abort();
189
+ this.#scheduleEviction(job.id);
190
+ }
191
+ }
192
+
193
+ async waitForAll(): Promise<void> {
194
+ await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
195
+ }
196
+
197
+ async drainDeliveries(options?: { timeoutMs?: number }): Promise<boolean> {
198
+ const timeoutMs = options?.timeoutMs;
199
+ const hasDeadline = timeoutMs !== undefined;
200
+ const deadline = hasDeadline ? Date.now() + Math.max(timeoutMs, 0) : Number.POSITIVE_INFINITY;
201
+
202
+ while (this.hasPendingDeliveries()) {
203
+ this.#ensureDeliveryLoop();
204
+ const loop = this.#deliveryLoop;
205
+ if (!loop) {
206
+ continue;
207
+ }
208
+
209
+ if (!hasDeadline) {
210
+ await loop;
211
+ continue;
212
+ }
213
+
214
+ const remainingMs = deadline - Date.now();
215
+ if (remainingMs <= 0) {
216
+ return false;
217
+ }
218
+
219
+ await Promise.race([loop, Bun.sleep(remainingMs)]);
220
+ if (Date.now() >= deadline && this.hasPendingDeliveries()) {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ return true;
226
+ }
227
+
228
+ async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
229
+ this.#disposed = true;
230
+ this.#clearEvictionTimers();
231
+ this.cancelAll();
232
+ await this.waitForAll();
233
+ const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
234
+ this.#clearEvictionTimers();
235
+ this.#jobs.clear();
236
+ this.#deliveries.length = 0;
237
+ return drained;
238
+ }
239
+
240
+ #resolveJobId(preferredId?: string): string {
241
+ if (!preferredId || preferredId.trim().length === 0) {
242
+ return `bg_${Snowflake.next()}`;
243
+ }
244
+
245
+ const base = preferredId.trim();
246
+ if (!this.#jobs.has(base)) return base;
247
+
248
+ let suffix = 2;
249
+ let candidate = `${base}-${suffix}`;
250
+ while (this.#jobs.has(candidate)) {
251
+ suffix += 1;
252
+ candidate = `${base}-${suffix}`;
253
+ }
254
+ return candidate;
255
+ }
256
+
257
+ #scheduleEviction(jobId: string): void {
258
+ if (this.#retentionMs <= 0) {
259
+ this.#jobs.delete(jobId);
260
+ return;
261
+ }
262
+ const existing = this.#evictionTimers.get(jobId);
263
+ if (existing) {
264
+ clearTimeout(existing);
265
+ }
266
+ const timer = setTimeout(() => {
267
+ this.#evictionTimers.delete(jobId);
268
+ this.#jobs.delete(jobId);
269
+ }, this.#retentionMs);
270
+ timer.unref();
271
+ this.#evictionTimers.set(jobId, timer);
272
+ }
273
+
274
+ #clearEvictionTimers(): void {
275
+ for (const timer of this.#evictionTimers.values()) {
276
+ clearTimeout(timer);
277
+ }
278
+ this.#evictionTimers.clear();
279
+ }
280
+
281
+ #enqueueDelivery(jobId: string, text: string): void {
282
+ this.#deliveries.push({
283
+ jobId,
284
+ text,
285
+ attempt: 0,
286
+ nextAttemptAt: Date.now(),
287
+ });
288
+ this.#ensureDeliveryLoop();
289
+ }
290
+
291
+ #ensureDeliveryLoop(): void {
292
+ if (this.#deliveryLoop) {
293
+ return;
294
+ }
295
+
296
+ this.#deliveryLoop = this.#runDeliveryLoop()
297
+ .catch(error => {
298
+ logger.error("Async job delivery loop crashed", { error: String(error) });
299
+ })
300
+ .finally(() => {
301
+ this.#deliveryLoop = undefined;
302
+ if (this.#deliveries.length > 0) {
303
+ this.#ensureDeliveryLoop();
304
+ }
305
+ });
306
+ }
307
+
308
+ async #runDeliveryLoop(): Promise<void> {
309
+ while (this.#deliveries.length > 0) {
310
+ const delivery = this.#deliveries[0];
311
+ const waitMs = delivery.nextAttemptAt - Date.now();
312
+ if (waitMs > 0) {
313
+ await Bun.sleep(waitMs);
314
+ }
315
+
316
+ try {
317
+ await this.#onJobComplete(delivery.jobId, delivery.text, this.#jobs.get(delivery.jobId));
318
+ this.#deliveries.shift();
319
+ } catch (error) {
320
+ delivery.attempt += 1;
321
+ delivery.lastError = error instanceof Error ? error.message : String(error);
322
+ delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
323
+ this.#deliveries.shift();
324
+ this.#deliveries.push(delivery);
325
+ logger.warn("Async job completion delivery failed", {
326
+ jobId: delivery.jobId,
327
+ attempt: delivery.attempt,
328
+ nextRetryAt: delivery.nextAttemptAt,
329
+ error: delivery.lastError,
330
+ });
331
+ }
332
+ }
333
+ }
334
+
335
+ #getRetryDelay(attempt: number): number {
336
+ const exp = Math.min(Math.max(attempt - 1, 0), 8);
337
+ const backoffMs = DELIVERY_RETRY_BASE_MS * 2 ** exp;
338
+ const jitterMs = Math.floor(Math.random() * DELIVERY_RETRY_JITTER_MS);
339
+ return Math.min(DELIVERY_RETRY_MAX_MS, backoffMs + jitterMs);
340
+ }
341
+ }
@@ -8,7 +8,7 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
8
8
  import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
9
9
  import chalk from "chalk";
10
10
  import { resolveReadPath } from "../tools/path-utils";
11
- import { formatSize } from "../tools/truncate";
11
+ import { formatBytes } from "../tools/render-utils";
12
12
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
13
13
  import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
14
14
 
@@ -47,9 +47,9 @@ export async function processFileArguments(fileArgs: string[], options?: Process
47
47
  const maxBytes = mimeType ? MAX_CLI_IMAGE_BYTES : MAX_CLI_TEXT_BYTES;
48
48
  if (stat.size > maxBytes) {
49
49
  console.error(
50
- chalk.yellow(`Warning: Skipping file contents (too large: ${formatSize(stat.size)}): ${absolutePath}`),
50
+ chalk.yellow(`Warning: Skipping file contents (too large: ${formatBytes(stat.size)}): ${absolutePath}`),
51
51
  );
52
- text += `<file name="${absolutePath}">(skipped: too large, ${formatSize(stat.size)})</file>\n`;
52
+ text += `<file name="${absolutePath}">(skipped: too large, ${formatBytes(stat.size)})</file>\n`;
53
53
  continue;
54
54
  }
55
55
 
@@ -2,24 +2,10 @@
2
2
  * List available models with optional fuzzy search
3
3
  */
4
4
  import type { Api, Model } from "@oh-my-pi/pi-ai";
5
+ import { formatNumber } from "@oh-my-pi/pi-utils";
5
6
  import type { ModelRegistry } from "../config/model-registry";
6
7
  import { fuzzyFilter } from "../utils/fuzzy";
7
8
 
8
- /**
9
- * Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")
10
- */
11
- function formatTokenCount(count: number): string {
12
- if (count >= 1_000_000) {
13
- const millions = count / 1_000_000;
14
- return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
15
- }
16
- if (count >= 1_000) {
17
- const thousands = count / 1_000;
18
- return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
19
- }
20
- return count.toString();
21
- }
22
-
23
9
  /**
24
10
  * List available models, optionally filtered by search pattern
25
11
  */
@@ -53,8 +39,8 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s
53
39
  const rows = filteredModels.map(m => ({
54
40
  provider: m.provider,
55
41
  model: m.id,
56
- context: formatTokenCount(m.contextWindow),
57
- maxOut: formatTokenCount(m.maxTokens),
42
+ context: formatNumber(m.contextWindow),
43
+ maxOut: formatNumber(m.maxTokens),
58
44
  thinking: m.reasoning ? "yes" : "no",
59
45
  images: m.input.includes("image") ? "yes" : "no",
60
46
  }));
@@ -4,6 +4,7 @@
4
4
  * Handles `omp stats` subcommand for viewing AI usage statistics.
5
5
  */
6
6
 
7
+ import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
7
8
  import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
8
9
  import chalk from "chalk";
9
10
  import { openPath } from "../utils/open";
@@ -53,32 +54,12 @@ export function parseStatsArgs(args: string[]): StatsCommandArgs | undefined {
53
54
  return result;
54
55
  }
55
56
 
56
- // =============================================================================
57
- // Formatting Helpers
58
- // =============================================================================
59
-
60
- function formatNumber(n: number): string {
61
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
62
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
63
- return n.toFixed(0);
64
- }
65
-
66
57
  function formatCost(n: number): string {
67
58
  if (n < 0.01) return `$${n.toFixed(4)}`;
68
59
  if (n < 1) return `$${n.toFixed(3)}`;
69
60
  return `$${n.toFixed(2)}`;
70
61
  }
71
62
 
72
- function formatDuration(ms: number | null): string {
73
- if (ms === null) return "-";
74
- if (ms < 1000) return `${ms.toFixed(0)}ms`;
75
- return `${(ms / 1000).toFixed(1)}s`;
76
- }
77
-
78
- function formatPercent(n: number): string {
79
- return `${(n * 100).toFixed(1)}%`;
80
- }
81
-
82
63
  // =============================================================================
83
64
  // Command Handler
84
65
  // =============================================================================
@@ -140,8 +121,8 @@ async function printStatsSummary(): Promise<void> {
140
121
  console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
141
122
  console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
142
123
  console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
143
- console.log(` Avg Duration: ${formatDuration(overall.avgDuration)}`);
144
- console.log(` Avg TTFT: ${formatDuration(overall.avgTtft)}`);
124
+ console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
125
+ console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`);
145
126
  if (overall.avgTokensPerSecond !== null) {
146
127
  console.log(` Avg Tokens/s: ${overall.avgTokensPerSecond.toFixed(1)}`);
147
128
  }
@@ -64,24 +64,24 @@ export function parseSearchArgs(args: string[]): SearchCommandArgs | undefined {
64
64
 
65
65
  export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
66
66
  if (!cmd.query) {
67
- writeStderr(chalk.red("Error: Query is required"));
67
+ process.stderr.write(`${chalk.red("Error: Query is required")}\n`);
68
68
  process.exit(1);
69
69
  }
70
70
 
71
71
  if (cmd.provider && !PROVIDERS.includes(cmd.provider)) {
72
- writeStderr(chalk.red(`Error: Unknown provider "${cmd.provider}"`));
73
- writeStderr(chalk.dim(`Valid providers: ${PROVIDERS.join(", ")}`));
72
+ process.stderr.write(`${chalk.red(`Error: Unknown provider "${cmd.provider}"`)}\n`);
73
+ process.stderr.write(`${chalk.dim(`Valid providers: ${PROVIDERS.join(", ")}`)}\n`);
74
74
  process.exit(1);
75
75
  }
76
76
 
77
77
  if (cmd.recency && !RECENCY_OPTIONS.includes(cmd.recency)) {
78
- writeStderr(chalk.red(`Error: Invalid recency "${cmd.recency}"`));
79
- writeStderr(chalk.dim(`Valid recency values: ${RECENCY_OPTIONS.join(", ")}`));
78
+ process.stderr.write(`${chalk.red(`Error: Invalid recency "${cmd.recency}"`)}\n`);
79
+ process.stderr.write(`${chalk.dim(`Valid recency values: ${RECENCY_OPTIONS.join(", ")}`)}\n`);
80
80
  process.exit(1);
81
81
  }
82
82
 
83
83
  if (cmd.limit !== undefined && Number.isNaN(cmd.limit)) {
84
- writeStderr(chalk.red("Error: --limit must be a number"));
84
+ process.stderr.write(`${chalk.red("Error: --limit must be a number")}\n`);
85
85
  process.exit(1);
86
86
  }
87
87
 
@@ -104,7 +104,7 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
104
104
  });
105
105
 
106
106
  const width = Math.max(60, process.stdout.columns ?? 100);
107
- writeStdout(component.render(width).join("\n"));
107
+ process.stdout.write(`${component.render(width).join("\n")}\n`);
108
108
 
109
109
  if (result.details?.error) {
110
110
  process.exitCode = 1;
@@ -112,7 +112,7 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
112
112
  }
113
113
 
114
114
  export function printSearchHelp(): void {
115
- writeStdout(`${chalk.bold(`${APP_NAME} q`)} - Test web search providers
115
+ process.stdout.write(`${chalk.bold(`${APP_NAME} q`)} - Test web search providers
116
116
 
117
117
  ${chalk.bold("Usage:")}
118
118
  ${APP_NAME} q [options] <query>
@@ -133,11 +133,3 @@ ${chalk.bold("Examples:")}
133
133
  ${APP_NAME} q --provider=brave --recency=week "latest TypeScript 5.7 changes"
134
134
  `);
135
135
  }
136
-
137
- function writeStdout(message: string): void {
138
- process.stdout.write(`${message}\n`);
139
- }
140
-
141
- function writeStderr(message: string): void {
142
- process.stderr.write(`${message}\n`);
143
- }