@aefree/pi-plastic 0.7.6 → 0.8.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 (148) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +48 -174
  3. package/docs/2026-10-06-development.md +101 -0
  4. package/docs/2026-10-06-diffs-and-patches.md +136 -0
  5. package/docs/2026-10-06-discovery.md +135 -0
  6. package/docs/2026-10-06-integrations.md +49 -0
  7. package/docs/2026-10-06-merging.md +153 -0
  8. package/docs/2026-10-06-safety-and-receipts.md +95 -0
  9. package/docs/2026-10-06-setup.md +101 -0
  10. package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
  11. package/docs/2026-10-06-status.md +116 -0
  12. package/docs/2026-10-06-tools-and-loading.md +64 -0
  13. package/docs/2026-10-06-workspace-operations.md +142 -0
  14. package/docs/README.md +65 -0
  15. package/extensions/bash-cm-diff-guard.ts +1 -1
  16. package/index.ts +4 -687
  17. package/package.json +10 -4
  18. package/skills/using-plastic/SKILL.md +11 -5
  19. package/skills/using-plastic/references/branch-management.md +5 -1
  20. package/skills/using-plastic/references/changeset-operations.md +9 -8
  21. package/skills/using-plastic/references/code-review-creation.md +25 -18
  22. package/skills/using-plastic/references/integration.md +9 -5
  23. package/skills/using-plastic/references/quick-reference.md +4 -4
  24. package/skills/using-plastic/references/reviewing-changes.md +10 -10
  25. package/skills/using-plastic/references/shelving.md +26 -11
  26. package/skills/using-plastic/references/troubleshooting.md +4 -4
  27. package/src/diff/local-snapshot.ts +74 -0
  28. package/src/diff/text.ts +187 -0
  29. package/src/domain/add-contract.ts +24 -0
  30. package/src/domain/branch-create-contract.ts +38 -0
  31. package/src/domain/branch-delete-contract.ts +15 -0
  32. package/src/domain/branches.ts +263 -0
  33. package/src/domain/checkin-contract.ts +70 -0
  34. package/src/domain/closeout-contract.ts +30 -0
  35. package/src/domain/copied-merge.ts +42 -0
  36. package/src/domain/diff-base-xml.ts +65 -0
  37. package/src/domain/diff-pending-xml.ts +34 -0
  38. package/src/domain/merge-output.ts +327 -0
  39. package/src/domain/paths.ts +253 -0
  40. package/src/domain/pending.ts +499 -0
  41. package/src/domain/removal-contract.ts +25 -0
  42. package/src/domain/revisions.ts +301 -0
  43. package/src/domain/server-merge-contract.ts +45 -0
  44. package/src/domain/server-merge-resolutions.ts +61 -0
  45. package/src/domain/status-xml.ts +122 -0
  46. package/src/domain/switch-contract.ts +60 -0
  47. package/src/domain/switch-policy.ts +95 -0
  48. package/src/domain/undo-contract.ts +24 -0
  49. package/src/domain/update-contract.ts +16 -0
  50. package/src/domain/workspace-merge-contract.ts +41 -0
  51. package/src/execution/add-command.ts +118 -0
  52. package/src/execution/branch-create-command.ts +118 -0
  53. package/src/execution/branch-list-command.ts +75 -0
  54. package/src/execution/checkin-command.ts +116 -0
  55. package/src/execution/cli-version.ts +31 -0
  56. package/src/execution/cm.ts +59 -0
  57. package/src/execution/code-review-find-command.ts +75 -0
  58. package/src/execution/context.ts +20 -0
  59. package/src/execution/diff-revisions-command.ts +74 -0
  60. package/src/execution/process.ts +260 -0
  61. package/src/execution/removal-command.ts +118 -0
  62. package/src/execution/server-merge-command.ts +92 -0
  63. package/src/execution/shelveset-list-command.ts +75 -0
  64. package/src/execution/status-command.ts +29 -0
  65. package/src/execution/status-xml-command.ts +72 -0
  66. package/src/execution/status-xml-limits.ts +1 -0
  67. package/src/execution/switch-command.ts +118 -0
  68. package/src/execution/test-seams.ts +10 -0
  69. package/src/execution/undo-command.ts +118 -0
  70. package/src/execution/update-command.ts +118 -0
  71. package/src/execution/workspace-list-command.ts +75 -0
  72. package/src/execution/workspace-merge-command.ts +118 -0
  73. package/src/operations/add-receipt.ts +37 -0
  74. package/src/operations/arguments.ts +3 -0
  75. package/src/operations/branch-create.ts +74 -0
  76. package/src/operations/branch-delete-receipt.ts +25 -0
  77. package/src/operations/branch-list.ts +94 -0
  78. package/src/operations/branch-reads.ts +94 -0
  79. package/src/operations/branches.ts +111 -0
  80. package/src/operations/checkin-receipt.ts +140 -0
  81. package/src/operations/checkin.ts +102 -0
  82. package/src/operations/closeout-receipt.ts +74 -0
  83. package/src/operations/closeout.ts +19 -0
  84. package/src/operations/code-review-find.ts +97 -0
  85. package/src/operations/consolidated-diff.ts +144 -0
  86. package/src/operations/diff-revisions.ts +157 -0
  87. package/src/operations/diff.ts +53 -0
  88. package/src/operations/merge.ts +26 -0
  89. package/src/operations/object-delete-receipt.ts +21 -0
  90. package/src/operations/object-write-receipt.ts +105 -0
  91. package/src/operations/patch-receipt.ts +96 -0
  92. package/src/operations/patch-support.ts +375 -0
  93. package/src/operations/patch.ts +25 -0
  94. package/src/operations/removal-receipt.ts +42 -0
  95. package/src/operations/reviews.ts +90 -0
  96. package/src/operations/server-merge.ts +130 -0
  97. package/src/operations/shelveset-list.ts +90 -0
  98. package/src/operations/shelvesets.ts +90 -0
  99. package/src/operations/status.ts +129 -0
  100. package/src/operations/switch-receipt.ts +85 -0
  101. package/src/operations/switch.ts +25 -0
  102. package/src/operations/undo-receipt.ts +37 -0
  103. package/src/operations/update-receipt.ts +35 -0
  104. package/src/operations/workspace-list.ts +92 -0
  105. package/src/operations/workspace-merge-receipt.ts +50 -0
  106. package/src/operations/workspace.ts +120 -0
  107. package/src/pi/add-output.ts +52 -0
  108. package/src/pi/arguments.ts +298 -0
  109. package/src/pi/branch-create-output.ts +83 -0
  110. package/src/pi/branch-delete-output.ts +46 -0
  111. package/src/pi/branch-list-output.ts +59 -0
  112. package/src/pi/branch-output.ts +63 -0
  113. package/src/pi/capability-diagnostics.ts +23 -0
  114. package/src/pi/checkin-output.ts +68 -0
  115. package/src/pi/closeout-output.ts +87 -0
  116. package/src/pi/code-review-find-output.ts +50 -0
  117. package/src/pi/diff-output.ts +66 -0
  118. package/src/pi/diff-revisions-output.ts +55 -0
  119. package/src/pi/object-delete-output.ts +15 -0
  120. package/src/pi/object-write-output.ts +54 -0
  121. package/src/pi/patch-output.ts +69 -0
  122. package/src/pi/register.ts +188 -0
  123. package/src/pi/removal-output.ts +59 -0
  124. package/src/pi/schemas.ts +146 -0
  125. package/src/pi/server-merge-output.ts +83 -0
  126. package/src/pi/shelveset-list-output.ts +50 -0
  127. package/src/pi/status-output.ts +149 -0
  128. package/src/pi/switch-output.ts +80 -0
  129. package/src/pi/tool-registry.ts +72 -0
  130. package/src/pi/undo-output.ts +52 -0
  131. package/src/pi/update-output.ts +48 -0
  132. package/src/pi/workspace-list-output.ts +53 -0
  133. package/src/pi/workspace-merge-output.ts +46 -0
  134. package/src/plastic-core.ts +14 -5775
  135. package/src/plastic-renderers.ts +102 -22
  136. package/src/plastic-tool-loading.ts +7 -12
  137. package/src/presentation/add-results.ts +5 -0
  138. package/src/presentation/branch-create.ts +7 -0
  139. package/src/presentation/branch-delete-results.ts +7 -0
  140. package/src/presentation/checkin-results.ts +14 -0
  141. package/src/presentation/closeout-results.ts +2 -0
  142. package/src/presentation/diff-results.ts +237 -0
  143. package/src/presentation/removal-results.ts +7 -0
  144. package/src/presentation/results.ts +84 -0
  145. package/src/presentation/switch-results.ts +7 -0
  146. package/src/presentation/undo-results.ts +5 -0
  147. package/src/presentation/update-results.ts +5 -0
  148. package/src/presentation/workspace-merge-results.ts +6 -0
@@ -0,0 +1,95 @@
1
+ import type { PendingItemSummary, PendingSummary } from "./pending";
2
+ import { isSameBranchSpec } from "./branches";
3
+ export type SwitchPendingProfile = {
4
+ hasPendingChanges: boolean;
5
+ hasTrackedPendingChanges: boolean;
6
+ hasPrivatePendingChanges: boolean;
7
+ hasPrivateOnlyPendingChanges: boolean;
8
+ };
9
+
10
+ export const toLegacyPendingSummary = (summary: PendingItemSummary): PendingSummary =>
11
+ {
12
+ return {
13
+ totalPending: summary.totalPending,
14
+ added: summary.added,
15
+ changed: summary.changed,
16
+ moved: summary.moved,
17
+ deleted: summary.deleted,
18
+ other: summary.other + summary.private,
19
+ };
20
+ };
21
+
22
+ export const buildSwitchPendingProfile = (summary: PendingItemSummary): SwitchPendingProfile =>
23
+ {
24
+ return {
25
+ hasPendingChanges: summary.totalPending > 0,
26
+ hasTrackedPendingChanges: summary.tracked > 0,
27
+ hasPrivatePendingChanges: summary.private > 0,
28
+ hasPrivateOnlyPendingChanges: summary.private > 0 && summary.tracked === 0,
29
+ };
30
+ };
31
+
32
+ export const isSwitchBringBlockedForUnattended = (pendingChoice: "shelve" | "bring" | "cancel", profile: SwitchPendingProfile): boolean =>
33
+ {
34
+ return pendingChoice === "bring" && profile.hasTrackedPendingChanges;
35
+ };
36
+
37
+ export type CanceledSwitchOutcome = {
38
+ kind: "canceled";
39
+ strategy: "cancel-with-pending";
40
+ branchBefore: string;
41
+ branchTarget: string;
42
+ pendingSummary: PendingSummary;
43
+ pendingSummaryDetailed: PendingItemSummary;
44
+ pendingPolicy: "cancel";
45
+ defaultedPolicy: boolean;
46
+ reason: string;
47
+ };
48
+
49
+ export const createCanceledSwitchOutcome = (
50
+ branchBefore: string,
51
+ branchTarget: string,
52
+ pendingSummary: PendingSummary,
53
+ pendingSummaryDetailed: PendingItemSummary,
54
+ pendingChoice: "shelve" | "bring" | "cancel",
55
+ defaultedPolicy: boolean,
56
+ ): CanceledSwitchOutcome | undefined =>
57
+ {
58
+ if (pendingChoice !== "cancel" || pendingSummaryDetailed.totalPending === 0 || isSameBranchSpec(branchBefore, branchTarget))
59
+ {
60
+ return undefined;
61
+ }
62
+
63
+ return {
64
+ kind: "canceled",
65
+ strategy: "cancel-with-pending",
66
+ branchBefore,
67
+ branchTarget,
68
+ pendingSummary,
69
+ pendingSummaryDetailed,
70
+ pendingPolicy: "cancel",
71
+ defaultedPolicy,
72
+ reason: defaultedPolicy
73
+ ? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
74
+ : "Switch canceled because pending changes were detected and pendingChanges was set to cancel.",
75
+ };
76
+ };
77
+
78
+ export const canSwitchDirectWithPrivateOnlyPending = (
79
+ pendingChoice: "shelve" | "bring" | "cancel",
80
+ defaultedPolicy: boolean,
81
+ profile: SwitchPendingProfile,
82
+ ): boolean =>
83
+ {
84
+ if (!profile.hasPrivateOnlyPendingChanges)
85
+ {
86
+ return false;
87
+ }
88
+
89
+ if (pendingChoice === "shelve" || pendingChoice === "bring")
90
+ {
91
+ return true;
92
+ }
93
+
94
+ return defaultedPolicy;
95
+ };
@@ -0,0 +1,24 @@
1
+ import type { UndoAttempt, UndoCapture } from "../execution/undo-command";
2
+ export type { UndoAttempt, UndoCapture } from "../execution/undo-command";
3
+ export const UNDO_COMMAND = "undo" as const;
4
+ export const safeUndoText = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && value.trim().length > 0 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
5
+ export function validUndoOperands(paths: unknown): paths is string[] {
6
+ if (!Array.isArray(paths) || !paths.length || paths.length > 256) return false;
7
+ let bytes = 0;
8
+ for (let index = 0; index < paths.length; index++) {
9
+ const path = paths[index];
10
+ if (!safeUndoText(path) || path.trimStart().startsWith("-")) return false;
11
+ bytes += Buffer.byteLength(path, "utf8");
12
+ if (bytes > 32768) return false;
13
+ }
14
+ return true;
15
+ }
16
+ export type UndoData = {
17
+ intendedArgv: readonly string[]; requestedOperandCount: number | null; workingDirectory: string | null;
18
+ attempt: UndoAttempt; capture: UndoCapture | null;
19
+ observedWorkspaceIdentity: null; observedUndoneItems: null;
20
+ verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
21
+ };
22
+ type Header = { schemaVersion: 1; action: "undo"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: UndoData };
23
+ export type UndoError = { code: "invalid_request" | "launch_failed" | "aborted" | "uncertain" | "producer_failed"; message: string };
24
+ export type UndoReceipt = Header & ({ ok: true; outcome: "command-completed" } | { ok: false; outcome: "failed" | "uncertain"; error: UndoError });
@@ -0,0 +1,16 @@
1
+ import type { UpdateAttempt, UpdateCapture } from "../execution/update-command";
2
+ export type { UpdateAttempt, UpdateCapture } from "../execution/update-command";
3
+ export const UPDATE_ARGV = Object.freeze(["update", "--dontmerge", "--noinput"] as const);
4
+ export const safeUpdateText = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && value.trim().length > 0 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
5
+ export type UpdateData = {
6
+ intendedArgv: typeof UPDATE_ARGV; workingDirectory: string | null;
7
+ attempt: UpdateAttempt; capture: UpdateCapture | null;
8
+ observedWorkspaceIdentity: null; observedChanges: null;
9
+ verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
10
+ };
11
+ type Header = { schemaVersion: 1; action: "update"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: UpdateData };
12
+ export type UpdateError = { code: "invalid_request" | "launch_failed" | "aborted" | "uncertain" | "producer_failed"; message: string };
13
+ export type UpdateReceipt = Header & (
14
+ { ok: true; outcome: "command-completed" } |
15
+ { ok: false; outcome: "failed" | "uncertain"; error: UpdateError }
16
+ );
@@ -0,0 +1,41 @@
1
+ import type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
2
+ export type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
3
+ export type MergeAction = "merge" | "finalize-merge";
4
+ export type MergeStrategy = "auto" | "source" | "destination";
5
+ export const safeMergeText = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 4096 && v.trim().length > 0 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(v);
6
+ export type MergeStage = { attempt: WorkspaceMergeAttempt; capture: WorkspaceMergeCapture | null; admission: "not-observed" | "unsupported" | "admitted" };
7
+ export type WorkspaceMergeData = {
8
+ intendedArgv: readonly string[]; workingDirectory: string | null; requestedSource: string | null; strategy: MergeStrategy | null; cherrypicking: boolean | null; forced: boolean | null; previewRequested: boolean | null; outputFormatRequested: "text" | "json" | null;
9
+ apply: MergeStage; shortStatus: MergeStage; fullStatus: MergeStage; pendingReadMode: "short" | "copied-machine";
10
+ commandCompleted: boolean; observedWorkspaceIdentity: null; observedSourceIdentity: null; observedFinalizedMetadata: null; observedPreservation: null; observedPendingItems: null;
11
+ fileConflictCount: number | null; protocol: "not-observed" | "already-connected" | "file-conflict" | "apply-add" | "apply-add-copied" | "unsupported";
12
+ checkinReadiness: "unknown" | "blocked" | "supported-no-unresolved-signals"; verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
13
+ };
14
+ type Header = { schemaVersion: 1; action: MergeAction; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: WorkspaceMergeData };
15
+ export type WorkspaceMergeReceipt = Header & ({ ok: true; outcome: "preflight" | "command-completed" } | { ok: false; outcome: "failed" | "uncertain" | "blocked"; error: { code: "invalid_request" | "launch_failed" | "aborted" | "uncertain" | "unsupported_readiness" | "conflict" | "producer_failed"; message: string } });
16
+ export function buildWorkspaceMergeArgv(source: string, strategy: MergeStrategy, cherry: boolean, forced: boolean, token: string): string[] {
17
+ const result = ["merge",source,"--merge","--nointeractiveresolution","--machinereadable",`--startlineseparator=${token}S__`,`--endlineseparator=${token}E__`,`--fieldseparator=${token}F__`];
18
+ if(strategy === "auto") result.push("--mergetype=try"); else result.push("--mergetype=forced",strategy === "source" ? "--keepsource" : "--keepdestination",`--automaticresolution=all-${strategy === "source" ? "src" : "dst"}`);
19
+ if(cherry)result.push("--cherrypicking");if(forced)result.push("--forced");return result;
20
+ }
21
+ /** Narrow observed CM profile. Unknown records/text are not missing-conflict proof. */
22
+ export function admitWorkspaceMergeOutput(text: string, token: string): { protocol: WorkspaceMergeData["protocol"]; fileConflictCount: number | null; copiedPaths: string[]; addedPaths: string[] } {
23
+ const start=token+"S__",end=token+"E__",field=token+"F__";let rest=text,count=0,conflicts=0,connected=0,adds=0; const copiedPaths:string[]=[], addedPaths:string[]=[];
24
+ while(rest.trim()!==""){
25
+ if(++count>256)return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};const s=rest.indexOf(start),e=rest.indexOf(end,s+start.length);
26
+ if(s<0||e<0||rest.slice(0,s).trim()!=="")return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};
27
+ const payload=rest.slice(s+start.length,e);if(payload.includes(start))return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};const f=payload.split(field);rest=rest.slice(e+end.length);
28
+ if(f.length===3&&f[0]==="STATUS"&&f[1]==="ALREADY_CONNECTED"&&f[2]==="No merges detected")connected++;
29
+ else if(f.length===6&&f[0]==="FILE_CONFLICT"&&safeMergeText(f[1])&&f.slice(2).every(v=>/^[0-9]+$/.test(v)))conflicts++;
30
+ else if(f.length===3&&f[0]==="APPLY"&&f[1]==="ADD"&&safeMergeText(f[2])){adds++;addedPaths.push(f[2]);}
31
+ else if(f.length===2&&f[0]==="DO_COPIED"&&safeMergeText(f[1]))copiedPaths.push(f[1]);
32
+ else return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};
33
+ }
34
+ if(!count||connected&&(connected!==1||count!==1)||conflicts&&(adds||copiedPaths.length)||copiedPaths.length&&copiedPaths.length!==adds)return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};
35
+ return {protocol:conflicts?"file-conflict":connected?"already-connected":copiedPaths.length?"apply-add-copied":"apply-add",fileConflictCount:conflicts,copiedPaths,addedPaths};
36
+ }
37
+ /** Only the directly observed header-only standard status shape is admitted initially.
38
+ * Rich/pending/localized/merge-link status stays unknown until independently sourced. */
39
+ export function admitCleanStandardStatus(text: string): boolean {
40
+ return /^[^\r\n\u0000-\u001f\u007f]+ \(cs:[0-9]+ - head\)\r?\n(?:\r?\n)*$/.test(text)&&text.startsWith("/")&&text.includes("@");
41
+ }
@@ -0,0 +1,118 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
4
+ import { getCmExecutable } from "./process";
5
+ export type AddAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
6
+ export const emptyAddAttempt = (): AddAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
7
+ export type AddCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
8
+ export type AddCommandObservation = { attempt: AddAttempt; capture: AddCapture; stdout: string | null; stderr: string | null; failed: boolean };
9
+ // Forced-retirement guards have no per-command state or retained buffers.
10
+ const ignoreRetiredError = () => {};
11
+ /** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
12
+ export async function captureAddCommand(args: string[], workdir?: string, timeoutMs = 30000): Promise<AddCommandObservation> {
13
+ const attempt = emptyAddAttempt();
14
+ const capture: AddCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
15
+ const result: AddCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
16
+ const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
17
+ if (signal?.aborted) { attempt.aborted = true; return result; }
18
+ let child: ReturnType<typeof spawn>;
19
+ try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
20
+ catch { attempt.state = "not-started"; result.failed = true; return result; }
21
+ attempt.state = "unknown";
22
+ const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
23
+ let settled = false, failed = false, started = false, retired = false, finalized = false;
24
+ let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
25
+ let settleExit!: () => void;
26
+ const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
27
+ const retire = () => {
28
+ if (retired || finalized) return;
29
+ retired = true; failed = true;
30
+ // Retirement is not an observed terminal event or proof that the child stopped.
31
+ for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
32
+ settleExit();
33
+ for (const finish of finishers) finish();
34
+ child.stdout?.destroy(); child.stderr?.destroy();
35
+ };
36
+ const terminate = () => {
37
+ if (terminationRequested || retired || finalized) return;
38
+ terminationRequested = true;
39
+ const grace = deps.abortKillDelayMs ?? 5000;
40
+ if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
41
+ killTimer = schedule(() => {
42
+ if (finalized || retired) return;
43
+ if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
44
+ // Same bounded drain grace after KILL; terminal state never gates retirement.
45
+ retireTimer = schedule(retire, grace);
46
+ }, grace);
47
+ };
48
+ const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
49
+ let length = 0; const parts: Buffer[] = [];
50
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
51
+ if (stream.readableEncoding) { failed = true; terminate(); }
52
+ return new Promise<Buffer>(resolve => {
53
+ let done = false;
54
+ const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
55
+ finishers.push(finish);
56
+ const data = (chunk: unknown) => {
57
+ if (done || retired || finalized) return;
58
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
59
+ const key = name === "stdout" ? "stdoutBytes" : "stderrBytes";
60
+ capture[key] = Math.min(Number.MAX_SAFE_INTEGER, capture[key] + chunk.byteLength);
61
+ const available = cap - length;
62
+ if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
63
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
64
+ };
65
+ const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
66
+ const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
67
+ stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
68
+ disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
69
+ });
70
+ };
71
+ let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
72
+ const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
73
+ const exit = new Promise<void>(resolve => {
74
+ settleExit = resolve;
75
+ onClose = (code, terminalSignal) => {
76
+ if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
77
+ attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 && code <= 2147483647 ? code : null;
78
+ // A terminal event is not a substitute for the native start event.
79
+ if (!started) failed = true;
80
+ if (attempt.exitCode === null || terminalSignal) failed = true;
81
+ resolve();
82
+ };
83
+ onError = error => {
84
+ if (retired || finalized) return;
85
+ failed = true;
86
+ if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
87
+ attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
88
+ } else { terminate(); }
89
+ };
90
+ child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
91
+ });
92
+ const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
93
+ const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
94
+ signal?.addEventListener("abort", onAbort, { once: true });
95
+ const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
96
+ if (signal?.aborted) onAbort();
97
+ try {
98
+ const [, stdout, stderr] = await Promise.all([exit, out, err]);
99
+ capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
100
+ result.failed = failed;
101
+ try {
102
+ // Retain a literal UTF-8 BOM rather than silently removing source bytes.
103
+ result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
104
+ result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
105
+ capture.validUtf8 = true;
106
+ } catch { result.stdout = null; result.stderr = null; }
107
+ if (signal?.aborted) attempt.aborted = true;
108
+ capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
109
+ return result;
110
+ } finally {
111
+ finalized = true;
112
+ cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
113
+ signal?.removeEventListener("abort", onAbort);
114
+ child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
115
+ for (const dispose of disposers) dispose();
116
+ finishers.length = 0; disposers.length = 0;
117
+ }
118
+ }
@@ -0,0 +1,118 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
4
+ import { getCmExecutable } from "./process";
5
+ export type BranchCreateAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
6
+ export const emptyBranchCreateAttempt = (): BranchCreateAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
7
+ export type BranchCreateCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
8
+ export type BranchCreateCommandObservation = { attempt: BranchCreateAttempt; capture: BranchCreateCapture; stdout: string | null; stderr: string | null; failed: boolean };
9
+ // Forced-retirement guards have no per-command state or retained buffers.
10
+ const ignoreRetiredError = () => {};
11
+ /** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
12
+ export async function captureBranchCreateCommand(args: string[], workdir?: string, timeoutMs = 30000): Promise<BranchCreateCommandObservation> {
13
+ const attempt = emptyBranchCreateAttempt();
14
+ const capture: BranchCreateCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
15
+ const result: BranchCreateCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
16
+ const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
17
+ if (signal?.aborted) { attempt.aborted = true; return result; }
18
+ let child: ReturnType<typeof spawn>;
19
+ try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
20
+ catch { attempt.state = "not-started"; result.failed = true; return result; }
21
+ attempt.state = "unknown";
22
+ const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
23
+ let settled = false, failed = false, started = false, retired = false, finalized = false;
24
+ let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
25
+ let settleExit!: () => void;
26
+ const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
27
+ const retire = () => {
28
+ if (retired || finalized) return;
29
+ retired = true; failed = true;
30
+ // Retirement is not an observed terminal event or proof that the child stopped.
31
+ for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
32
+ settleExit();
33
+ for (const finish of finishers) finish();
34
+ child.stdout?.destroy(); child.stderr?.destroy();
35
+ };
36
+ const terminate = () => {
37
+ if (terminationRequested || retired || finalized) return;
38
+ terminationRequested = true;
39
+ const grace = deps.abortKillDelayMs ?? 5000;
40
+ if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
41
+ killTimer = schedule(() => {
42
+ if (finalized || retired) return;
43
+ if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
44
+ // Same bounded drain grace after KILL; terminal state never gates retirement.
45
+ retireTimer = schedule(retire, grace);
46
+ }, grace);
47
+ };
48
+ const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
49
+ let length = 0; const parts: Buffer[] = [];
50
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
51
+ if (stream.readableEncoding) { failed = true; terminate(); }
52
+ return new Promise<Buffer>(resolve => {
53
+ let done = false;
54
+ const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
55
+ finishers.push(finish);
56
+ const data = (chunk: unknown) => {
57
+ if (done || retired || finalized) return;
58
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
59
+ const key = name === "stdout" ? "stdoutBytes" : "stderrBytes";
60
+ capture[key] = Math.min(Number.MAX_SAFE_INTEGER, capture[key] + chunk.byteLength);
61
+ const available = cap - length;
62
+ if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
63
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
64
+ };
65
+ const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
66
+ const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
67
+ stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
68
+ disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
69
+ });
70
+ };
71
+ let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
72
+ const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
73
+ const exit = new Promise<void>(resolve => {
74
+ settleExit = resolve;
75
+ onClose = (code, terminalSignal) => {
76
+ if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
77
+ attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 && code <= 2147483647 ? code : null;
78
+ // A terminal event is not a substitute for the native start event.
79
+ if (!started) failed = true;
80
+ if (attempt.exitCode === null || terminalSignal) failed = true;
81
+ resolve();
82
+ };
83
+ onError = error => {
84
+ if (retired || finalized) return;
85
+ failed = true;
86
+ if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
87
+ attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
88
+ } else { terminate(); }
89
+ };
90
+ child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
91
+ });
92
+ const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
93
+ const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
94
+ signal?.addEventListener("abort", onAbort, { once: true });
95
+ const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
96
+ if (signal?.aborted) onAbort();
97
+ try {
98
+ const [, stdout, stderr] = await Promise.all([exit, out, err]);
99
+ capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
100
+ result.failed = failed;
101
+ try {
102
+ // Retain a literal UTF-8 BOM rather than silently removing source bytes.
103
+ result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
104
+ result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
105
+ capture.validUtf8 = true;
106
+ } catch { result.stdout = null; result.stderr = null; }
107
+ if (signal?.aborted) attempt.aborted = true;
108
+ capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
109
+ return result;
110
+ } finally {
111
+ finalized = true;
112
+ cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
113
+ signal?.removeEventListener("abort", onAbort);
114
+ child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
115
+ for (const dispose of disposers) dispose();
116
+ finishers.length = 0; disposers.length = 0;
117
+ }
118
+ }
@@ -0,0 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ export const BRANCH_LIST_CAPTURE_LIMITS = { stdoutBytes: 1048576, stderrBytes: 65536, timeoutMs: 30000, records: 20000 } as const;
4
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
5
+ import { getCmExecutable } from "./process";
6
+ export class BranchListCommandError extends Error {
7
+ constructor(readonly code: "command_failed" | "aborted" | "capture_incomplete") { super(`Branch list read failed (${code}).`); }
8
+ }
9
+
10
+ // Branch-list-only strict byte transport. Native/default and status routes are unchanged.
11
+ export async function runBranchListNamesCommand(args: string[], workdir?: string): Promise<Buffer> {
12
+ const signal = getActiveAbortSignal();
13
+ if (signal?.aborted) throw new BranchListCommandError("aborted");
14
+ const dependencies = commandExecutionStorage.getStore() ?? {};
15
+ let child;
16
+ try { child = (dependencies.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
17
+ catch { throw new BranchListCommandError("command_failed"); }
18
+ const schedule = dependencies.setTimeout ?? setTimeout;
19
+ const cancel = dependencies.clearTimeout ?? clearTimeout;
20
+ let aborted = false, incomplete = false, failed = false, settled = false;
21
+ let killTimer: NodeJS.Timeout | undefined;
22
+ const terminate = () => {
23
+ if (settled) return;
24
+ try { child.kill("SIGTERM"); } catch { /* process may already have exited */ }
25
+ if (killTimer === undefined) killTimer = schedule(() => { if (!settled) { try { child.kill("SIGKILL"); } catch { /* process may already have exited */ } } }, dependencies.abortKillDelayMs ?? 5000);
26
+ };
27
+ const collectors: Array<{ dispose(): void }> = [];
28
+ const collect = (stream: Readable | null, cap: number) => {
29
+ let length = 0;
30
+ const chunks: Buffer[] = [];
31
+ if (!stream) { failed = true; return Promise.resolve(Buffer.alloc(0)); }
32
+ if (stream.readableEncoding) { failed = true; terminate(); }
33
+ return new Promise<Buffer>(resolve => {
34
+ let done = false;
35
+ const finish = () => { if (!done) { done = true; resolve(Buffer.concat(chunks, length)); } };
36
+ const data = (chunk: unknown) => {
37
+ // Already-decoded streams cannot attest exact source bytes.
38
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
39
+ const available = cap - length;
40
+ if (chunk.byteLength > available) { incomplete = true; terminate(); }
41
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); chunks.push(part); length += part.length; }
42
+ // Keep draining beyond the cap until process/streams settle.
43
+ };
44
+ const error = () => { failed = true; terminate(); finish(); };
45
+ const closed = () => { if (!done && !stream.readableEnded) failed = true; finish(); };
46
+ stream.on("data", data); stream.once("end", finish); stream.once("error", error); stream.once("close", closed);
47
+ collectors.push({ dispose: () => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", closed); } });
48
+ });
49
+ };
50
+ let onClose: (code: number | null) => void;
51
+ let onError: () => void;
52
+ const exit = new Promise<number>(resolve => {
53
+ onClose = code => { settled = true; resolve(code ?? 1); };
54
+ onError = () => { failed = true; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve(1); };
55
+ child.once("close", onClose); child.once("error", onError);
56
+ });
57
+ const stdout = collect(child.stdout, BRANCH_LIST_CAPTURE_LIMITS.stdoutBytes);
58
+ const stderr = collect(child.stderr, BRANCH_LIST_CAPTURE_LIMITS.stderrBytes);
59
+ const onAbort = () => { aborted = true; terminate(); };
60
+ signal?.addEventListener("abort", onAbort, { once: true });
61
+ const timeout = schedule(() => { incomplete = true; terminate(); }, Math.min(dependencies.timeoutMs ?? BRANCH_LIST_CAPTURE_LIMITS.timeoutMs, BRANCH_LIST_CAPTURE_LIMITS.timeoutMs));
62
+ if (signal?.aborted) onAbort();
63
+ try {
64
+ const [code, bytes, errors] = await Promise.all([exit, stdout, stderr]);
65
+ if (aborted || signal?.aborted) throw new BranchListCommandError("aborted");
66
+ if (incomplete) throw new BranchListCommandError("capture_incomplete");
67
+ if (failed || code !== 0 || errors.length) throw new BranchListCommandError("command_failed");
68
+ return bytes;
69
+ } finally {
70
+ cancel(timeout); if (killTimer !== undefined) cancel(killTimer);
71
+ signal?.removeEventListener("abort", onAbort);
72
+ child.removeListener("close", onClose!); child.removeListener("error", onError!);
73
+ for (const collector of collectors) collector.dispose();
74
+ }
75
+ }
@@ -0,0 +1,116 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
4
+ import { getCmExecutable } from "./process";
5
+ export type CheckinAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
6
+ export type CheckinCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
7
+ export const emptyCheckinAttempt = (): CheckinAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
8
+ export type CheckinCommandObservation = { attempt: CheckinAttempt; capture: CheckinCapture; stdout: string | null; stderr: string | null; failed: boolean };
9
+ // Forced-retirement guards have no per-command state or retained buffers.
10
+ const ignoreRetiredError = () => {};
11
+ /** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
12
+ export async function captureCheckinCommand(args: string[], workdir: string, timeoutMs = 30000): Promise<CheckinCommandObservation> {
13
+ if (args.length > 120 || args.reduce((n, a) => n + a.length, 0) > 32768 || Buffer.byteLength(JSON.stringify(args), "utf8") > 65536) throw new Error("Checkin argv exceeds the admitted bound.");
14
+ const attempt = emptyCheckinAttempt();
15
+ const capture: CheckinCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
16
+ const result: CheckinCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
17
+ const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
18
+ if (signal?.aborted) { attempt.aborted = true; return result; }
19
+ let child: ReturnType<typeof spawn>;
20
+ try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir, shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
21
+ catch { attempt.state = "not-started"; result.failed = true; return result; }
22
+ attempt.state = "unknown";
23
+ const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
24
+ let settled = false, failed = false, started = false, retired = false, finalized = false;
25
+ let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
26
+ let settleExit!: () => void;
27
+ const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
28
+ const retire = () => {
29
+ if (retired || finalized) return;
30
+ retired = true; failed = true;
31
+ // Retirement is not an observed terminal event or proof that the child stopped.
32
+ for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
33
+ settleExit();
34
+ for (const finish of finishers) finish();
35
+ child.stdout?.destroy(); child.stderr?.destroy();
36
+ };
37
+ const terminate = () => {
38
+ if (terminationRequested || retired || finalized) return;
39
+ terminationRequested = true;
40
+ const grace = deps.abortKillDelayMs ?? 5000;
41
+ if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
42
+ killTimer = schedule(() => {
43
+ if (finalized || retired) return;
44
+ if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
45
+ // Same bounded drain grace after KILL; terminal state never gates retirement.
46
+ retireTimer = schedule(retire, grace);
47
+ }, grace);
48
+ };
49
+ const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
50
+ let length = 0; const parts: Buffer[] = [];
51
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
52
+ if (stream.readableEncoding) { failed = true; terminate(); }
53
+ return new Promise<Buffer>(resolve => {
54
+ let done = false;
55
+ const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
56
+ finishers.push(finish);
57
+ const data = (chunk: unknown) => {
58
+ if (done || retired || finalized) return;
59
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
60
+ capture[name === "stdout" ? "stdoutBytes" : "stderrBytes"] += chunk.byteLength;
61
+ const available = cap - length;
62
+ if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
63
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
64
+ };
65
+ const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
66
+ const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
67
+ stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
68
+ disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
69
+ });
70
+ };
71
+ let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
72
+ const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
73
+ const exit = new Promise<void>(resolve => {
74
+ settleExit = resolve;
75
+ onClose = (code, terminalSignal) => {
76
+ if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
77
+ attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 ? code : null;
78
+ if (!started || attempt.exitCode === null || terminalSignal) failed = true;
79
+ resolve();
80
+ };
81
+ onError = error => {
82
+ if (retired || finalized) return;
83
+ failed = true;
84
+ if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
85
+ attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
86
+ } else { terminate(); }
87
+ };
88
+ child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
89
+ });
90
+ const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
91
+ const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
92
+ signal?.addEventListener("abort", onAbort, { once: true });
93
+ const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
94
+ if (signal?.aborted) onAbort();
95
+ try {
96
+ const [, stdout, stderr] = await Promise.all([exit, out, err]);
97
+ capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
98
+ result.failed = failed;
99
+ try {
100
+ // Preserve a BOM as evidence rather than silently removing source bytes.
101
+ result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
102
+ result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
103
+ capture.validUtf8 = true;
104
+ } catch { result.stdout = null; result.stderr = null; }
105
+ if (signal?.aborted) attempt.aborted = true;
106
+ capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
107
+ return result;
108
+ } finally {
109
+ finalized = true;
110
+ cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
111
+ signal?.removeEventListener("abort", onAbort);
112
+ child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
113
+ for (const dispose of disposers) dispose();
114
+ finishers.length = 0; disposers.length = 0;
115
+ }
116
+ }