@oh-my-pi/pi-coding-agent 12.18.1 → 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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.19.0] - 2026-02-22
6
+ ### Added
7
+
8
+ - Added `poll_jobs` tool to block until background jobs complete, providing an alternative to polling `read jobs://` in loops
9
+ - Added `task.maxConcurrency` setting to limit the number of concurrently executing subagent tasks
10
+ - Added support for rendering markdown output from Python cells with proper formatting and theme styling
11
+ - Added async background job execution for bash commands and tasks with `async: true` parameter
12
+ - Added `cancel_job` tool to cancel running background jobs
13
+ - Added `jobs://` internal protocol to inspect background job status and results
14
+ - Added `/jobs` slash command to display running and recent background jobs in interactive mode
15
+ - Added `async.enabled` and `async.maxJobs` settings to control background job execution
16
+ - Added background job status indicator in status line showing count of running jobs
17
+ - Added support for GitLab Duo authentication provider
18
+ - Added clearer truncation notices across tools with consistent line/size context and continuation hints
19
+
20
+ ### Changed
21
+
22
+ - Updated bash and task tool guidance to recommend `poll_jobs` instead of polling `read jobs://` in loops when waiting for async results
23
+ - Improved parallel task execution to schedule multiple background jobs independently instead of batching all tasks into a single job, enabling true concurrent execution
24
+ - Enhanced task progress tracking to report per-task status (pending, running, completed, failed, aborted) with individual timing and token metrics for each background task
25
+ - Updated background task messaging to provide real-time progress counts (e.g., '2/5 finished') and distinguish between single and multiple task jobs
26
+ - Hid internal `agent__intent` parameter from tool argument displays in UI and logs to reduce visual clutter
27
+ - Updated Python tool to detect and handle markdown display output separately from plain text
28
+ - Updated bash tool to support async execution mode with streaming progress updates
29
+ - Updated task tool to support async execution mode for parallel subagent execution
30
+ - Modified subagent settings to disable async execution in child agents to prevent nesting
31
+ - Updated tool execution component to handle background async task state without spinner animation
32
+ - Changed event controller to keep background tool calls pending until async completion
33
+ - Updated status line width calculation to accommodate background job indicator
34
+ - Updated the system prompt pipeline to reduce injected environment noise and make instructions more focused on execution quality
35
+ - Updated system prompt/workflow guidance to emphasize root-cause fixes, code quality, and explicit handoff/testing expectations
36
+ - Changed default value of `todo.reminders` setting from false to true to enable todo reminders by default
37
+ - Improved truncation/output handling for large command results to reduce memory pressure and keep previews responsive
38
+ - Updated internal artifact handling so tool output artifacts stay consistent across session switches and resumes
39
+
40
+ ### Removed
41
+
42
+ - Removed git context (branch, status, commit history) from system prompt — version control information is no longer injected into agent instructions
43
+
44
+ ### Fixed
45
+
46
+ - Fixed task progress display to hide tool count and token metrics when zero, reducing visual clutter in status output
47
+ - Fixed Lobsters scraper to correctly parse API responses where user fields are strings instead of objects, resolving undefined user display in story listings
48
+ - Fixed artifact manager caching to properly invalidate when session file changes, preventing stale artifact references
49
+ - Fixed truncation behavior around UTF-8 boundaries and chunked output accounting
50
+ - Fixed `submit_result` schema generation to use valid JSON Schema when no explicit output schema is provided
51
+
5
52
  ## [12.18.1] - 2026-02-21
6
53
  ### Added
7
54
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "12.18.1",
4
+ "version": "12.19.0",
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
7
  "author": "Can Bölük",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "0.6.0",
44
- "@oh-my-pi/omp-stats": "12.18.1",
45
- "@oh-my-pi/pi-agent-core": "12.18.1",
46
- "@oh-my-pi/pi-ai": "12.18.1",
47
- "@oh-my-pi/pi-natives": "12.18.1",
48
- "@oh-my-pi/pi-tui": "12.18.1",
49
- "@oh-my-pi/pi-utils": "12.18.1",
44
+ "@oh-my-pi/omp-stats": "12.19.0",
45
+ "@oh-my-pi/pi-agent-core": "12.19.0",
46
+ "@oh-my-pi/pi-ai": "12.19.0",
47
+ "@oh-my-pi/pi-natives": "12.19.0",
48
+ "@oh-my-pi/pi-tui": "12.19.0",
49
+ "@oh-my-pi/pi-utils": "12.19.0",
50
50
  "@sinclair/typebox": "^0.34.48",
51
51
  "@xterm/headless": "^6.0.0",
52
52
  "ajv": "^8.18.0",
@@ -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
- }
@@ -115,7 +115,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
115
115
  clearThinkingLine();
116
116
  const assistantMessage = event.message as { stopReason?: string; errorMessage?: string };
117
117
  if (assistantMessage.stopReason === "error" && assistantMessage.errorMessage) {
118
- writeStdout(`● Error: ${assistantMessage.errorMessage}`);
118
+ process.stdout.write(`● Error: ${assistantMessage.errorMessage}\n`);
119
119
  }
120
120
  const messageText = extractMessageText(event.message?.content ?? []);
121
121
  if (messageText) {
@@ -130,10 +130,10 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
130
130
  clearThinkingLine();
131
131
  const toolLabel = formatToolLabel(stored.name);
132
132
  const symbol = event.isError ? "" : "";
133
- writeStdout(`${symbol} ${toolLabel}`);
133
+ process.stdout.write(`${symbol} ${toolLabel}\n`);
134
134
  const argsLines = formatToolArgs(stored.args);
135
135
  if (argsLines.length > 0) {
136
- writeStdout(formatToolArgsBlock(argsLines));
136
+ process.stdout.write(`${formatToolArgsBlock(argsLines)}\n`);
137
137
  }
138
138
  break;
139
139
  }
@@ -141,7 +141,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
141
141
  if (isThinking) {
142
142
  isThinking = false;
143
143
  }
144
- writeStdout(`● agent finished (${messageCount} messages, ${toolCalls} tools)`);
144
+ process.stdout.write(`● agent finished (${messageCount} messages, ${toolCalls} tools)\n`);
145
145
  break;
146
146
  default:
147
147
  break;
@@ -172,10 +172,6 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
172
172
  }
173
173
  }
174
174
 
175
- function writeStdout(message: string): void {
176
- process.stdout.write(`${message}\n`);
177
- }
178
-
179
175
  function extractMessagePreview(content: Array<{ type: string; text?: string }>): string | null {
180
176
  const textBlocks = content
181
177
  .filter(block => block.type === "text" && typeof block.text === "string")
@@ -204,7 +200,7 @@ function writeAssistantMessage(message: string): void {
204
200
  }
205
201
  for (const [index, line] of lines.entries()) {
206
202
  const prefix = index === firstContentIndex ? "● " : " ";
207
- writeStdout(`${prefix}${line}`.trimEnd());
203
+ process.stdout.write(`${`${prefix}${line}`.trimEnd()}\n`);
208
204
  }
209
205
  }
210
206
 
@@ -249,6 +245,7 @@ function formatToolArgs(args?: Record<string, unknown>): string[] {
249
245
  }
250
246
  };
251
247
  for (const [key, value] of Object.entries(args)) {
248
+ if (key === "agent__intent") continue;
252
249
  visit(value, key);
253
250
  }
254
251
  return lines;