indusagi-coding-agent 0.50.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 (240) hide show
  1. package/CHANGELOG.md +2249 -0
  2. package/README.md +546 -0
  3. package/dist/cli/args.js +282 -0
  4. package/dist/cli/config-selector.js +30 -0
  5. package/dist/cli/file-processor.js +78 -0
  6. package/dist/cli/list-models.js +91 -0
  7. package/dist/cli/session-picker.js +31 -0
  8. package/dist/cli.js +10 -0
  9. package/dist/config.js +158 -0
  10. package/dist/core/agent-session.js +2097 -0
  11. package/dist/core/auth-storage.js +278 -0
  12. package/dist/core/bash-executor.js +211 -0
  13. package/dist/core/compaction/branch-summarization.js +241 -0
  14. package/dist/core/compaction/compaction.js +606 -0
  15. package/dist/core/compaction/index.js +6 -0
  16. package/dist/core/compaction/utils.js +137 -0
  17. package/dist/core/diagnostics.js +1 -0
  18. package/dist/core/event-bus.js +24 -0
  19. package/dist/core/exec.js +70 -0
  20. package/dist/core/export-html/ansi-to-html.js +248 -0
  21. package/dist/core/export-html/index.js +221 -0
  22. package/dist/core/export-html/template.css +905 -0
  23. package/dist/core/export-html/template.html +54 -0
  24. package/dist/core/export-html/template.js +1549 -0
  25. package/dist/core/export-html/tool-renderer.js +56 -0
  26. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  27. package/dist/core/export-html/vendor/marked.min.js +6 -0
  28. package/dist/core/extensions/index.js +8 -0
  29. package/dist/core/extensions/loader.js +395 -0
  30. package/dist/core/extensions/runner.js +499 -0
  31. package/dist/core/extensions/types.js +31 -0
  32. package/dist/core/extensions/wrapper.js +101 -0
  33. package/dist/core/footer-data-provider.js +133 -0
  34. package/dist/core/index.js +8 -0
  35. package/dist/core/keybindings.js +140 -0
  36. package/dist/core/messages.js +122 -0
  37. package/dist/core/model-registry.js +454 -0
  38. package/dist/core/model-resolver.js +309 -0
  39. package/dist/core/package-manager.js +1142 -0
  40. package/dist/core/prompt-templates.js +250 -0
  41. package/dist/core/resource-loader.js +569 -0
  42. package/dist/core/sdk.js +225 -0
  43. package/dist/core/session-manager.js +1078 -0
  44. package/dist/core/settings-manager.js +430 -0
  45. package/dist/core/skills.js +339 -0
  46. package/dist/core/system-prompt.js +136 -0
  47. package/dist/core/timings.js +24 -0
  48. package/dist/core/tools/bash.js +226 -0
  49. package/dist/core/tools/edit-diff.js +242 -0
  50. package/dist/core/tools/edit.js +145 -0
  51. package/dist/core/tools/find.js +205 -0
  52. package/dist/core/tools/grep.js +238 -0
  53. package/dist/core/tools/index.js +60 -0
  54. package/dist/core/tools/ls.js +117 -0
  55. package/dist/core/tools/path-utils.js +52 -0
  56. package/dist/core/tools/read.js +165 -0
  57. package/dist/core/tools/truncate.js +204 -0
  58. package/dist/core/tools/write.js +77 -0
  59. package/dist/index.js +41 -0
  60. package/dist/main.js +565 -0
  61. package/dist/migrations.js +260 -0
  62. package/dist/modes/index.js +7 -0
  63. package/dist/modes/interactive/components/armin.js +328 -0
  64. package/dist/modes/interactive/components/assistant-message.js +86 -0
  65. package/dist/modes/interactive/components/bash-execution.js +155 -0
  66. package/dist/modes/interactive/components/bordered-loader.js +47 -0
  67. package/dist/modes/interactive/components/branch-summary-message.js +41 -0
  68. package/dist/modes/interactive/components/compaction-summary-message.js +42 -0
  69. package/dist/modes/interactive/components/config-selector.js +458 -0
  70. package/dist/modes/interactive/components/countdown-timer.js +27 -0
  71. package/dist/modes/interactive/components/custom-editor.js +61 -0
  72. package/dist/modes/interactive/components/custom-message.js +80 -0
  73. package/dist/modes/interactive/components/diff.js +132 -0
  74. package/dist/modes/interactive/components/dynamic-border.js +19 -0
  75. package/dist/modes/interactive/components/extension-editor.js +96 -0
  76. package/dist/modes/interactive/components/extension-input.js +54 -0
  77. package/dist/modes/interactive/components/extension-selector.js +70 -0
  78. package/dist/modes/interactive/components/footer.js +213 -0
  79. package/dist/modes/interactive/components/index.js +31 -0
  80. package/dist/modes/interactive/components/keybinding-hints.js +60 -0
  81. package/dist/modes/interactive/components/login-dialog.js +138 -0
  82. package/dist/modes/interactive/components/model-selector.js +253 -0
  83. package/dist/modes/interactive/components/oauth-selector.js +91 -0
  84. package/dist/modes/interactive/components/scoped-models-selector.js +262 -0
  85. package/dist/modes/interactive/components/session-selector-search.js +145 -0
  86. package/dist/modes/interactive/components/session-selector.js +698 -0
  87. package/dist/modes/interactive/components/settings-selector.js +250 -0
  88. package/dist/modes/interactive/components/show-images-selector.js +33 -0
  89. package/dist/modes/interactive/components/skill-invocation-message.js +44 -0
  90. package/dist/modes/interactive/components/theme-selector.js +43 -0
  91. package/dist/modes/interactive/components/thinking-selector.js +45 -0
  92. package/dist/modes/interactive/components/tool-execution.js +608 -0
  93. package/dist/modes/interactive/components/tree-selector.js +892 -0
  94. package/dist/modes/interactive/components/user-message-selector.js +109 -0
  95. package/dist/modes/interactive/components/user-message.js +15 -0
  96. package/dist/modes/interactive/components/visual-truncate.js +32 -0
  97. package/dist/modes/interactive/interactive-mode.js +3576 -0
  98. package/dist/modes/interactive/theme/dark.json +85 -0
  99. package/dist/modes/interactive/theme/light.json +84 -0
  100. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  101. package/dist/modes/interactive/theme/theme.js +938 -0
  102. package/dist/modes/print-mode.js +96 -0
  103. package/dist/modes/rpc/rpc-client.js +390 -0
  104. package/dist/modes/rpc/rpc-mode.js +448 -0
  105. package/dist/modes/rpc/rpc-types.js +7 -0
  106. package/dist/utils/changelog.js +86 -0
  107. package/dist/utils/clipboard-image.js +116 -0
  108. package/dist/utils/clipboard.js +58 -0
  109. package/dist/utils/frontmatter.js +25 -0
  110. package/dist/utils/git.js +5 -0
  111. package/dist/utils/image-convert.js +34 -0
  112. package/dist/utils/image-resize.js +180 -0
  113. package/dist/utils/mime.js +25 -0
  114. package/dist/utils/photon.js +120 -0
  115. package/dist/utils/shell.js +164 -0
  116. package/dist/utils/sleep.js +16 -0
  117. package/dist/utils/tools-manager.js +186 -0
  118. package/docs/compaction.md +390 -0
  119. package/docs/custom-provider.md +538 -0
  120. package/docs/development.md +69 -0
  121. package/docs/extensions.md +1733 -0
  122. package/docs/images/doom-extension.png +0 -0
  123. package/docs/images/interactive-mode.png +0 -0
  124. package/docs/images/tree-view.png +0 -0
  125. package/docs/json.md +79 -0
  126. package/docs/keybindings.md +162 -0
  127. package/docs/models.md +193 -0
  128. package/docs/packages.md +163 -0
  129. package/docs/prompt-templates.md +67 -0
  130. package/docs/providers.md +147 -0
  131. package/docs/rpc.md +1048 -0
  132. package/docs/sdk.md +957 -0
  133. package/docs/session.md +412 -0
  134. package/docs/settings.md +216 -0
  135. package/docs/shell-aliases.md +13 -0
  136. package/docs/skills.md +226 -0
  137. package/docs/terminal-setup.md +65 -0
  138. package/docs/themes.md +295 -0
  139. package/docs/tree.md +219 -0
  140. package/docs/tui.md +887 -0
  141. package/docs/windows.md +17 -0
  142. package/examples/README.md +25 -0
  143. package/examples/extensions/README.md +192 -0
  144. package/examples/extensions/antigravity-image-gen.ts +414 -0
  145. package/examples/extensions/auto-commit-on-exit.ts +49 -0
  146. package/examples/extensions/bookmark.ts +50 -0
  147. package/examples/extensions/claude-rules.ts +86 -0
  148. package/examples/extensions/confirm-destructive.ts +59 -0
  149. package/examples/extensions/custom-compaction.ts +115 -0
  150. package/examples/extensions/custom-footer.ts +65 -0
  151. package/examples/extensions/custom-header.ts +73 -0
  152. package/examples/extensions/custom-provider-anthropic/index.ts +605 -0
  153. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  154. package/examples/extensions/custom-provider-anthropic/package.json +19 -0
  155. package/examples/extensions/custom-provider-gitlab-duo/index.ts +350 -0
  156. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -0
  157. package/examples/extensions/custom-provider-gitlab-duo/test.ts +83 -0
  158. package/examples/extensions/dirty-repo-guard.ts +56 -0
  159. package/examples/extensions/doom-overlay/README.md +46 -0
  160. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  161. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  162. package/examples/extensions/doom-overlay/doom/build.sh +152 -0
  163. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  164. package/examples/extensions/doom-overlay/doom-component.ts +133 -0
  165. package/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  166. package/examples/extensions/doom-overlay/doom-keys.ts +105 -0
  167. package/examples/extensions/doom-overlay/index.ts +74 -0
  168. package/examples/extensions/doom-overlay/wad-finder.ts +51 -0
  169. package/examples/extensions/event-bus.ts +43 -0
  170. package/examples/extensions/file-trigger.ts +41 -0
  171. package/examples/extensions/git-checkpoint.ts +53 -0
  172. package/examples/extensions/handoff.ts +151 -0
  173. package/examples/extensions/hello.ts +25 -0
  174. package/examples/extensions/inline-bash.ts +94 -0
  175. package/examples/extensions/input-transform.ts +43 -0
  176. package/examples/extensions/interactive-shell.ts +196 -0
  177. package/examples/extensions/mac-system-theme.ts +47 -0
  178. package/examples/extensions/message-renderer.ts +60 -0
  179. package/examples/extensions/modal-editor.ts +86 -0
  180. package/examples/extensions/model-status.ts +31 -0
  181. package/examples/extensions/notify.ts +25 -0
  182. package/examples/extensions/overlay-qa-tests.ts +882 -0
  183. package/examples/extensions/overlay-test.ts +151 -0
  184. package/examples/extensions/permission-gate.ts +34 -0
  185. package/examples/extensions/pirate.ts +47 -0
  186. package/examples/extensions/plan-mode/README.md +65 -0
  187. package/examples/extensions/plan-mode/index.ts +341 -0
  188. package/examples/extensions/plan-mode/utils.ts +168 -0
  189. package/examples/extensions/preset.ts +399 -0
  190. package/examples/extensions/protected-paths.ts +30 -0
  191. package/examples/extensions/qna.ts +120 -0
  192. package/examples/extensions/question.ts +265 -0
  193. package/examples/extensions/questionnaire.ts +428 -0
  194. package/examples/extensions/rainbow-editor.ts +88 -0
  195. package/examples/extensions/sandbox/index.ts +318 -0
  196. package/examples/extensions/sandbox/package-lock.json +92 -0
  197. package/examples/extensions/sandbox/package.json +19 -0
  198. package/examples/extensions/send-user-message.ts +97 -0
  199. package/examples/extensions/session-name.ts +27 -0
  200. package/examples/extensions/shutdown-command.ts +63 -0
  201. package/examples/extensions/snake.ts +344 -0
  202. package/examples/extensions/space-invaders.ts +561 -0
  203. package/examples/extensions/ssh.ts +220 -0
  204. package/examples/extensions/status-line.ts +40 -0
  205. package/examples/extensions/subagent/README.md +172 -0
  206. package/examples/extensions/subagent/agents/planner.md +37 -0
  207. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  208. package/examples/extensions/subagent/agents/scout.md +50 -0
  209. package/examples/extensions/subagent/agents/worker.md +24 -0
  210. package/examples/extensions/subagent/agents.ts +127 -0
  211. package/examples/extensions/subagent/index.ts +964 -0
  212. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  213. package/examples/extensions/subagent/prompts/implement.md +10 -0
  214. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  215. package/examples/extensions/summarize.ts +196 -0
  216. package/examples/extensions/timed-confirm.ts +70 -0
  217. package/examples/extensions/todo.ts +300 -0
  218. package/examples/extensions/tool-override.ts +144 -0
  219. package/examples/extensions/tools.ts +147 -0
  220. package/examples/extensions/trigger-compact.ts +40 -0
  221. package/examples/extensions/truncated-tool.ts +193 -0
  222. package/examples/extensions/widget-placement.ts +17 -0
  223. package/examples/extensions/with-deps/index.ts +36 -0
  224. package/examples/extensions/with-deps/package-lock.json +31 -0
  225. package/examples/extensions/with-deps/package.json +22 -0
  226. package/examples/sdk/01-minimal.ts +22 -0
  227. package/examples/sdk/02-custom-model.ts +50 -0
  228. package/examples/sdk/03-custom-prompt.ts +55 -0
  229. package/examples/sdk/04-skills.ts +46 -0
  230. package/examples/sdk/05-tools.ts +56 -0
  231. package/examples/sdk/06-extensions.ts +88 -0
  232. package/examples/sdk/07-context-files.ts +40 -0
  233. package/examples/sdk/08-prompt-templates.ts +47 -0
  234. package/examples/sdk/09-api-keys-and-oauth.ts +48 -0
  235. package/examples/sdk/10-settings.ts +38 -0
  236. package/examples/sdk/11-sessions.ts +48 -0
  237. package/examples/sdk/12-full-control.ts +82 -0
  238. package/examples/sdk/13-codex-oauth.ts +37 -0
  239. package/examples/sdk/README.md +144 -0
  240. package/package.json +85 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Credential storage for API keys and OAuth tokens.
3
+ * Handles loading, saving, and refreshing credentials from auth.json.
4
+ *
5
+ * Uses file locking to prevent race conditions when multiple indusagi instances
6
+ * try to refresh tokens simultaneously.
7
+ */
8
+ import { getEnvApiKey, getOAuthApiKey, getOAuthProvider, getOAuthProviders, } from "indusagi/ai";
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
+ import { dirname, join } from "path";
11
+ import lockfile from "proper-lockfile";
12
+ import { getAgentDir } from "../config.js";
13
+ /**
14
+ * Credential storage backed by a JSON file.
15
+ */
16
+ export class AuthStorage {
17
+ constructor(authPath = join(getAgentDir(), "auth.json")) {
18
+ this.authPath = authPath;
19
+ this.data = {};
20
+ this.runtimeOverrides = new Map();
21
+ this.reload();
22
+ }
23
+ /**
24
+ * Set a runtime API key override (not persisted to disk).
25
+ * Used for CLI --api-key flag.
26
+ */
27
+ setRuntimeApiKey(provider, apiKey) {
28
+ this.runtimeOverrides.set(provider, apiKey);
29
+ }
30
+ /**
31
+ * Remove a runtime API key override.
32
+ */
33
+ removeRuntimeApiKey(provider) {
34
+ this.runtimeOverrides.delete(provider);
35
+ }
36
+ /**
37
+ * Set a fallback resolver for API keys not found in auth.json or env vars.
38
+ * Used for custom provider keys from models.json.
39
+ */
40
+ setFallbackResolver(resolver) {
41
+ this.fallbackResolver = resolver;
42
+ }
43
+ /**
44
+ * Reload credentials from disk.
45
+ */
46
+ reload() {
47
+ if (!existsSync(this.authPath)) {
48
+ this.data = {};
49
+ return;
50
+ }
51
+ try {
52
+ this.data = JSON.parse(readFileSync(this.authPath, "utf-8"));
53
+ }
54
+ catch {
55
+ this.data = {};
56
+ }
57
+ }
58
+ /**
59
+ * Save credentials to disk.
60
+ */
61
+ save() {
62
+ const dir = dirname(this.authPath);
63
+ if (!existsSync(dir)) {
64
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
65
+ }
66
+ writeFileSync(this.authPath, JSON.stringify(this.data, null, 2), "utf-8");
67
+ chmodSync(this.authPath, 0o600);
68
+ }
69
+ /**
70
+ * Get credential for a provider.
71
+ */
72
+ get(provider) {
73
+ return this.data[provider] ?? undefined;
74
+ }
75
+ /**
76
+ * Set credential for a provider.
77
+ */
78
+ set(provider, credential) {
79
+ this.data[provider] = credential;
80
+ this.save();
81
+ }
82
+ /**
83
+ * Remove credential for a provider.
84
+ */
85
+ remove(provider) {
86
+ delete this.data[provider];
87
+ this.save();
88
+ }
89
+ /**
90
+ * List all providers with credentials.
91
+ */
92
+ list() {
93
+ return Object.keys(this.data);
94
+ }
95
+ /**
96
+ * Check if credentials exist for a provider in auth.json.
97
+ */
98
+ has(provider) {
99
+ return provider in this.data;
100
+ }
101
+ /**
102
+ * Check if any form of auth is configured for a provider.
103
+ * Unlike getApiKey(), this doesn't refresh OAuth tokens.
104
+ */
105
+ hasAuth(provider) {
106
+ if (this.runtimeOverrides.has(provider))
107
+ return true;
108
+ if (this.data[provider])
109
+ return true;
110
+ if (getEnvApiKey(provider))
111
+ return true;
112
+ if (this.fallbackResolver?.(provider))
113
+ return true;
114
+ return false;
115
+ }
116
+ /**
117
+ * Get all credentials (for passing to getOAuthApiKey).
118
+ */
119
+ getAll() {
120
+ return { ...this.data };
121
+ }
122
+ /**
123
+ * Login to an OAuth provider.
124
+ */
125
+ async login(providerId, callbacks) {
126
+ const provider = getOAuthProvider(providerId);
127
+ if (!provider) {
128
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
129
+ }
130
+ const credentials = await provider.login(callbacks);
131
+ this.set(providerId, { type: "oauth", ...credentials });
132
+ }
133
+ /**
134
+ * Logout from a provider.
135
+ */
136
+ logout(provider) {
137
+ this.remove(provider);
138
+ }
139
+ /**
140
+ * Refresh OAuth token with file locking to prevent race conditions.
141
+ * Multiple indusagi instances may try to refresh simultaneously when tokens expire.
142
+ * This ensures only one instance refreshes while others wait and use the result.
143
+ */
144
+ async refreshOAuthTokenWithLock(providerId) {
145
+ const provider = getOAuthProvider(providerId);
146
+ if (!provider) {
147
+ return null;
148
+ }
149
+ // Ensure auth file exists for locking
150
+ if (!existsSync(this.authPath)) {
151
+ const dir = dirname(this.authPath);
152
+ if (!existsSync(dir)) {
153
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
154
+ }
155
+ writeFileSync(this.authPath, "{}", "utf-8");
156
+ chmodSync(this.authPath, 0o600);
157
+ }
158
+ let release;
159
+ try {
160
+ // Acquire exclusive lock with retry and timeout
161
+ // Use generous retry window to handle slow token endpoints
162
+ release = await lockfile.lock(this.authPath, {
163
+ retries: {
164
+ retries: 10,
165
+ factor: 2,
166
+ minTimeout: 100,
167
+ maxTimeout: 10000,
168
+ randomize: true,
169
+ },
170
+ stale: 30000, // Consider lock stale after 30 seconds
171
+ });
172
+ // Re-read file after acquiring lock - another instance may have refreshed
173
+ this.reload();
174
+ const cred = this.data[providerId];
175
+ if (cred?.type !== "oauth") {
176
+ return null;
177
+ }
178
+ // Check if token is still expired after re-reading
179
+ // (another instance may have already refreshed it)
180
+ if (Date.now() < cred.expires) {
181
+ // Token is now valid - another instance refreshed it
182
+ const apiKey = provider.getApiKey(cred);
183
+ return { apiKey, newCredentials: cred };
184
+ }
185
+ // Token still expired, we need to refresh
186
+ const oauthCreds = {};
187
+ for (const [key, value] of Object.entries(this.data)) {
188
+ if (value.type === "oauth") {
189
+ oauthCreds[key] = value;
190
+ }
191
+ }
192
+ const result = await getOAuthApiKey(providerId, oauthCreds);
193
+ if (result) {
194
+ this.data[providerId] = { type: "oauth", ...result.newCredentials };
195
+ this.save();
196
+ return result;
197
+ }
198
+ return null;
199
+ }
200
+ finally {
201
+ // Always release the lock
202
+ if (release) {
203
+ try {
204
+ await release();
205
+ }
206
+ catch {
207
+ // Ignore unlock errors (lock may have been compromised)
208
+ }
209
+ }
210
+ }
211
+ }
212
+ /**
213
+ * Get API key for a provider.
214
+ * Priority:
215
+ * 1. Runtime override (CLI --api-key)
216
+ * 2. API key from auth.json
217
+ * 3. OAuth token from auth.json (auto-refreshed with locking)
218
+ * 4. Environment variable
219
+ * 5. Fallback resolver (models.json custom providers)
220
+ */
221
+ async getApiKey(providerId) {
222
+ // Runtime override takes highest priority
223
+ const runtimeKey = this.runtimeOverrides.get(providerId);
224
+ if (runtimeKey) {
225
+ return runtimeKey;
226
+ }
227
+ const cred = this.data[providerId];
228
+ if (cred?.type === "api_key") {
229
+ return cred.key;
230
+ }
231
+ if (cred?.type === "oauth") {
232
+ const provider = getOAuthProvider(providerId);
233
+ if (!provider) {
234
+ // Unknown OAuth provider, can't get API key
235
+ return undefined;
236
+ }
237
+ // Check if token needs refresh
238
+ const needsRefresh = Date.now() >= cred.expires;
239
+ if (needsRefresh) {
240
+ // Use locked refresh to prevent race conditions
241
+ try {
242
+ const result = await this.refreshOAuthTokenWithLock(providerId);
243
+ if (result) {
244
+ return result.apiKey;
245
+ }
246
+ }
247
+ catch {
248
+ // Refresh failed - re-read file to check if another instance succeeded
249
+ this.reload();
250
+ const updatedCred = this.data[providerId];
251
+ if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) {
252
+ // Another instance refreshed successfully, use those credentials
253
+ return provider.getApiKey(updatedCred);
254
+ }
255
+ // Refresh truly failed - return undefined so model discovery skips this provider
256
+ // User can /login to re-authenticate (credentials preserved for retry)
257
+ return undefined;
258
+ }
259
+ }
260
+ else {
261
+ // Token not expired, use current access token
262
+ return provider.getApiKey(cred);
263
+ }
264
+ }
265
+ // Fall back to environment variable
266
+ const envKey = getEnvApiKey(providerId);
267
+ if (envKey)
268
+ return envKey;
269
+ // Fall back to custom resolver (e.g., models.json custom providers)
270
+ return this.fallbackResolver?.(providerId) ?? undefined;
271
+ }
272
+ /**
273
+ * Get all registered OAuth providers
274
+ */
275
+ getOAuthProviders() {
276
+ return getOAuthProviders();
277
+ }
278
+ }
@@ -0,0 +1,211 @@
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
+ import { randomBytes } from "node:crypto";
9
+ import { createWriteStream } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { spawn } from "child_process";
13
+ import stripAnsi from "strip-ansi";
14
+ import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
15
+ import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
16
+ // ============================================================================
17
+ // Implementation
18
+ // ============================================================================
19
+ /**
20
+ * Execute a bash command with optional streaming and cancellation support.
21
+ *
22
+ * Features:
23
+ * - Streams sanitized output via onChunk callback
24
+ * - Writes large output to temp file for later retrieval
25
+ * - Supports cancellation via AbortSignal
26
+ * - Sanitizes output (strips ANSI, removes binary garbage, normalizes newlines)
27
+ * - Truncates output if it exceeds the default max bytes
28
+ *
29
+ * @param command - The bash command to execute
30
+ * @param options - Optional streaming callback and abort signal
31
+ * @returns Promise resolving to execution result
32
+ */
33
+ export function executeBash(command, options) {
34
+ return new Promise((resolve, reject) => {
35
+ const { shell, args } = getShellConfig();
36
+ const child = spawn(shell, [...args, command], {
37
+ detached: true,
38
+ env: getShellEnv(),
39
+ stdio: ["ignore", "pipe", "pipe"],
40
+ });
41
+ // Track sanitized output for truncation
42
+ const outputChunks = [];
43
+ let outputBytes = 0;
44
+ const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
45
+ // Temp file for large output
46
+ let tempFilePath;
47
+ let tempFileStream;
48
+ let totalBytes = 0;
49
+ // Handle abort signal
50
+ const abortHandler = () => {
51
+ if (child.pid) {
52
+ killProcessTree(child.pid);
53
+ }
54
+ };
55
+ if (options?.signal) {
56
+ if (options.signal.aborted) {
57
+ // Already aborted, don't even start
58
+ child.kill();
59
+ resolve({
60
+ output: "",
61
+ exitCode: undefined,
62
+ cancelled: true,
63
+ truncated: false,
64
+ });
65
+ return;
66
+ }
67
+ options.signal.addEventListener("abort", abortHandler, { once: true });
68
+ }
69
+ const decoder = new TextDecoder();
70
+ const handleData = (data) => {
71
+ totalBytes += data.length;
72
+ // Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
73
+ const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(data, { stream: true }))).replace(/\r/g, "");
74
+ // Start writing to temp file if exceeds threshold
75
+ if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
76
+ const id = randomBytes(8).toString("hex");
77
+ tempFilePath = join(tmpdir(), `indusagi-bash-${id}.log`);
78
+ tempFileStream = createWriteStream(tempFilePath);
79
+ // Write already-buffered chunks to temp file
80
+ for (const chunk of outputChunks) {
81
+ tempFileStream.write(chunk);
82
+ }
83
+ }
84
+ if (tempFileStream) {
85
+ tempFileStream.write(text);
86
+ }
87
+ // Keep rolling buffer of sanitized text
88
+ outputChunks.push(text);
89
+ outputBytes += text.length;
90
+ while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
91
+ const removed = outputChunks.shift();
92
+ outputBytes -= removed.length;
93
+ }
94
+ // Stream to callback if provided
95
+ if (options?.onChunk) {
96
+ options.onChunk(text);
97
+ }
98
+ };
99
+ child.stdout?.on("data", handleData);
100
+ child.stderr?.on("data", handleData);
101
+ child.on("close", (code) => {
102
+ // Clean up abort listener
103
+ if (options?.signal) {
104
+ options.signal.removeEventListener("abort", abortHandler);
105
+ }
106
+ if (tempFileStream) {
107
+ tempFileStream.end();
108
+ }
109
+ // Combine buffered chunks for truncation (already sanitized)
110
+ const fullOutput = outputChunks.join("");
111
+ const truncationResult = truncateTail(fullOutput);
112
+ // code === null means killed (cancelled)
113
+ const cancelled = code === null;
114
+ resolve({
115
+ output: truncationResult.truncated ? truncationResult.content : fullOutput,
116
+ exitCode: cancelled ? undefined : code,
117
+ cancelled,
118
+ truncated: truncationResult.truncated,
119
+ fullOutputPath: tempFilePath,
120
+ });
121
+ });
122
+ child.on("error", (err) => {
123
+ // Clean up abort listener
124
+ if (options?.signal) {
125
+ options.signal.removeEventListener("abort", abortHandler);
126
+ }
127
+ if (tempFileStream) {
128
+ tempFileStream.end();
129
+ }
130
+ reject(err);
131
+ });
132
+ });
133
+ }
134
+ /**
135
+ * Execute a bash command using custom BashOperations.
136
+ * Used for remote execution (SSH, containers, etc.).
137
+ */
138
+ export async function executeBashWithOperations(command, cwd, operations, options) {
139
+ const outputChunks = [];
140
+ let outputBytes = 0;
141
+ const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
142
+ let tempFilePath;
143
+ let tempFileStream;
144
+ let totalBytes = 0;
145
+ const decoder = new TextDecoder();
146
+ const onData = (data) => {
147
+ totalBytes += data.length;
148
+ // Sanitize: strip ANSI, replace binary garbage, normalize newlines
149
+ const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(data, { stream: true }))).replace(/\r/g, "");
150
+ // Start writing to temp file if exceeds threshold
151
+ if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
152
+ const id = randomBytes(8).toString("hex");
153
+ tempFilePath = join(tmpdir(), `indusagi-bash-${id}.log`);
154
+ tempFileStream = createWriteStream(tempFilePath);
155
+ for (const chunk of outputChunks) {
156
+ tempFileStream.write(chunk);
157
+ }
158
+ }
159
+ if (tempFileStream) {
160
+ tempFileStream.write(text);
161
+ }
162
+ // Keep rolling buffer
163
+ outputChunks.push(text);
164
+ outputBytes += text.length;
165
+ while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
166
+ const removed = outputChunks.shift();
167
+ outputBytes -= removed.length;
168
+ }
169
+ // Stream to callback
170
+ if (options?.onChunk) {
171
+ options.onChunk(text);
172
+ }
173
+ };
174
+ try {
175
+ const result = await operations.exec(command, cwd, {
176
+ onData,
177
+ signal: options?.signal,
178
+ });
179
+ if (tempFileStream) {
180
+ tempFileStream.end();
181
+ }
182
+ const fullOutput = outputChunks.join("");
183
+ const truncationResult = truncateTail(fullOutput);
184
+ const cancelled = options?.signal?.aborted ?? false;
185
+ return {
186
+ output: truncationResult.truncated ? truncationResult.content : fullOutput,
187
+ exitCode: cancelled ? undefined : (result.exitCode ?? undefined),
188
+ cancelled,
189
+ truncated: truncationResult.truncated,
190
+ fullOutputPath: tempFilePath,
191
+ };
192
+ }
193
+ catch (err) {
194
+ if (tempFileStream) {
195
+ tempFileStream.end();
196
+ }
197
+ // Check if it was an abort
198
+ if (options?.signal?.aborted) {
199
+ const fullOutput = outputChunks.join("");
200
+ const truncationResult = truncateTail(fullOutput);
201
+ return {
202
+ output: truncationResult.truncated ? truncationResult.content : fullOutput,
203
+ exitCode: undefined,
204
+ cancelled: true,
205
+ truncated: truncationResult.truncated,
206
+ fullOutputPath: tempFilePath,
207
+ };
208
+ }
209
+ throw err;
210
+ }
211
+ }