@gajae-code/coding-agent 0.12.6 → 0.12.8

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 (150) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/types/async/job-manager.d.ts +36 -1
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/config/file-lock.d.ts +3 -0
  5. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  6. package/dist/types/config/model-registry.d.ts +2 -0
  7. package/dist/types/config/resolve-config-value.d.ts +2 -1
  8. package/dist/types/config/settings-schema.d.ts +17 -1
  9. package/dist/types/eval/backend.d.ts +2 -2
  10. package/dist/types/exec/bash-executor.d.ts +8 -10
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -36
  12. package/dist/types/internal-urls/registry-helpers.d.ts +0 -18
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  15. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  16. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  17. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  18. package/dist/types/sdk/broker/broker.d.ts +5 -1
  19. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -3
  21. package/dist/types/sdk/bus/config.d.ts +75 -113
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/host.d.ts +2 -23
  33. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  34. package/dist/types/sdk/host/reverse-leases.d.ts +4 -7
  35. package/dist/types/sdk/index.d.ts +1 -0
  36. package/dist/types/sdk/providers.d.ts +22 -0
  37. package/dist/types/sdk/session-directory.d.ts +1 -1
  38. package/dist/types/session/artifacts.d.ts +5 -8
  39. package/dist/types/session/client-bridge.d.ts +0 -14
  40. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  41. package/dist/types/session/internal/managed-session-storage.d.ts +11 -4
  42. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  43. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  44. package/dist/types/session/messages.d.ts +1 -4
  45. package/dist/types/session/session-manager.d.ts +7 -6
  46. package/dist/types/session/session-storage.d.ts +2 -0
  47. package/dist/types/session/streaming-output.d.ts +0 -32
  48. package/dist/types/task/receipt.d.ts +1 -2
  49. package/dist/types/task/types.d.ts +43 -178
  50. package/dist/types/tools/bash.d.ts +1 -1
  51. package/dist/types/tools/fetch.d.ts +0 -3
  52. package/dist/types/tools/output-meta.d.ts +5 -37
  53. package/dist/types/tools/subagent.d.ts +15 -0
  54. package/dist/types/tools/todo-write.d.ts +3 -0
  55. package/dist/types/tools/tool-result.d.ts +0 -1
  56. package/package.json +10 -8
  57. package/scripts/build-sdk-package-smoke.ts +51 -0
  58. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  59. package/src/async/job-manager.ts +430 -46
  60. package/src/cli/notify-cli.ts +204 -94
  61. package/src/commands/notify.ts +19 -8
  62. package/src/config/file-lock.ts +6 -0
  63. package/src/config/model-discovery-manager.ts +50 -8
  64. package/src/config/model-registry.ts +862 -75
  65. package/src/config/resolve-config-value.ts +10 -8
  66. package/src/config/settings-schema.ts +25 -15
  67. package/src/config/settings.ts +15 -15
  68. package/src/cursor.ts +13 -20
  69. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -3
  70. package/src/eval/backend.ts +2 -2
  71. package/src/exec/bash-executor.ts +151 -425
  72. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  73. package/src/gjc-runtime/ultragoal-runtime.ts +3 -280
  74. package/src/gjc-runtime/workflow-manifest.generated.json +1 -40
  75. package/src/gjc-runtime/workflow-manifest.ts +0 -12
  76. package/src/internal-urls/agent-protocol.ts +38 -98
  77. package/src/internal-urls/artifact-protocol.ts +17 -28
  78. package/src/internal-urls/docs-index.generated.ts +7 -7
  79. package/src/internal-urls/registry-helpers.ts +0 -62
  80. package/src/main.ts +43 -7
  81. package/src/modes/DESIGN.md +11 -11
  82. package/src/modes/acp/acp-agent.ts +0 -24
  83. package/src/modes/acp/acp-client-bridge.ts +11 -154
  84. package/src/modes/components/hook-selector.ts +155 -44
  85. package/src/modes/components/model-selector.ts +242 -49
  86. package/src/modes/components/notifications-settings-editor.ts +631 -85
  87. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  88. package/src/modes/controllers/selector-controller.ts +464 -50
  89. package/src/prompts/tools/bash.md +2 -2
  90. package/src/sdk/acp/adapter.ts +9 -200
  91. package/src/sdk/broker/broker.ts +54 -10
  92. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  93. package/src/sdk/broker/lifecycle.ts +47 -50
  94. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  95. package/src/sdk/bus/chat-daemon-control.ts +35 -8
  96. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  97. package/src/sdk/bus/config.ts +534 -286
  98. package/src/sdk/bus/discord-live-provider.ts +57 -2
  99. package/src/sdk/bus/discord-provider.ts +21 -0
  100. package/src/sdk/bus/index.ts +105 -359
  101. package/src/sdk/bus/notification-orchestration.ts +371 -68
  102. package/src/sdk/bus/notification-service.ts +431 -130
  103. package/src/sdk/bus/session-control.ts +119 -44
  104. package/src/sdk/bus/slack-live-provider.ts +78 -2
  105. package/src/sdk/bus/slack-provider.ts +24 -0
  106. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  107. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  108. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  109. package/src/sdk/bus/telegram-daemon.ts +23 -15
  110. package/src/sdk/client/discovery.ts +10 -4
  111. package/src/sdk/host/host.ts +9 -187
  112. package/src/sdk/host/query/handlers.ts +20 -1
  113. package/src/sdk/host/reverse-leases.ts +42 -249
  114. package/src/sdk/index.ts +1 -0
  115. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  116. package/src/sdk/protocol/operation-registry.ts +5 -2
  117. package/src/sdk/providers.ts +51 -0
  118. package/src/sdk/session-directory.ts +1 -0
  119. package/src/sdk/session.ts +10 -7
  120. package/src/session/agent-session.ts +59 -202
  121. package/src/session/artifacts.ts +74 -211
  122. package/src/session/client-bridge.ts +0 -15
  123. package/src/session/internal/managed-session-scope.ts +981 -450
  124. package/src/session/internal/managed-session-storage.ts +362 -124
  125. package/src/session/internal/native-publish-outcome.ts +3 -1
  126. package/src/session/internal/session-open-errors.ts +11 -0
  127. package/src/session/messages.ts +7 -32
  128. package/src/session/session-manager.ts +48 -15
  129. package/src/session/session-storage.ts +72 -5
  130. package/src/session/streaming-output.ts +40 -198
  131. package/src/slash-commands/builtin-registry.ts +82 -4
  132. package/src/task/index.ts +237 -65
  133. package/src/task/receipt.ts +2 -4
  134. package/src/task/render.ts +0 -30
  135. package/src/task/types.ts +10 -34
  136. package/src/tools/ask.ts +1 -1
  137. package/src/tools/bash.ts +239 -818
  138. package/src/tools/eval.ts +4 -99
  139. package/src/tools/fetch.ts +15 -77
  140. package/src/tools/find.ts +3 -7
  141. package/src/tools/output-meta.ts +114 -696
  142. package/src/tools/search.ts +4 -9
  143. package/src/tools/subagent.ts +97 -75
  144. package/src/tools/telegram-send.ts +4 -2
  145. package/src/tools/todo-write.ts +47 -0
  146. package/src/tools/tool-result.ts +0 -1
  147. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +0 -83
  148. package/dist/types/utils/artifact-id.d.ts +0 -2
  149. package/src/gjc-runtime/ultragoal-review-source.ts +0 -378
  150. package/src/utils/artifact-id.ts +0 -6
@@ -4,28 +4,18 @@
4
4
  * Uses brush-core via native bindings for shell execution.
5
5
  */
6
6
  import * as fs from "node:fs/promises";
7
- import {
8
- executeShell,
9
- type MinimizerOptions,
10
- type ShellRunResult as NativeShellRunResult,
11
- Shell,
12
- } from "@gajae-code/natives";
7
+ import { executeShell, type MinimizerOptions, Shell } from "@gajae-code/natives";
13
8
  import { postmortem } from "@gajae-code/utils";
14
9
  import { Settings, type ShellMinimizerSettings } from "../config/settings";
15
10
  import { formatCrashDiagnosticNotice, writeCrashReport } from "../debug/crash-diagnostics";
16
11
  import {
12
+ DEFAULT_ARTIFACT_MAX_BYTES,
17
13
  DEFAULT_MAX_BYTES,
18
14
  OutputSink,
19
- type OutputSummary,
20
15
  type TerminalArtifactPublisher,
21
16
  truncateHeadBytes,
22
17
  } from "../session/streaming-output";
23
- import {
24
- formatArtifactEvidenceNotice,
25
- isValidArtifactId,
26
- resolveOutputMaxColumns,
27
- resolveOutputSinkHeadBytes,
28
- } from "../tools/output-meta";
18
+ import { formatArtifactReference, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
29
19
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
30
20
  import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
31
21
 
@@ -35,19 +25,7 @@ export interface BashArtifactSaveSummary {
35
25
  omittedBytes?: number;
36
26
  }
37
27
 
38
- export type BashMinimizedSaveReturn = BashArtifactSaveResult | BashArtifactSaveSummary | undefined;
39
-
40
- interface ValidatedNativeMinimized {
41
- filter: string;
42
- text: string;
43
- originalText: string;
44
- inputBytes: number;
45
- outputBytes: number;
46
- }
47
-
48
- const MINIMIZED_SAVE_WAIT_MS = 500;
49
- const MINIMIZED_SAVE_PENDING_LIMIT = 64;
50
- const pendingMinimizedSaves = new Set<Promise<unknown>>();
28
+ export type BashMinimizedSaveReturn = BashArtifactSaveResult | BashArtifactSaveSummary | string | undefined;
51
29
 
52
30
  export type BashArtifactSaveResult =
53
31
  | { status: "saved"; artifactId: string; complete: true; omittedBytes?: undefined }
@@ -55,16 +33,18 @@ export type BashArtifactSaveResult =
55
33
  | { status: "unavailable" }
56
34
  | { status: "failed"; diagnostic: string };
57
35
 
58
- function isValidOmittedBytes(omittedBytes: number): boolean {
59
- return Number.isSafeInteger(omittedBytes) && omittedBytes >= 0;
60
- }
61
-
62
- function isRecord(value: unknown): value is Record<string, unknown> {
63
- return typeof value === "object" && value !== null;
64
- }
65
-
66
- function invalidArtifactSaveShape(): BashArtifactSaveResult {
67
- return { status: "failed", diagnostic: "artifact save returned an invalid result" };
36
+ function summarizeLegacyArtifactSave(artifactId: string, originalText: string): BashArtifactSaveResult {
37
+ const inputBytes = Buffer.byteLength(originalText, "utf-8");
38
+ if (inputBytes <= DEFAULT_ARTIFACT_MAX_BYTES) {
39
+ return { status: "saved", artifactId, complete: true };
40
+ }
41
+ const retainedBytes = truncateHeadBytes(originalText, DEFAULT_ARTIFACT_MAX_BYTES).bytes;
42
+ return {
43
+ status: "saved",
44
+ artifactId,
45
+ complete: false,
46
+ omittedBytes: inputBytes - retainedBytes,
47
+ };
68
48
  }
69
49
 
70
50
  function normalizeExplicitSavedArtifact(
@@ -72,12 +52,6 @@ function normalizeExplicitSavedArtifact(
72
52
  complete: boolean,
73
53
  omittedBytes: number | undefined,
74
54
  ): BashArtifactSaveResult {
75
- if (!isValidArtifactId(artifactId)) {
76
- return { status: "failed", diagnostic: "artifact save reported an invalid artifact id" };
77
- }
78
- if (omittedBytes !== undefined && !isValidOmittedBytes(omittedBytes)) {
79
- return { status: "failed", diagnostic: "artifact save reported invalid omitted bytes" };
80
- }
81
55
  if (complete) {
82
56
  return (omittedBytes ?? 0) > 0
83
57
  ? { status: "failed", diagnostic: "artifact save reported complete output with omitted bytes" }
@@ -88,143 +62,35 @@ function normalizeExplicitSavedArtifact(
88
62
  : { status: "failed", diagnostic: "artifact save reported incomplete output without omitted bytes" };
89
63
  }
90
64
 
91
- function normalizeMinimizedSaveResult(value: unknown): BashArtifactSaveResult {
92
- if (value === undefined) return { status: "unavailable" };
93
- if (typeof value === "string") return invalidArtifactSaveShape();
94
- if (!isRecord(value)) return invalidArtifactSaveShape();
95
-
96
- const status = value.status;
97
- if (status === "unavailable") return { status: "unavailable" };
98
- if (status === "failed") {
99
- const diagnostic = value.diagnostic;
100
- return typeof diagnostic === "string" && diagnostic.trim().length > 0
101
- ? { status: "failed", diagnostic: diagnostic.slice(0, 512) }
102
- : invalidArtifactSaveShape();
103
- }
104
- if (status !== undefined && status !== "saved") return invalidArtifactSaveShape();
105
- if (typeof value.artifactId !== "string" || typeof value.complete !== "boolean") {
106
- return invalidArtifactSaveShape();
107
- }
108
- if (value.omittedBytes !== undefined && typeof value.omittedBytes !== "number") {
109
- return invalidArtifactSaveShape();
65
+ function normalizeMinimizedSaveResult(value: BashMinimizedSaveReturn, originalText: string): BashArtifactSaveResult {
66
+ if (typeof value === "string") return summarizeLegacyArtifactSave(value, originalText);
67
+ if (!value) return { status: "unavailable" };
68
+ if (!("status" in value)) {
69
+ return normalizeExplicitSavedArtifact(value.artifactId, value.complete, value.omittedBytes);
110
70
  }
71
+ if (value.status !== "saved") return value;
111
72
  return normalizeExplicitSavedArtifact(value.artifactId, value.complete, value.omittedBytes);
112
73
  }
113
74
 
114
- export function normalizeMinimizedSaveResultForTests(value: unknown, _originalText?: string): BashArtifactSaveResult {
115
- return normalizeMinimizedSaveResult(value);
116
- }
117
-
118
- function normalizeNativeMinimized(value: unknown): { minimized?: ValidatedNativeMinimized; malformed: boolean } {
119
- if (value === undefined) return { malformed: false };
120
- if (!isRecord(value)) return { malformed: true };
121
- const { filter, text, originalText, inputBytes, outputBytes } = value;
122
- if (
123
- typeof filter !== "string" ||
124
- typeof text !== "string" ||
125
- typeof originalText !== "string" ||
126
- !Number.isSafeInteger(inputBytes) ||
127
- !Number.isSafeInteger(outputBytes) ||
128
- (inputBytes as number) < 0 ||
129
- (outputBytes as number) < 0 ||
130
- Buffer.byteLength(originalText, "utf-8") !== inputBytes ||
131
- Buffer.byteLength(text, "utf-8") !== outputBytes
132
- ) {
133
- return { malformed: true };
134
- }
135
- return {
136
- minimized: { filter, text, originalText, inputBytes: inputBytes as number, outputBytes: outputBytes as number },
137
- malformed: false,
138
- };
139
- }
140
-
141
- function nativeCaptureStatusIncomplete(result: NativeShellRunResult | undefined): boolean {
142
- if (!result) return true;
143
- const capture = result.outputCaptureIncomplete as unknown;
144
- const saturated = result.outputLossCountSaturated as unknown;
145
- return (
146
- (capture !== undefined && typeof capture !== "boolean") ||
147
- (saturated !== undefined && typeof saturated !== "boolean") ||
148
- capture === true ||
149
- saturated === true
150
- );
151
- }
152
-
153
- async function saveMinimizedArtifactBounded(
154
- operation: () => Promise<BashMinimizedSaveReturn>,
155
- ): Promise<BashArtifactSaveResult> {
156
- if (pendingMinimizedSaves.size >= MINIMIZED_SAVE_PENDING_LIMIT) {
157
- return { status: "failed", diagnostic: "pending minimized artifact save limit reached" };
158
- }
159
- const promise = Promise.resolve().then(operation);
160
- pendingMinimizedSaves.add(promise);
161
- void promise.then(
162
- () => pendingMinimizedSaves.delete(promise),
163
- () => pendingMinimizedSaves.delete(promise),
164
- );
165
- const outcome = await Promise.race([
166
- promise.then(
167
- value => ({ status: "ok" as const, value }),
168
- error => ({ status: "failed" as const, diagnostic: error instanceof Error ? error.message : String(error) }),
169
- ),
170
- Bun.sleep(MINIMIZED_SAVE_WAIT_MS).then(() => ({
171
- status: "failed" as const,
172
- diagnostic: `did not settle within ${MINIMIZED_SAVE_WAIT_MS}ms`,
173
- })),
174
- ]);
175
- if (outcome.status === "failed") {
176
- return {
177
- status: "failed",
178
- diagnostic: truncateHeadBytes(outcome.diagnostic.replace(/\s+/gu, " ").trim(), 512).text,
179
- };
180
- }
181
- return normalizeMinimizedSaveResult(outcome.value);
75
+ export function normalizeMinimizedSaveResultForTests(
76
+ value: BashMinimizedSaveReturn,
77
+ originalText: string,
78
+ ): BashArtifactSaveResult {
79
+ return normalizeMinimizedSaveResult(value, originalText);
182
80
  }
183
81
 
184
82
  function completeRawArtifactAvailable(summary: {
185
83
  artifactId?: string;
186
84
  artifactTruncatedBytes?: number;
187
- sourceTruncatedBytes?: number;
188
- sourceCaptureIncomplete?: boolean;
189
85
  artifactFailureDiagnostic?: string;
190
86
  }): boolean {
191
87
  return (
192
88
  summary.artifactId !== undefined &&
193
89
  (summary.artifactTruncatedBytes ?? 0) <= 0 &&
194
- (summary.sourceTruncatedBytes ?? 0) <= 0 &&
195
- !summary.sourceCaptureIncomplete &&
196
90
  summary.artifactFailureDiagnostic === undefined
197
91
  );
198
92
  }
199
93
 
200
- function applyShellCallbackLoss(
201
- summary: OutputSummary,
202
- droppedOutputBytes: number | undefined,
203
- droppedOutputChunks: number | undefined,
204
- sourceCaptureIncomplete = false,
205
- ): OutputSummary {
206
- const bytesValid =
207
- droppedOutputBytes === undefined || (Number.isSafeInteger(droppedOutputBytes) && droppedOutputBytes >= 0);
208
- const chunksValid =
209
- droppedOutputChunks === undefined || (Number.isSafeInteger(droppedOutputChunks) && droppedOutputChunks >= 0);
210
- const counterPairValid = (droppedOutputBytes === undefined) === (droppedOutputChunks === undefined);
211
- const knownDroppedBytes = bytesValid && droppedOutputBytes !== undefined && droppedOutputBytes > 0;
212
- const knownDroppedChunks = chunksValid && droppedOutputChunks !== undefined && droppedOutputChunks > 0;
213
- const captureIncomplete =
214
- sourceCaptureIncomplete ||
215
- !bytesValid ||
216
- !chunksValid ||
217
- !counterPairValid ||
218
- knownDroppedBytes !== knownDroppedChunks;
219
- if (!knownDroppedBytes && !captureIncomplete) return summary;
220
- return {
221
- ...summary,
222
- truncated: true,
223
- ...(knownDroppedBytes ? { sourceTruncatedBytes: droppedOutputBytes } : {}),
224
- ...(captureIncomplete ? { sourceCaptureIncomplete: true } : {}),
225
- };
226
- }
227
-
228
94
  function appendModelNotice(output: string, notice: string): string {
229
95
  const separator = output.length > 0 && !output.endsWith("\n") ? "\n" : "";
230
96
  return `${output}${separator}${notice}\n`;
@@ -232,13 +98,7 @@ function appendModelNotice(output: string, notice: string): string {
232
98
 
233
99
  function minimizedSaveNotice(
234
100
  result: BashArtifactSaveResult,
235
- summary: {
236
- artifactId?: string;
237
- artifactTruncatedBytes?: number;
238
- sourceTruncatedBytes?: number;
239
- sourceCaptureIncomplete?: boolean;
240
- artifactFailureDiagnostic?: string;
241
- },
101
+ summary: { artifactId?: string; artifactTruncatedBytes?: number; artifactFailureDiagnostic?: string },
242
102
  ): string | undefined {
243
103
  if (result.status === "failed") return `Bash output artifact save failed: ${result.diagnostic}`;
244
104
  if (result.status === "unavailable" && !completeRawArtifactAvailable(summary)) {
@@ -247,6 +107,13 @@ function minimizedSaveNotice(
247
107
  return undefined;
248
108
  }
249
109
 
110
+ function minimizedArtifactFooter(result: Extract<BashArtifactSaveResult, { status: "saved" }>): string {
111
+ const reference = result.complete
112
+ ? `artifact://${result.artifactId}`
113
+ : formatArtifactReference(result.artifactId, result.omittedBytes);
114
+ return `[raw output: ${reference}]`;
115
+ }
116
+
250
117
  export interface BashExecutorOptions {
251
118
  /**
252
119
  * Invoked when the native minimizer rewrote the command's output, giving
@@ -303,122 +170,40 @@ export interface BashResult {
303
170
  outputBytes: number;
304
171
  artifactId?: string;
305
172
  artifactTruncatedBytes?: number;
306
- /** Bytes dropped before the Bash executor received the native output stream. */
307
- sourceTruncatedBytes?: number;
308
- /** Exact source capture completeness could not be proven. */
309
- sourceCaptureIncomplete?: boolean;
310
173
  artifactFailureDiagnostic?: string;
311
174
  }
312
175
 
313
176
  const shellSessions = new Map<string, Shell>();
314
- const brokenShellSessions = new Map<string, Shell>();
177
+ const brokenShellSessions = new Set<string>();
315
178
  const retiringShellSessions = new Set<Shell>();
316
- const shellInFlightRuns = new Map<Shell, number>();
317
- const shellSessionKeys = new Map<Shell, string>();
318
- const MAX_OWNED_SHELL_SESSIONS = 64;
319
- let shellSessionAdmissionTail = Promise.resolve();
320
-
321
- async function withShellSessionAdmission<T>(operation: () => Promise<T>): Promise<T> {
322
- const previous = shellSessionAdmissionTail;
323
- const release = Promise.withResolvers<void>();
324
- shellSessionAdmissionTail = previous.then(() => release.promise);
325
- await previous;
326
- try {
327
- return await operation();
328
- } finally {
329
- release.resolve();
330
- }
331
- }
332
-
333
- function touchShellSession(sessionKey: string, shellSession: Shell): void {
334
- if (shellSessions.get(sessionKey) !== shellSession) return;
335
- shellSessions.delete(sessionKey);
336
- shellSessions.set(sessionKey, shellSession);
337
- }
338
-
339
- async function evictLeastRecentlyUsedIdleShell(): Promise<boolean> {
340
- for (const [sessionKey, shellSession] of shellSessions) {
341
- if ((shellInFlightRuns.get(shellSession) ?? 0) > 0) continue;
342
- quarantineShellSession(sessionKey, shellSession);
343
- const abortPromise = Promise.resolve()
344
- .then(() => shellSession.abort())
345
- .catch(() => undefined);
346
- void abortPromise.finally(() => releaseQuarantinedShellIfIdle(sessionKey, shellSession));
347
- return Promise.race([abortPromise.then(() => true), Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => false)]);
348
- }
349
- return false;
350
- }
351
-
352
- function beginShellRun(shellSession: Shell): void {
353
- shellInFlightRuns.set(shellSession, (shellInFlightRuns.get(shellSession) ?? 0) + 1);
354
- }
355
-
356
- function releaseQuarantinedShellIfIdle(sessionKey: string, shellSession: Shell): void {
357
- if ((shellInFlightRuns.get(shellSession) ?? 0) > 0) return;
358
- clearBrokenShellSession(sessionKey, shellSession);
359
- retiringShellSessions.delete(shellSession);
360
- shellSessionKeys.delete(shellSession);
361
- }
362
-
363
- function endShellRun(sessionKey: string, shellSession: Shell): void {
364
- const remaining = Math.max(0, (shellInFlightRuns.get(shellSession) ?? 1) - 1);
365
- if (remaining > 0) {
366
- shellInFlightRuns.set(shellSession, remaining);
367
- return;
368
- }
369
- const quarantined = brokenShellSessions.get(sessionKey) === shellSession;
370
- shellInFlightRuns.delete(shellSession);
371
- if (quarantined) releaseQuarantinedShellIfIdle(sessionKey, shellSession);
372
- }
373
-
374
- function quarantineShellSession(sessionKey: string, shellSession: Shell): void {
375
- retiringShellSessions.add(shellSession);
376
- brokenShellSessions.set(sessionKey, shellSession);
377
- shellSessionKeys.set(shellSession, sessionKey);
378
- if (shellSessions.get(sessionKey) === shellSession) shellSessions.delete(sessionKey);
379
- }
380
- // Give ordinary native cancellation time to settle without turning a stalled
381
- // cleanup into a multi-second JavaScript tool delay.
179
+ // Cover pi-shell's normal cancellation kill waves without turning a stalled
180
+ // native cleanup into a multi-second JavaScript tool stall.
382
181
  const CANCEL_CLEANUP_WAIT_MS = 400;
383
- function clearBrokenShellSession(sessionKey: string, shellSession: Shell): void {
384
- if (brokenShellSessions.get(sessionKey) === shellSession) brokenShellSessions.delete(sessionKey);
385
- }
386
-
387
- interface AbortCleanupOutcome {
388
- settled: boolean;
389
- result?: NativeShellRunResult;
390
- }
391
182
 
392
- /** Number of persistent and retiring shell sessions currently retained (owner gauge). */
183
+ /** Number of persistent shell sessions currently retained (owner gauge). */
393
184
  export function getShellSessionCount(): number {
394
- return new Set([...shellSessions.values(), ...retiringShellSessions, ...shellSessionKeys.keys()]).size;
185
+ return shellSessions.size;
395
186
  }
396
187
 
397
188
  /**
398
- * Dispose all persistent shell sessions without waiting forever on a native
399
- * abort that failed to settle. Quarantine entries are owner-scoped so a late
400
- * finalizer cannot erase a successor shell's quarantine.
189
+ * Dispose all persistent shell sessions: abort in-flight work and drop the
190
+ * strong references so the native shells can be finalized. Healthy persistent
191
+ * sessions are otherwise retained for the whole process lifetime (MEM-7). This
192
+ * is registered as a postmortem cleanup so shutdown/signals release native
193
+ * shell resources, and is also callable directly (e.g. on owner teardown).
401
194
  */
402
195
  export async function disposeAllShellSessions(): Promise<void> {
196
+ // Snapshot and drop strong references up front so concurrent callers cannot
197
+ // reuse a session that is being torn down, then await every native abort so
198
+ // shutdown/signal cleanup does not return before resources are released.
199
+ // Include retiring shells whose JS call returned after bounded abort cleanup
200
+ // while the native run is still unwinding; they are no longer reusable but
201
+ // remain owned until their run promise settles.
403
202
  const sessions = new Set([...shellSessions.values(), ...retiringShellSessions]);
404
- for (const session of sessions) {
405
- const sessionKey = shellSessionKeys.get(session);
406
- if (sessionKey) quarantineShellSession(sessionKey, session);
407
- else retiringShellSessions.add(session);
408
- }
409
203
  shellSessions.clear();
410
- await Promise.all(
411
- [...sessions].map(async session => {
412
- const sessionKey = shellSessionKeys.get(session);
413
- const abortPromise = Promise.resolve()
414
- .then(() => session.abort())
415
- .catch(() => undefined);
416
- if (sessionKey) {
417
- void abortPromise.finally(() => releaseQuarantinedShellIfIdle(sessionKey, session));
418
- }
419
- await Promise.race([abortPromise, Bun.sleep(CANCEL_CLEANUP_WAIT_MS)]);
420
- }),
421
- );
204
+ retiringShellSessions.clear();
205
+ brokenShellSessions.clear();
206
+ await Promise.allSettled([...sessions].map(session => session.abort()));
422
207
  }
423
208
 
424
209
  postmortem.register("bash-executor:shell-sessions", () => disposeAllShellSessions());
@@ -448,9 +233,6 @@ export function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerO
448
233
  }
449
234
 
450
235
  export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
451
- if (options?.artifactId !== undefined && !isValidArtifactId(options.artifactId)) {
452
- throw new Error("Invalid Bash artifact id");
453
- }
454
236
  const settings = await Settings.init();
455
237
  const { shell, env: shellEnv, prefix } = settings.getShellConfig();
456
238
  const configuredPrefix = options?.ignoreShellPrefix ? undefined : prefix;
@@ -497,71 +279,51 @@ export async function executeBash(command: string, options?: BashExecutorOptions
497
279
  };
498
280
  }
499
281
 
282
+ const usePersistentShell = options?.oneShot !== true;
283
+ const sessionKey = buildSessionKey(shell, configuredPrefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
284
+ const persistentSessionBroken = usePersistentShell && brokenShellSessions.has(sessionKey);
285
+
286
+ let shellSession = persistentSessionBroken || !usePersistentShell ? undefined : shellSessions.get(sessionKey);
287
+ if (!shellSession && !persistentSessionBroken && usePersistentShell) {
288
+ shellSession = new Shell({
289
+ sessionEnv: shellEnv,
290
+ snapshotPath: snapshotPath ?? undefined,
291
+ minimizer,
292
+ });
293
+ shellSessions.set(sessionKey, shellSession);
294
+ }
500
295
  const userSignal = options?.signal;
501
296
  const runAbortController = new AbortController();
502
297
  const abortCurrentExecution = () => {
503
- if (!runAbortController.signal.aborted) runAbortController.abort();
298
+ if (!runAbortController.signal.aborted) {
299
+ runAbortController.abort();
300
+ }
301
+ if (shellSession && !abortPromise) {
302
+ abortPromise = shellSession.abort();
303
+ }
504
304
  };
505
305
  const abortDeferred = Promise.withResolvers<"abort">();
306
+ let abortPromise: Promise<unknown> | undefined;
506
307
  const abortHandler = () => {
507
308
  abortCurrentExecution();
508
309
  abortDeferred.resolve("abort");
509
310
  };
510
- userSignal?.addEventListener("abort", abortHandler, { once: true });
511
- if (userSignal?.aborted) abortHandler();
512
-
513
- const usePersistentShell = options?.oneShot !== true;
514
- const sessionKey = buildSessionKey(shell, configuredPrefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
515
- let shellSession: Shell | undefined;
516
- try {
517
- shellSession = usePersistentShell
518
- ? await withShellSessionAdmission(async () => {
519
- if (brokenShellSessions.has(sessionKey)) return undefined;
520
- const existing = shellSessions.get(sessionKey);
521
- if (existing) {
522
- touchShellSession(sessionKey, existing);
523
- beginShellRun(existing);
524
- return existing;
525
- }
526
- if (getShellSessionCount() >= MAX_OWNED_SHELL_SESSIONS) {
527
- const evicted = await evictLeastRecentlyUsedIdleShell();
528
- if (!evicted || getShellSessionCount() >= MAX_OWNED_SHELL_SESSIONS) {
529
- throw new Error(`Bash shell session limit reached (${MAX_OWNED_SHELL_SESSIONS})`);
530
- }
531
- }
532
- const created = new Shell({
533
- sessionEnv: shellEnv,
534
- snapshotPath: snapshotPath ?? undefined,
535
- minimizer,
536
- });
537
- shellSessions.set(sessionKey, created);
538
- shellSessionKeys.set(created, sessionKey);
539
- beginShellRun(created);
540
- return created;
541
- })
542
- : undefined;
543
- } catch (error) {
544
- userSignal?.removeEventListener("abort", abortHandler);
545
- throw error;
546
- }
547
- if (userSignal?.aborted) {
548
- if (shellSession) endShellRun(sessionKey, shellSession);
549
- userSignal.removeEventListener("abort", abortHandler);
550
- return {
551
- exitCode: undefined,
552
- cancelled: true,
553
- ...(await sink.dump("Command cancelled")),
554
- };
555
- }
556
-
557
- const awaitAbortCleanup = (runPromise: Promise<NativeShellRunResult>): Promise<AbortCleanupOutcome> =>
558
- Promise.race([
311
+ const awaitAbortCleanup = async (runPromise: Promise<unknown>): Promise<boolean> => {
312
+ const settled = await Promise.race([
559
313
  runPromise.then(
560
- result => ({ settled: true, result }) satisfies AbortCleanupOutcome,
561
- () => ({ settled: true }) satisfies AbortCleanupOutcome,
314
+ () => true,
315
+ () => true,
562
316
  ),
563
- Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => ({ settled: false }) satisfies AbortCleanupOutcome),
317
+ Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => false),
564
318
  ]);
319
+ if (abortPromise) {
320
+ await Promise.race([abortPromise.catch(() => undefined), Bun.sleep(CANCEL_CLEANUP_WAIT_MS)]);
321
+ }
322
+ return settled;
323
+ };
324
+ if (userSignal) {
325
+ userSignal.addEventListener("abort", abortHandler, { once: true });
326
+ }
565
327
 
566
328
  let timeoutTimer: NodeJS.Timeout | undefined;
567
329
  const timeoutDeferred = Promise.withResolvers<"timeout">();
@@ -575,12 +337,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions
575
337
  }
576
338
 
577
339
  let resetSession = false;
340
+ let runSettled = false;
578
341
 
579
342
  try {
580
- let runPromise: Promise<NativeShellRunResult>;
581
- if (shellSession) {
582
- try {
583
- runPromise = shellSession.run(
343
+ const runPromise = shellSession
344
+ ? shellSession.run(
584
345
  {
585
346
  command: finalCommand,
586
347
  cwd: commandCwd,
@@ -589,31 +350,28 @@ export async function executeBash(command: string, options?: BashExecutorOptions
589
350
  signal: runAbortController.signal,
590
351
  },
591
352
  (err, chunk) => {
592
- if (!err) enqueueChunk(chunk);
353
+ if (!err) {
354
+ enqueueChunk(chunk);
355
+ }
356
+ },
357
+ )
358
+ : executeShell(
359
+ {
360
+ command: finalCommand,
361
+ cwd: commandCwd,
362
+ env: commandEnv,
363
+ sessionEnv: shellEnv,
364
+ snapshotPath: snapshotPath ?? undefined,
365
+ minimizer,
366
+ timeoutMs: executionTimeoutMs,
367
+ signal: runAbortController.signal,
368
+ },
369
+ (err, chunk) => {
370
+ if (!err) {
371
+ enqueueChunk(chunk);
372
+ }
593
373
  },
594
374
  );
595
- } catch (error) {
596
- endShellRun(sessionKey, shellSession);
597
- throw error;
598
- }
599
- void runPromise.finally(() => endShellRun(sessionKey, shellSession)).catch(() => undefined);
600
- } else {
601
- runPromise = executeShell(
602
- {
603
- command: finalCommand,
604
- cwd: commandCwd,
605
- env: commandEnv,
606
- sessionEnv: shellEnv,
607
- snapshotPath: snapshotPath ?? undefined,
608
- minimizer,
609
- timeoutMs: executionTimeoutMs,
610
- signal: runAbortController.signal,
611
- },
612
- (err, chunk) => {
613
- if (!err) enqueueChunk(chunk);
614
- },
615
- );
616
- }
617
375
 
618
376
  const winner = await Promise.race([
619
377
  runPromise.then(result => ({ kind: "result" as const, result })),
@@ -622,37 +380,44 @@ export async function executeBash(command: string, options?: BashExecutorOptions
622
380
  ]);
623
381
 
624
382
  if (winner.kind === "timeout" || winner.kind === "abort") {
625
- const abortOutcome = await awaitAbortCleanup(runPromise);
626
383
  acceptingChunks = false;
627
384
  if (shellSession) {
628
385
  resetSession = true;
629
- quarantineShellSession(sessionKey, shellSession);
630
- releaseQuarantinedShellIfIdle(sessionKey, shellSession);
631
- } else if (!abortOutcome.settled) {
386
+ retiringShellSessions.add(shellSession);
387
+ brokenShellSessions.add(sessionKey);
388
+ shellSessions.delete(sessionKey);
389
+ runSettled = await awaitAbortCleanup(runPromise);
390
+ if (runSettled) {
391
+ brokenShellSessions.delete(sessionKey);
392
+ retiringShellSessions.delete(shellSession);
393
+ } else {
394
+ void runPromise
395
+ .finally(() => {
396
+ brokenShellSessions.delete(sessionKey);
397
+ retiringShellSessions.delete(shellSession);
398
+ if (shellSessions.get(sessionKey) === shellSession) {
399
+ shellSessions.delete(sessionKey);
400
+ }
401
+ })
402
+ .catch(() => undefined);
403
+ }
404
+ } else {
632
405
  void runPromise.catch(() => undefined);
633
406
  }
634
- const summary = applyShellCallbackLoss(
635
- await sink.dump(
636
- winner.kind === "timeout" && baseTimeoutMs !== undefined
637
- ? `Command timed out after ${Math.round(baseTimeoutMs / 1000)} seconds`
638
- : "Command cancelled",
639
- ),
640
- abortOutcome.result?.droppedOutputBytes,
641
- abortOutcome.result?.droppedOutputChunks,
642
- nativeCaptureStatusIncomplete(abortOutcome.result),
643
- );
644
407
  return {
645
408
  exitCode: undefined,
646
409
  cancelled: true,
647
- ...summary,
410
+ ...(await sink.dump(
411
+ winner.kind === "timeout" && baseTimeoutMs !== undefined
412
+ ? `Command timed out after ${Math.round(baseTimeoutMs / 1000)} seconds`
413
+ : "Command cancelled",
414
+ )),
648
415
  };
649
416
  }
650
417
  if (timeoutTimer) {
651
418
  clearTimeout(timeoutTimer);
652
419
  timeoutTimer = undefined;
653
420
  }
654
- const minimizedEvidence = normalizeNativeMinimized(winner.result.minimized);
655
- const nativeEvidenceIncomplete = nativeCaptureStatusIncomplete(winner.result) || minimizedEvidence.malformed;
656
421
 
657
422
  // Handle timeout
658
423
  if (winner.result.timedOut) {
@@ -663,12 +428,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
663
428
  return {
664
429
  exitCode: undefined,
665
430
  cancelled: true,
666
- ...applyShellCallbackLoss(
667
- await sink.dump(annotation),
668
- winner.result.droppedOutputBytes,
669
- winner.result.droppedOutputChunks,
670
- nativeEvidenceIncomplete,
671
- ),
431
+ ...(await sink.dump(annotation)),
672
432
  };
673
433
  }
674
434
 
@@ -678,12 +438,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
678
438
  return {
679
439
  exitCode: undefined,
680
440
  cancelled: true,
681
- ...applyShellCallbackLoss(
682
- await sink.dump("Command cancelled"),
683
- winner.result.droppedOutputBytes,
684
- winner.result.droppedOutputChunks,
685
- nativeEvidenceIncomplete,
686
- ),
441
+ ...(await sink.dump("Command cancelled")),
687
442
  };
688
443
  }
689
444
 
@@ -691,24 +446,21 @@ export async function executeBash(command: string, options?: BashExecutorOptions
691
446
  // raw stream for the minimized text, persist the original as a session
692
447
  // artifact, and splice an artifact footer into the visible text so the agent
693
448
  // can retrieve retained raw bytes without a false completeness claim.
694
- const minimized = minimizedEvidence.minimized;
449
+ const minimized = winner.result.minimized;
695
450
  let minimizedSaveResult: BashArtifactSaveResult | undefined;
696
451
  if (minimized && minimized.text !== minimized.originalText) {
697
452
  sink.replace(minimized.text);
698
- minimizedSaveResult = options?.onMinimizedSave
699
- ? await saveMinimizedArtifactBounded(() =>
700
- options.onMinimizedSave!(minimized.originalText, {
701
- filter: minimized.filter,
702
- inputBytes: minimized.inputBytes,
703
- outputBytes: minimized.outputBytes,
704
- }),
705
- )
706
- : { status: "unavailable" };
453
+ const saved = options?.onMinimizedSave
454
+ ? await options.onMinimizedSave(minimized.originalText, {
455
+ filter: minimized.filter,
456
+ inputBytes: minimized.inputBytes,
457
+ outputBytes: minimized.outputBytes,
458
+ })
459
+ : undefined;
460
+ minimizedSaveResult = normalizeMinimizedSaveResult(saved, minimized.originalText);
707
461
  if (minimizedSaveResult.status === "saved") {
708
- minimizedSaveResult = {
709
- status: "failed",
710
- diagnostic: "artifact save could not be verified in the current session",
711
- };
462
+ const sep = minimized.text.endsWith("\n") ? "" : "\n";
463
+ sink.push(`${sep}${minimizedArtifactFooter(minimizedSaveResult)}\n`);
712
464
  }
713
465
  }
714
466
 
@@ -728,12 +480,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
728
480
  }
729
481
 
730
482
  // Normal completion
731
- const summary = applyShellCallbackLoss(
732
- await sink.dump(),
733
- winner.result.droppedOutputBytes,
734
- winner.result.droppedOutputChunks,
735
- nativeEvidenceIncomplete,
736
- );
483
+ const summary = await sink.dump();
737
484
  const saveNotice = minimizedSaveResult ? minimizedSaveNotice(minimizedSaveResult, summary) : undefined;
738
485
  return {
739
486
  exitCode: winner.result.exitCode,
@@ -743,23 +490,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
743
490
  };
744
491
  } catch (err) {
745
492
  resetSession = true;
746
- acceptingChunks = false;
747
- const captured = await sink.dump();
748
- const rawErrorMessage = err instanceof Error ? err.message : String(err);
749
- const boundedErrorMessage = truncateHeadBytes(rawErrorMessage, 1024);
750
- const errorMessage = `${boundedErrorMessage.text}${
751
- boundedErrorMessage.bytes < Buffer.byteLength(rawErrorMessage, "utf-8") ? "\n[native error truncated]" : ""
752
- }`;
753
- if (captured.totalBytes <= 0) {
754
- throw new Error(
755
- `Source capture completeness could not be proven before any output was retained\n\nNative shell execution failed: ${errorMessage}`,
756
- );
757
- }
758
- const summary = applyShellCallbackLoss(captured, undefined, undefined, true);
759
- const evidence = formatArtifactEvidenceNotice(summary);
760
- const suffix = [evidence, `Native shell execution failed: ${errorMessage}`].filter(Boolean).join("\n\n");
761
- const separator = summary.output.length > 0 ? (summary.output.endsWith("\n") ? "\n" : "\n\n") : "";
762
- throw new Error(`${summary.output}${separator}${suffix}`);
493
+ throw err;
763
494
  } finally {
764
495
  if (timeoutTimer) {
765
496
  clearTimeout(timeoutTimer);
@@ -767,13 +498,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
767
498
  if (userSignal) {
768
499
  userSignal.removeEventListener("abort", abortHandler);
769
500
  }
770
- if (resetSession && shellSession) {
771
- if (shellSessions.get(sessionKey) === shellSession) shellSessions.delete(sessionKey);
772
- if ((shellInFlightRuns.get(shellSession) ?? 0) > 0) {
773
- quarantineShellSession(sessionKey, shellSession);
774
- } else {
775
- releaseQuarantinedShellIfIdle(sessionKey, shellSession);
776
- }
501
+ if (resetSession && runSettled && shellSessions.get(sessionKey) === shellSession) {
502
+ shellSessions.delete(sessionKey);
777
503
  }
778
504
  }
779
505
  }