@oh-my-pi/pi-coding-agent 1.337.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 (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Credential storage for API keys and OAuth tokens.
3
+ * Handles loading, saving, and refreshing credentials from auth.json.
4
+ */
5
+
6
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import {
9
+ getEnvApiKey,
10
+ getOAuthApiKey,
11
+ loginAnthropic,
12
+ loginAntigravity,
13
+ loginGeminiCli,
14
+ loginGitHubCopilot,
15
+ type OAuthCredentials,
16
+ type OAuthProvider,
17
+ } from "@oh-my-pi/pi-ai";
18
+
19
+ export type ApiKeyCredential = {
20
+ type: "api_key";
21
+ key: string;
22
+ };
23
+
24
+ export type OAuthCredential = {
25
+ type: "oauth";
26
+ } & OAuthCredentials;
27
+
28
+ export type AuthCredential = ApiKeyCredential | OAuthCredential;
29
+
30
+ export type AuthStorageData = Record<string, AuthCredential>;
31
+
32
+ /**
33
+ * Credential storage backed by a JSON file.
34
+ */
35
+ export class AuthStorage {
36
+ private data: AuthStorageData = {};
37
+ private runtimeOverrides: Map<string, string> = new Map();
38
+ private fallbackResolver?: (provider: string) => string | undefined;
39
+
40
+ constructor(private authPath: string) {
41
+ this.reload();
42
+ }
43
+
44
+ /**
45
+ * Set a runtime API key override (not persisted to disk).
46
+ * Used for CLI --api-key flag.
47
+ */
48
+ setRuntimeApiKey(provider: string, apiKey: string): void {
49
+ this.runtimeOverrides.set(provider, apiKey);
50
+ }
51
+
52
+ /**
53
+ * Remove a runtime API key override.
54
+ */
55
+ removeRuntimeApiKey(provider: string): void {
56
+ this.runtimeOverrides.delete(provider);
57
+ }
58
+
59
+ /**
60
+ * Set a fallback resolver for API keys not found in auth.json or env vars.
61
+ * Used for custom provider keys from models.json.
62
+ */
63
+ setFallbackResolver(resolver: (provider: string) => string | undefined): void {
64
+ this.fallbackResolver = resolver;
65
+ }
66
+
67
+ /**
68
+ * Reload credentials from disk.
69
+ */
70
+ reload(): void {
71
+ if (!existsSync(this.authPath)) {
72
+ this.data = {};
73
+ return;
74
+ }
75
+ try {
76
+ const file = Bun.file(this.authPath);
77
+ this.data = JSON.parse(file.text() as unknown as string);
78
+ } catch {
79
+ this.data = {};
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Save credentials to disk.
85
+ */
86
+ private save(): void {
87
+ const dir = dirname(this.authPath);
88
+ if (!existsSync(dir)) {
89
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ }
91
+ Bun.write(this.authPath, JSON.stringify(this.data, null, 2));
92
+ chmodSync(this.authPath, 0o600);
93
+ }
94
+
95
+ /**
96
+ * Get credential for a provider.
97
+ */
98
+ get(provider: string): AuthCredential | undefined {
99
+ return this.data[provider] ?? undefined;
100
+ }
101
+
102
+ /**
103
+ * Set credential for a provider.
104
+ */
105
+ set(provider: string, credential: AuthCredential): void {
106
+ this.data[provider] = credential;
107
+ this.save();
108
+ }
109
+
110
+ /**
111
+ * Remove credential for a provider.
112
+ */
113
+ remove(provider: string): void {
114
+ delete this.data[provider];
115
+ this.save();
116
+ }
117
+
118
+ /**
119
+ * List all providers with credentials.
120
+ */
121
+ list(): string[] {
122
+ return Object.keys(this.data);
123
+ }
124
+
125
+ /**
126
+ * Check if credentials exist for a provider.
127
+ */
128
+ has(provider: string): boolean {
129
+ return provider in this.data;
130
+ }
131
+
132
+ /**
133
+ * Get all credentials (for passing to getOAuthApiKey).
134
+ */
135
+ getAll(): AuthStorageData {
136
+ return { ...this.data };
137
+ }
138
+
139
+ /**
140
+ * Login to an OAuth provider.
141
+ */
142
+ async login(
143
+ provider: OAuthProvider,
144
+ callbacks: {
145
+ onAuth: (info: { url: string; instructions?: string }) => void;
146
+ onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
147
+ onProgress?: (message: string) => void;
148
+ },
149
+ ): Promise<void> {
150
+ let credentials: OAuthCredentials;
151
+
152
+ switch (provider) {
153
+ case "anthropic":
154
+ credentials = await loginAnthropic(
155
+ (url) => callbacks.onAuth({ url }),
156
+ () => callbacks.onPrompt({ message: "Paste the authorization code:" }),
157
+ );
158
+ break;
159
+ case "github-copilot":
160
+ credentials = await loginGitHubCopilot({
161
+ onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
162
+ onPrompt: callbacks.onPrompt,
163
+ onProgress: callbacks.onProgress,
164
+ });
165
+ break;
166
+ case "google-gemini-cli":
167
+ credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);
168
+ break;
169
+ case "google-antigravity":
170
+ credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
171
+ break;
172
+ default:
173
+ throw new Error(`Unknown OAuth provider: ${provider}`);
174
+ }
175
+
176
+ this.set(provider, { type: "oauth", ...credentials });
177
+ }
178
+
179
+ /**
180
+ * Logout from a provider.
181
+ */
182
+ logout(provider: string): void {
183
+ this.remove(provider);
184
+ }
185
+
186
+ /**
187
+ * Get API key for a provider.
188
+ * Priority:
189
+ * 1. Runtime override (CLI --api-key)
190
+ * 2. API key from auth.json
191
+ * 3. OAuth token from auth.json (auto-refreshed)
192
+ * 4. Environment variable
193
+ * 5. Fallback resolver (models.json custom providers)
194
+ */
195
+ async getApiKey(provider: string): Promise<string | undefined> {
196
+ // Runtime override takes highest priority
197
+ const runtimeKey = this.runtimeOverrides.get(provider);
198
+ if (runtimeKey) {
199
+ return runtimeKey;
200
+ }
201
+
202
+ const cred = this.data[provider];
203
+
204
+ if (cred?.type === "api_key") {
205
+ return cred.key;
206
+ }
207
+
208
+ if (cred?.type === "oauth") {
209
+ // Filter to only oauth credentials for getOAuthApiKey
210
+ const oauthCreds: Record<string, OAuthCredentials> = {};
211
+ for (const [key, value] of Object.entries(this.data)) {
212
+ if (value.type === "oauth") {
213
+ oauthCreds[key] = value;
214
+ }
215
+ }
216
+
217
+ try {
218
+ const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
219
+ if (result) {
220
+ this.data[provider] = { type: "oauth", ...result.newCredentials };
221
+ this.save();
222
+ return result.apiKey;
223
+ }
224
+ } catch {
225
+ this.remove(provider);
226
+ }
227
+ }
228
+
229
+ // Fall back to environment variable
230
+ const envKey = getEnvApiKey(provider);
231
+ if (envKey) return envKey;
232
+
233
+ // Fall back to custom resolver (e.g., models.json custom providers)
234
+ return this.fallbackResolver?.(provider) ?? undefined;
235
+ }
236
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Bash command execution with streaming support and cancellation.
3
+ *
4
+ * This module provides a unified bash execution implementation used by:
5
+ * - AgentSession.executeBash() for interactive and RPC modes
6
+ * - Direct calls from modes that need bash execution
7
+ */
8
+
9
+ import { createWriteStream, type WriteStream } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { Subprocess } from "bun";
13
+ import stripAnsi from "strip-ansi";
14
+ import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
15
+ import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ export interface BashExecutorOptions {
22
+ /** Callback for streaming output chunks (already sanitized) */
23
+ onChunk?: (chunk: string) => void;
24
+ /** AbortSignal for cancellation */
25
+ signal?: AbortSignal;
26
+ }
27
+
28
+ export interface BashResult {
29
+ /** Combined stdout + stderr output (sanitized, possibly truncated) */
30
+ output: string;
31
+ /** Process exit code (undefined if killed/cancelled) */
32
+ exitCode: number | undefined;
33
+ /** Whether the command was cancelled via signal */
34
+ cancelled: boolean;
35
+ /** Whether the output was truncated */
36
+ truncated: boolean;
37
+ /** Path to temp file containing full output (if output exceeded truncation threshold) */
38
+ fullOutputPath?: string;
39
+ }
40
+
41
+ // ============================================================================
42
+ // Implementation
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Execute a bash command with optional streaming and cancellation support.
47
+ *
48
+ * Features:
49
+ * - Streams sanitized output via onChunk callback
50
+ * - Writes large output to temp file for later retrieval
51
+ * - Supports cancellation via AbortSignal
52
+ * - Sanitizes output (strips ANSI, removes binary garbage, normalizes newlines)
53
+ * - Truncates output if it exceeds the default max bytes
54
+ *
55
+ * @param command - The bash command to execute
56
+ * @param options - Optional streaming callback and abort signal
57
+ * @returns Promise resolving to execution result
58
+ */
59
+ export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
60
+ return new Promise((resolve, reject) => {
61
+ const { shell, args } = getShellConfig();
62
+ const child: Subprocess = Bun.spawn([shell, ...args, command], {
63
+ stdin: "ignore",
64
+ stdout: "pipe",
65
+ stderr: "pipe",
66
+ });
67
+
68
+ // Track sanitized output for truncation
69
+ const outputChunks: string[] = [];
70
+ let outputBytes = 0;
71
+ const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
72
+
73
+ // Temp file for large output
74
+ let tempFilePath: string | undefined;
75
+ let tempFileStream: WriteStream | undefined;
76
+ let totalBytes = 0;
77
+
78
+ // Handle abort signal
79
+ const abortHandler = () => {
80
+ killProcessTree(child.pid);
81
+ };
82
+
83
+ if (options?.signal) {
84
+ if (options.signal.aborted) {
85
+ // Already aborted, don't even start
86
+ child.kill();
87
+ resolve({
88
+ output: "",
89
+ exitCode: undefined,
90
+ cancelled: true,
91
+ truncated: false,
92
+ });
93
+ return;
94
+ }
95
+ options.signal.addEventListener("abort", abortHandler, { once: true });
96
+ }
97
+
98
+ const handleData = (data: Buffer) => {
99
+ totalBytes += data.length;
100
+
101
+ // Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
102
+ const text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\r/g, "");
103
+
104
+ // Start writing to temp file if exceeds threshold
105
+ if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
106
+ const randomId = crypto.getRandomValues(new Uint8Array(8));
107
+ const id = Array.from(randomId, (b) => b.toString(16).padStart(2, "0")).join("");
108
+ tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
109
+ tempFileStream = createWriteStream(tempFilePath);
110
+ // Write already-buffered chunks to temp file
111
+ for (const chunk of outputChunks) {
112
+ tempFileStream.write(chunk);
113
+ }
114
+ }
115
+
116
+ if (tempFileStream) {
117
+ tempFileStream.write(text);
118
+ }
119
+
120
+ // Keep rolling buffer of sanitized text
121
+ outputChunks.push(text);
122
+ outputBytes += text.length;
123
+ while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
124
+ const removed = outputChunks.shift()!;
125
+ outputBytes -= removed.length;
126
+ }
127
+
128
+ // Stream to callback if provided
129
+ if (options?.onChunk) {
130
+ options.onChunk(text);
131
+ }
132
+ };
133
+
134
+ // Read streams asynchronously
135
+ (async () => {
136
+ try {
137
+ const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
138
+ const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
139
+
140
+ await Promise.all([
141
+ (async () => {
142
+ while (true) {
143
+ const { done, value } = await stdoutReader.read();
144
+ if (done) break;
145
+ handleData(Buffer.from(value));
146
+ }
147
+ })(),
148
+ (async () => {
149
+ while (true) {
150
+ const { done, value } = await stderrReader.read();
151
+ if (done) break;
152
+ handleData(Buffer.from(value));
153
+ }
154
+ })(),
155
+ ]);
156
+
157
+ const exitCode = await child.exited;
158
+
159
+ // Clean up abort listener
160
+ if (options?.signal) {
161
+ options.signal.removeEventListener("abort", abortHandler);
162
+ }
163
+
164
+ if (tempFileStream) {
165
+ tempFileStream.end();
166
+ }
167
+
168
+ // Combine buffered chunks for truncation (already sanitized)
169
+ const fullOutput = outputChunks.join("");
170
+ const truncationResult = truncateTail(fullOutput);
171
+
172
+ // Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal
173
+ const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
174
+
175
+ resolve({
176
+ output: truncationResult.truncated ? truncationResult.content : fullOutput,
177
+ exitCode: cancelled ? undefined : exitCode,
178
+ cancelled,
179
+ truncated: truncationResult.truncated,
180
+ fullOutputPath: tempFilePath,
181
+ });
182
+ } catch (err) {
183
+ // Clean up abort listener
184
+ if (options?.signal) {
185
+ options.signal.removeEventListener("abort", abortHandler);
186
+ }
187
+
188
+ if (tempFileStream) {
189
+ tempFileStream.end();
190
+ }
191
+
192
+ reject(err);
193
+ }
194
+ })();
195
+ });
196
+ }