@gajae-code/coding-agent 0.12.4 → 0.12.6

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 (139) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +9 -1
  4. package/dist/types/eval/backend.d.ts +2 -2
  5. package/dist/types/exec/bash-executor.d.ts +10 -8
  6. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  7. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  8. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  9. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  10. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  11. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  12. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  15. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  16. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  17. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  19. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  20. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  21. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  23. package/dist/types/sdk/broker/broker.d.ts +18 -0
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  25. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  26. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -2
  27. package/dist/types/sdk/bus/index.d.ts +4 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -3
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  30. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  31. package/dist/types/sdk/host/host.d.ts +23 -2
  32. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  33. package/dist/types/session/agent-session.d.ts +5 -4
  34. package/dist/types/session/artifacts.d.ts +8 -5
  35. package/dist/types/session/attempt-record-store.d.ts +19 -0
  36. package/dist/types/session/client-bridge.d.ts +14 -0
  37. package/dist/types/session/internal/managed-session-storage.d.ts +7 -23
  38. package/dist/types/session/messages.d.ts +4 -1
  39. package/dist/types/session/session-manager.d.ts +7 -5
  40. package/dist/types/session/session-storage.d.ts +8 -0
  41. package/dist/types/session/streaming-output.d.ts +32 -0
  42. package/dist/types/task/executor.d.ts +1 -1
  43. package/dist/types/task/index.d.ts +2 -1
  44. package/dist/types/task/receipt.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +194 -0
  46. package/dist/types/task/worktree.d.ts +13 -6
  47. package/dist/types/tools/bash.d.ts +1 -1
  48. package/dist/types/tools/fetch.d.ts +3 -0
  49. package/dist/types/tools/output-meta.d.ts +37 -5
  50. package/dist/types/tools/subagent.d.ts +2 -0
  51. package/dist/types/tools/tool-result.d.ts +1 -0
  52. package/dist/types/utils/artifact-id.d.ts +2 -0
  53. package/dist/types/utils/git.d.ts +1 -0
  54. package/package.json +8 -8
  55. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  56. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  57. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  58. package/src/async/job-manager.ts +13 -5
  59. package/src/config/model-profiles.ts +8 -0
  60. package/src/config/settings-schema.ts +17 -3
  61. package/src/cursor.ts +20 -13
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  63. package/src/eval/backend.ts +2 -2
  64. package/src/exec/bash-executor.ts +425 -151
  65. package/src/extensibility/extensions/runner.ts +80 -6
  66. package/src/extensibility/extensions/wrapper.ts +22 -15
  67. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  68. package/src/gjc-runtime/launch-tmux.ts +309 -293
  69. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  70. package/src/gjc-runtime/psmux-detect.ts +52 -6
  71. package/src/gjc-runtime/team-launch.ts +50 -2
  72. package/src/gjc-runtime/team-runtime.ts +716 -251
  73. package/src/gjc-runtime/team-store.ts +25 -7
  74. package/src/gjc-runtime/team-workers.ts +161 -1
  75. package/src/gjc-runtime/tmux-common.ts +16 -4
  76. package/src/gjc-runtime/tmux-gc.ts +37 -5
  77. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  78. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  79. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  80. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  82. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  83. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  84. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  85. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  86. package/src/internal-urls/agent-protocol.ts +98 -38
  87. package/src/internal-urls/artifact-protocol.ts +28 -17
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/internal-urls/local-protocol.ts +39 -18
  90. package/src/internal-urls/registry-helpers.ts +62 -0
  91. package/src/modes/acp/acp-agent.ts +51 -13
  92. package/src/modes/acp/acp-client-bridge.ts +154 -11
  93. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  94. package/src/modes/components/plugin-settings.ts +5 -0
  95. package/src/modes/components/settings-selector.ts +4 -0
  96. package/src/modes/controllers/selector-controller.ts +1 -0
  97. package/src/prompts/tools/bash.md +2 -2
  98. package/src/prompts/tools/monitor.md +4 -4
  99. package/src/sdk/acp/adapter.ts +200 -9
  100. package/src/sdk/broker/broker.ts +93 -10
  101. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  102. package/src/sdk/broker/lifecycle.ts +692 -98
  103. package/src/sdk/bus/chat-daemon-control.ts +33 -5
  104. package/src/sdk/bus/index.ts +347 -14
  105. package/src/sdk/bus/telegram-daemon-contract.ts +7 -3
  106. package/src/sdk/bus/telegram-daemon.ts +283 -44
  107. package/src/sdk/bus/topic-registry.ts +133 -10
  108. package/src/sdk/host/host.ts +187 -9
  109. package/src/sdk/host/reverse-leases.ts +249 -42
  110. package/src/sdk/session.ts +8 -6
  111. package/src/session/agent-session.ts +930 -354
  112. package/src/session/artifacts.ts +211 -74
  113. package/src/session/attempt-record-store.ts +106 -0
  114. package/src/session/client-bridge.ts +15 -0
  115. package/src/session/internal/managed-session-scope.ts +332 -198
  116. package/src/session/internal/managed-session-storage.ts +162 -450
  117. package/src/session/messages.ts +32 -7
  118. package/src/session/session-manager.ts +31 -28
  119. package/src/session/session-storage.ts +190 -53
  120. package/src/session/streaming-output.ts +198 -40
  121. package/src/task/executor.ts +5 -2
  122. package/src/task/index.ts +277 -64
  123. package/src/task/provider-retry-status.ts +4 -1
  124. package/src/task/receipt.ts +26 -2
  125. package/src/task/render.ts +42 -1
  126. package/src/task/types.ts +52 -0
  127. package/src/task/worktree.ts +122 -43
  128. package/src/tools/bash.ts +818 -239
  129. package/src/tools/eval.ts +99 -4
  130. package/src/tools/fetch.ts +77 -15
  131. package/src/tools/find.ts +7 -3
  132. package/src/tools/monitor.ts +7 -1
  133. package/src/tools/output-meta.ts +706 -116
  134. package/src/tools/search.ts +9 -4
  135. package/src/tools/subagent-render.ts +5 -2
  136. package/src/tools/subagent.ts +8 -0
  137. package/src/tools/tool-result.ts +1 -0
  138. package/src/utils/artifact-id.ts +6 -0
  139. package/src/utils/git.ts +2 -0
@@ -4,18 +4,28 @@
4
4
  * Uses brush-core via native bindings for shell execution.
5
5
  */
6
6
  import * as fs from "node:fs/promises";
7
- import { executeShell, type MinimizerOptions, Shell } from "@gajae-code/natives";
7
+ import {
8
+ executeShell,
9
+ type MinimizerOptions,
10
+ type ShellRunResult as NativeShellRunResult,
11
+ Shell,
12
+ } from "@gajae-code/natives";
8
13
  import { postmortem } from "@gajae-code/utils";
9
14
  import { Settings, type ShellMinimizerSettings } from "../config/settings";
10
15
  import { formatCrashDiagnosticNotice, writeCrashReport } from "../debug/crash-diagnostics";
11
16
  import {
12
- DEFAULT_ARTIFACT_MAX_BYTES,
13
17
  DEFAULT_MAX_BYTES,
14
18
  OutputSink,
19
+ type OutputSummary,
15
20
  type TerminalArtifactPublisher,
16
21
  truncateHeadBytes,
17
22
  } from "../session/streaming-output";
18
- import { formatArtifactReference, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
23
+ import {
24
+ formatArtifactEvidenceNotice,
25
+ isValidArtifactId,
26
+ resolveOutputMaxColumns,
27
+ resolveOutputSinkHeadBytes,
28
+ } from "../tools/output-meta";
19
29
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
20
30
  import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
21
31
 
@@ -25,7 +35,19 @@ export interface BashArtifactSaveSummary {
25
35
  omittedBytes?: number;
26
36
  }
27
37
 
28
- export type BashMinimizedSaveReturn = BashArtifactSaveResult | BashArtifactSaveSummary | string | undefined;
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>>();
29
51
 
30
52
  export type BashArtifactSaveResult =
31
53
  | { status: "saved"; artifactId: string; complete: true; omittedBytes?: undefined }
@@ -33,18 +55,16 @@ export type BashArtifactSaveResult =
33
55
  | { status: "unavailable" }
34
56
  | { status: "failed"; diagnostic: string };
35
57
 
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
- };
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" };
48
68
  }
49
69
 
50
70
  function normalizeExplicitSavedArtifact(
@@ -52,6 +72,12 @@ function normalizeExplicitSavedArtifact(
52
72
  complete: boolean,
53
73
  omittedBytes: number | undefined,
54
74
  ): 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
+ }
55
81
  if (complete) {
56
82
  return (omittedBytes ?? 0) > 0
57
83
  ? { status: "failed", diagnostic: "artifact save reported complete output with omitted bytes" }
@@ -62,35 +88,143 @@ function normalizeExplicitSavedArtifact(
62
88
  : { status: "failed", diagnostic: "artifact save reported incomplete output without omitted bytes" };
63
89
  }
64
90
 
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);
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();
70
110
  }
71
- if (value.status !== "saved") return value;
72
111
  return normalizeExplicitSavedArtifact(value.artifactId, value.complete, value.omittedBytes);
73
112
  }
74
113
 
75
- export function normalizeMinimizedSaveResultForTests(
76
- value: BashMinimizedSaveReturn,
77
- originalText: string,
78
- ): BashArtifactSaveResult {
79
- return normalizeMinimizedSaveResult(value, originalText);
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);
80
182
  }
81
183
 
82
184
  function completeRawArtifactAvailable(summary: {
83
185
  artifactId?: string;
84
186
  artifactTruncatedBytes?: number;
187
+ sourceTruncatedBytes?: number;
188
+ sourceCaptureIncomplete?: boolean;
85
189
  artifactFailureDiagnostic?: string;
86
190
  }): boolean {
87
191
  return (
88
192
  summary.artifactId !== undefined &&
89
193
  (summary.artifactTruncatedBytes ?? 0) <= 0 &&
194
+ (summary.sourceTruncatedBytes ?? 0) <= 0 &&
195
+ !summary.sourceCaptureIncomplete &&
90
196
  summary.artifactFailureDiagnostic === undefined
91
197
  );
92
198
  }
93
199
 
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
+
94
228
  function appendModelNotice(output: string, notice: string): string {
95
229
  const separator = output.length > 0 && !output.endsWith("\n") ? "\n" : "";
96
230
  return `${output}${separator}${notice}\n`;
@@ -98,7 +232,13 @@ function appendModelNotice(output: string, notice: string): string {
98
232
 
99
233
  function minimizedSaveNotice(
100
234
  result: BashArtifactSaveResult,
101
- summary: { artifactId?: string; artifactTruncatedBytes?: number; artifactFailureDiagnostic?: string },
235
+ summary: {
236
+ artifactId?: string;
237
+ artifactTruncatedBytes?: number;
238
+ sourceTruncatedBytes?: number;
239
+ sourceCaptureIncomplete?: boolean;
240
+ artifactFailureDiagnostic?: string;
241
+ },
102
242
  ): string | undefined {
103
243
  if (result.status === "failed") return `Bash output artifact save failed: ${result.diagnostic}`;
104
244
  if (result.status === "unavailable" && !completeRawArtifactAvailable(summary)) {
@@ -107,13 +247,6 @@ function minimizedSaveNotice(
107
247
  return undefined;
108
248
  }
109
249
 
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
-
117
250
  export interface BashExecutorOptions {
118
251
  /**
119
252
  * Invoked when the native minimizer rewrote the command's output, giving
@@ -170,40 +303,122 @@ export interface BashResult {
170
303
  outputBytes: number;
171
304
  artifactId?: string;
172
305
  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;
173
310
  artifactFailureDiagnostic?: string;
174
311
  }
175
312
 
176
313
  const shellSessions = new Map<string, Shell>();
177
- const brokenShellSessions = new Set<string>();
314
+ const brokenShellSessions = new Map<string, Shell>();
178
315
  const retiringShellSessions = new Set<Shell>();
179
- // Cover pi-shell's normal cancellation kill waves without turning a stalled
180
- // native cleanup into a multi-second JavaScript tool stall.
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.
181
382
  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
+ }
182
391
 
183
- /** Number of persistent shell sessions currently retained (owner gauge). */
392
+ /** Number of persistent and retiring shell sessions currently retained (owner gauge). */
184
393
  export function getShellSessionCount(): number {
185
- return shellSessions.size;
394
+ return new Set([...shellSessions.values(), ...retiringShellSessions, ...shellSessionKeys.keys()]).size;
186
395
  }
187
396
 
188
397
  /**
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).
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.
194
401
  */
195
402
  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.
202
403
  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
+ }
203
409
  shellSessions.clear();
204
- retiringShellSessions.clear();
205
- brokenShellSessions.clear();
206
- await Promise.allSettled([...sessions].map(session => session.abort()));
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
+ );
207
422
  }
208
423
 
209
424
  postmortem.register("bash-executor:shell-sessions", () => disposeAllShellSessions());
@@ -233,6 +448,9 @@ export function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerO
233
448
  }
234
449
 
235
450
  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
+ }
236
454
  const settings = await Settings.init();
237
455
  const { shell, env: shellEnv, prefix } = settings.getShellConfig();
238
456
  const configuredPrefix = options?.ignoreShellPrefix ? undefined : prefix;
@@ -279,51 +497,71 @@ export async function executeBash(command: string, options?: BashExecutorOptions
279
497
  };
280
498
  }
281
499
 
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
- }
295
500
  const userSignal = options?.signal;
296
501
  const runAbortController = new AbortController();
297
502
  const abortCurrentExecution = () => {
298
- if (!runAbortController.signal.aborted) {
299
- runAbortController.abort();
300
- }
301
- if (shellSession && !abortPromise) {
302
- abortPromise = shellSession.abort();
303
- }
503
+ if (!runAbortController.signal.aborted) runAbortController.abort();
304
504
  };
305
505
  const abortDeferred = Promise.withResolvers<"abort">();
306
- let abortPromise: Promise<unknown> | undefined;
307
506
  const abortHandler = () => {
308
507
  abortCurrentExecution();
309
508
  abortDeferred.resolve("abort");
310
509
  };
311
- const awaitAbortCleanup = async (runPromise: Promise<unknown>): Promise<boolean> => {
312
- const settled = await Promise.race([
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([
313
559
  runPromise.then(
314
- () => true,
315
- () => true,
560
+ result => ({ settled: true, result }) satisfies AbortCleanupOutcome,
561
+ () => ({ settled: true }) satisfies AbortCleanupOutcome,
316
562
  ),
317
- Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => false),
563
+ Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => ({ settled: false }) satisfies AbortCleanupOutcome),
318
564
  ]);
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
- }
327
565
 
328
566
  let timeoutTimer: NodeJS.Timeout | undefined;
329
567
  const timeoutDeferred = Promise.withResolvers<"timeout">();
@@ -337,11 +575,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
337
575
  }
338
576
 
339
577
  let resetSession = false;
340
- let runSettled = false;
341
578
 
342
579
  try {
343
- const runPromise = shellSession
344
- ? shellSession.run(
580
+ let runPromise: Promise<NativeShellRunResult>;
581
+ if (shellSession) {
582
+ try {
583
+ runPromise = shellSession.run(
345
584
  {
346
585
  command: finalCommand,
347
586
  cwd: commandCwd,
@@ -350,28 +589,31 @@ export async function executeBash(command: string, options?: BashExecutorOptions
350
589
  signal: runAbortController.signal,
351
590
  },
352
591
  (err, 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
- }
592
+ if (!err) enqueueChunk(chunk);
373
593
  },
374
594
  );
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
+ }
375
617
 
376
618
  const winner = await Promise.race([
377
619
  runPromise.then(result => ({ kind: "result" as const, result })),
@@ -380,44 +622,37 @@ export async function executeBash(command: string, options?: BashExecutorOptions
380
622
  ]);
381
623
 
382
624
  if (winner.kind === "timeout" || winner.kind === "abort") {
625
+ const abortOutcome = await awaitAbortCleanup(runPromise);
383
626
  acceptingChunks = false;
384
627
  if (shellSession) {
385
628
  resetSession = true;
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 {
629
+ quarantineShellSession(sessionKey, shellSession);
630
+ releaseQuarantinedShellIfIdle(sessionKey, shellSession);
631
+ } else if (!abortOutcome.settled) {
405
632
  void runPromise.catch(() => undefined);
406
633
  }
407
- return {
408
- exitCode: undefined,
409
- cancelled: true,
410
- ...(await sink.dump(
634
+ const summary = applyShellCallbackLoss(
635
+ await sink.dump(
411
636
  winner.kind === "timeout" && baseTimeoutMs !== undefined
412
637
  ? `Command timed out after ${Math.round(baseTimeoutMs / 1000)} seconds`
413
638
  : "Command cancelled",
414
- )),
639
+ ),
640
+ abortOutcome.result?.droppedOutputBytes,
641
+ abortOutcome.result?.droppedOutputChunks,
642
+ nativeCaptureStatusIncomplete(abortOutcome.result),
643
+ );
644
+ return {
645
+ exitCode: undefined,
646
+ cancelled: true,
647
+ ...summary,
415
648
  };
416
649
  }
417
650
  if (timeoutTimer) {
418
651
  clearTimeout(timeoutTimer);
419
652
  timeoutTimer = undefined;
420
653
  }
654
+ const minimizedEvidence = normalizeNativeMinimized(winner.result.minimized);
655
+ const nativeEvidenceIncomplete = nativeCaptureStatusIncomplete(winner.result) || minimizedEvidence.malformed;
421
656
 
422
657
  // Handle timeout
423
658
  if (winner.result.timedOut) {
@@ -428,7 +663,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
428
663
  return {
429
664
  exitCode: undefined,
430
665
  cancelled: true,
431
- ...(await sink.dump(annotation)),
666
+ ...applyShellCallbackLoss(
667
+ await sink.dump(annotation),
668
+ winner.result.droppedOutputBytes,
669
+ winner.result.droppedOutputChunks,
670
+ nativeEvidenceIncomplete,
671
+ ),
432
672
  };
433
673
  }
434
674
 
@@ -438,7 +678,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
438
678
  return {
439
679
  exitCode: undefined,
440
680
  cancelled: true,
441
- ...(await sink.dump("Command cancelled")),
681
+ ...applyShellCallbackLoss(
682
+ await sink.dump("Command cancelled"),
683
+ winner.result.droppedOutputBytes,
684
+ winner.result.droppedOutputChunks,
685
+ nativeEvidenceIncomplete,
686
+ ),
442
687
  };
443
688
  }
444
689
 
@@ -446,21 +691,24 @@ export async function executeBash(command: string, options?: BashExecutorOptions
446
691
  // raw stream for the minimized text, persist the original as a session
447
692
  // artifact, and splice an artifact footer into the visible text so the agent
448
693
  // can retrieve retained raw bytes without a false completeness claim.
449
- const minimized = winner.result.minimized;
694
+ const minimized = minimizedEvidence.minimized;
450
695
  let minimizedSaveResult: BashArtifactSaveResult | undefined;
451
696
  if (minimized && minimized.text !== minimized.originalText) {
452
697
  sink.replace(minimized.text);
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);
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" };
461
707
  if (minimizedSaveResult.status === "saved") {
462
- const sep = minimized.text.endsWith("\n") ? "" : "\n";
463
- sink.push(`${sep}${minimizedArtifactFooter(minimizedSaveResult)}\n`);
708
+ minimizedSaveResult = {
709
+ status: "failed",
710
+ diagnostic: "artifact save could not be verified in the current session",
711
+ };
464
712
  }
465
713
  }
466
714
 
@@ -480,7 +728,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
480
728
  }
481
729
 
482
730
  // Normal completion
483
- const summary = await sink.dump();
731
+ const summary = applyShellCallbackLoss(
732
+ await sink.dump(),
733
+ winner.result.droppedOutputBytes,
734
+ winner.result.droppedOutputChunks,
735
+ nativeEvidenceIncomplete,
736
+ );
484
737
  const saveNotice = minimizedSaveResult ? minimizedSaveNotice(minimizedSaveResult, summary) : undefined;
485
738
  return {
486
739
  exitCode: winner.result.exitCode,
@@ -490,7 +743,23 @@ export async function executeBash(command: string, options?: BashExecutorOptions
490
743
  };
491
744
  } catch (err) {
492
745
  resetSession = true;
493
- throw err;
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}`);
494
763
  } finally {
495
764
  if (timeoutTimer) {
496
765
  clearTimeout(timeoutTimer);
@@ -498,8 +767,13 @@ export async function executeBash(command: string, options?: BashExecutorOptions
498
767
  if (userSignal) {
499
768
  userSignal.removeEventListener("abort", abortHandler);
500
769
  }
501
- if (resetSession && runSettled && shellSessions.get(sessionKey) === shellSession) {
502
- shellSessions.delete(sessionKey);
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
+ }
503
777
  }
504
778
  }
505
779
  }