@gajae-code/coding-agent 0.15.4 → 0.15.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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Session-owned fold/wake coordinator.
3
+ *
4
+ * Folding a running foreground wait is a lifecycle handoff across three
5
+ * independently scheduled domains — the tool observer, the job owner, and
6
+ * agent-turn admission — so it needs ONE linearization point. That is this
7
+ * coordinator: a synchronous prologue claims the fold and arms the steering
8
+ * fence with no `await` in between, and only then does an async tail capture and
9
+ * persist the receipt.
10
+ *
11
+ * Two invariants are load-bearing and easy to get wrong:
12
+ *
13
+ * 1. **Identity is the `AsyncJob` instance, acquired once.** Job ids and
14
+ * generations are manager-local, so two managers can mint the same
15
+ * `bg_1`/`job:1` pair. Slots are therefore keyed by the object itself,
16
+ * obtained exactly once through the registration-bound
17
+ * {@link FoldAdapter.getJob} and retained for the rest of the transaction.
18
+ * Nothing in the fold path may consult an ambient, singleton, or
19
+ * endpoint-resolved manager.
20
+ * 2. **A delivery callback can run more than once.** The async job manager
21
+ * re-pushes the SAME delivery object on retry, so the receipt is attached to
22
+ * a carrier keyed by that object BEFORE the slot is released. A retried
23
+ * delivery therefore still carries its receipt and can never silently
24
+ * degrade into an ordinary receipt-less completion.
25
+ */
26
+ import type { AsyncJob } from "../async/job-manager";
27
+ import foldReceiptPrompt from "../prompts/tools/fold-receipt.md" with { type: "text" };
28
+
29
+ /**
30
+ * Idle-flush merge window.
31
+ *
32
+ * Completions that land within this window coalesce into ONE wake turn. The
33
+ * previous 1ms debounce meant staggered completions each bought their own full
34
+ * LLM turn. Deliberately a fixed internal constant, not a setting: the contract
35
+ * is one merge window, and a per-user knob would let wake behavior differ
36
+ * between sessions.
37
+ */
38
+ export const FOLD_WAKE_MERGE_WINDOW_MS = 800;
39
+
40
+ /** The wait kinds that can be folded. `task`/`subagent` awaits are a non-goal. */
41
+ export type FoldWaitKind = "bash-managed" | "client-terminal" | "bash-pty";
42
+
43
+ /** Outcome of settling the foreground caller. Exactly one party may settle it. */
44
+ export type ForegroundSettleOutcome = "resolved" | "already-settled";
45
+
46
+ /**
47
+ * How a folded job's output is retrieved later. Deliberately a structured
48
+ * handle over already-implemented paths rather than an invented URI scheme:
49
+ * there is no `job://` resolver, and inventing one would make the reference
50
+ * unresolvable in exactly the degraded case it exists for.
51
+ */
52
+ export interface JobOutputRetrieval {
53
+ jobId: string;
54
+ generation: string;
55
+ /** Human/model-facing retrieval instruction naming a real mechanism. */
56
+ instruction: string;
57
+ }
58
+
59
+ /** Durable record captured at fold time and replayed into the wake turn. */
60
+ export interface FoldReceipt {
61
+ jobId: string;
62
+ jobGeneration: string;
63
+ kind: FoldWaitKind;
64
+ label: string;
65
+ outputRef: JobOutputRetrieval;
66
+ /** What the interrupted turn still intended to do, so the wake can finish it. */
67
+ remainingIntent: string | undefined;
68
+ foldedAt: number;
69
+ /** Only command kinds can change directory; task-style waits never do. */
70
+ cwdSensitive: boolean;
71
+ }
72
+
73
+ /** A terminal completion observed for a folded job. */
74
+ export interface ForegroundTerminalPayload {
75
+ jobId: string;
76
+ generation: string;
77
+ text: string;
78
+ }
79
+
80
+ /**
81
+ * Per-wait fold capability. `getJob` is bound at registration over the manager
82
+ * that registered THIS job, which is what makes cross-manager identity safe.
83
+ */
84
+ export interface FoldAdapter {
85
+ readonly kind: FoldWaitKind;
86
+ readonly jobId: string;
87
+ readonly jobGeneration: string;
88
+ readonly label: string;
89
+ readonly cwdSensitive: boolean;
90
+ /** Signal for the foreground wait; aborting capture must roll back the fold. */
91
+ readonly signal?: AbortSignal;
92
+ /** True only when the adapter was created by a model-owned Agent turn. */
93
+ readonly originatingTurn?: boolean;
94
+ readonly outputRef: JobOutputRetrieval;
95
+ /** Registration-bound resolver: the job for this adapter, or undefined once evicted or reused. */
96
+ readonly getJob: () => AsyncJob | undefined;
97
+ /** Settle the foreground caller with the fold result. Idempotent. */
98
+ readonly detachObserver: (receipt: FoldReceipt) => ForegroundSettleOutcome;
99
+ /** Hand a parked completion back to a still-attached observer. Idempotent. */
100
+ readonly resolveForegroundObserver: (payload: ForegroundTerminalPayload) => ForegroundSettleOutcome;
101
+ }
102
+
103
+ export type FoldRequestResult =
104
+ | { status: "folded"; receipt: FoldReceipt }
105
+ | { status: "unavailable"; reason: string }
106
+ | { status: "already-terminal"; reason: string }
107
+ | { status: "capture-failed"; reason: string };
108
+
109
+ /** What a delivery should carry, decided by the durable slot state (T2). */
110
+ export type FoldDeliveryDisposition =
111
+ | { kind: "ordinary" }
112
+ | { kind: "parked" }
113
+ | { kind: "receipt"; receipt: FoldReceipt; text: string };
114
+
115
+ interface ReservedSlot {
116
+ state: "reserved";
117
+ adapter: FoldAdapter;
118
+ parked: ForegroundTerminalPayload | undefined;
119
+ parkedAt: number | undefined;
120
+ }
121
+
122
+ interface PresentSlot {
123
+ state: "present";
124
+ adapter: FoldAdapter;
125
+ receipt: FoldReceipt;
126
+ }
127
+
128
+ type FoldSlot = ReservedSlot | PresentSlot;
129
+
130
+ export interface FoldCoordinatorDeps {
131
+ /**
132
+ * Fence old-turn steering admission and return the release handle. Armed
133
+ * synchronously inside the prologue because the agent loop polls steering
134
+ * upstream of its pause checkpoint; fencing there keeps queued steering
135
+ * intact instead of draining it.
136
+ */
137
+ armSteeringFence: () => () => void;
138
+ /** Whether this fold belongs to a currently running Agent turn. */
139
+ hasActiveTurn?: () => boolean;
140
+ /** Arm the cooperative stop so the turn ends after the fold result, never via abort. */
141
+ requestStop: () => void;
142
+ /** Capture what the interrupted turn still intended to accomplish. */
143
+ captureRemainingIntent: () => Promise<string | undefined> | string | undefined;
144
+ /**
145
+ * Push a parked completion into the wake path. The delivery seam parks a
146
+ * completion that arrives before the receipt exists; when the fold later
147
+ * replays it (A6), THIS is what makes the wake happen rather than relying on
148
+ * an unrelated idle rearm to rescue it.
149
+ */
150
+ deliverParked: (job: AsyncJob, disposition: { kind: "receipt"; receipt: FoldReceipt; text: string }) => void;
151
+ }
152
+
153
+ /** Why a slot is being retired, which decides whether retiring is safe. */
154
+ export type FoldRetireReason = "cancel" | "evict";
155
+
156
+ /**
157
+ * Render a fold receipt for the wake turn.
158
+ *
159
+ * Carries the job id, a real retrieval handle, and the captured remaining
160
+ * intent, so the wake turn can finish the ORIGINAL task instead of only
161
+ * reporting that a command ended. The cwd caveat is included only for wait
162
+ * kinds that can actually change directory.
163
+ */
164
+ export function describeFoldReceipt(receipt: FoldReceipt): string {
165
+ const cwdNotice = receipt.cwdSensitive
166
+ ? "Session cwd is unchanged; any directory change made by the folded command does not apply to later commands.\n"
167
+ : "";
168
+ const intentNotice = receipt.remainingIntent
169
+ ? `Complete the original request, which was: ${receipt.remainingIntent}`
170
+ : "";
171
+ return foldReceiptPrompt
172
+ .replace("{{kind}}", () => receipt.kind)
173
+ .replace("{{jobId}}", () => receipt.jobId)
174
+ .replace("{{outputInstruction}}", () => receipt.outputRef.instruction)
175
+ .replace("{{cwdNotice}}", () => cwdNotice)
176
+ .replace("{{intentNotice}}", () => intentNotice)
177
+ .trim();
178
+ }
179
+
180
+ export class FoldCoordinator {
181
+ /**
182
+ * Slots and receipt carriers are keyed by the `AsyncJob` INSTANCE, so two
183
+ * managers minting identical ids/generations can never collide, and an entry
184
+ * cannot outlive the object it belongs to.
185
+ */
186
+ readonly #slots = new WeakMap<AsyncJob, FoldSlot>();
187
+ readonly #carriers = new WeakMap<AsyncJob, FoldReceipt>();
188
+ readonly #participants = new Map<number, FoldAdapter>();
189
+ #nextParticipantId = 0;
190
+ readonly #folding = new WeakSet<AsyncJob>();
191
+ /** Jobs whose completion notice has already been emitted. */
192
+ readonly #noticed = new WeakSet<AsyncJob>();
193
+ /** Jobs whose receipt-bearing completion has already been queued. */
194
+ readonly #delivered = new WeakSet<AsyncJob>();
195
+ readonly #deps: FoldCoordinatorDeps;
196
+
197
+ constructor(deps: FoldCoordinatorDeps) {
198
+ this.#deps = deps;
199
+ }
200
+
201
+ /** Register a foldable wait. The most recently registered wait is the default target. */
202
+ registerParticipant(adapter: FoldAdapter): () => void {
203
+ const key = ++this.#nextParticipantId;
204
+ this.#participants.set(key, adapter);
205
+ return () => {
206
+ if (this.#participants.get(key) === adapter) this.#participants.delete(key);
207
+ };
208
+ }
209
+
210
+ /** Whether any wait is currently foldable, for the key-availability gate. */
211
+ hasFoldableParticipant(): boolean {
212
+ return this.#participants.size > 0;
213
+ }
214
+
215
+ /** Deterministic target: newest registration wins, so the chord folds what the user is watching. */
216
+ resolveTarget(): FoldAdapter | undefined {
217
+ let target: FoldAdapter | undefined;
218
+ for (const adapter of this.#participants.values()) target = adapter;
219
+ return target;
220
+ }
221
+
222
+ /**
223
+ * T1. Synchronous prologue then async durability tail.
224
+ *
225
+ * Nothing awaits before the slot is reserved and the fence is armed, so a
226
+ * completion or a steering submission that races the capture cannot slip
227
+ * past into the old turn.
228
+ */
229
+ async requestFold(explicit?: FoldAdapter): Promise<FoldRequestResult> {
230
+ // S1 resolve target.
231
+ const adapter = explicit ?? this.resolveTarget();
232
+ if (!adapter) return { status: "unavailable", reason: "no foldable wait is registered" };
233
+
234
+ // S2 acquire identity ONCE through the registration-bound resolver.
235
+ const job = adapter.getJob();
236
+ if (!job) return { status: "already-terminal", reason: "the wait already settled or was evicted" };
237
+ if (job.generation !== adapter.jobGeneration) {
238
+ return { status: "already-terminal", reason: "the job id was reused under a new generation" };
239
+ }
240
+ if (this.#folding.has(job) || this.#slots.has(job)) {
241
+ return { status: "unavailable", reason: "a fold is already in progress for this wait" };
242
+ }
243
+
244
+ // S3 claim + reserve, S4 hand-off is the reservation itself, S5 arm the fence.
245
+ this.#folding.add(job);
246
+ this.#slots.set(job, { state: "reserved", adapter, parked: undefined, parkedAt: undefined });
247
+ const fencesOriginatingTurn = adapter.originatingTurn ?? this.#deps.hasActiveTurn?.() ?? true;
248
+ const releaseFence = fencesOriginatingTurn ? this.#deps.armSteeringFence() : () => {};
249
+
250
+ let remainingIntent: string | undefined;
251
+ try {
252
+ // A1 capture.
253
+ const signal = adapter.signal;
254
+ if (signal?.aborted) throw new Error("fold capture aborted");
255
+ const capture = Promise.resolve(this.#deps.captureRemainingIntent());
256
+ if (!fencesOriginatingTurn) {
257
+ remainingIntent = undefined;
258
+ } else if (!signal) {
259
+ remainingIntent = await capture;
260
+ } else {
261
+ const aborted = Promise.withResolvers<never>();
262
+ const onAbort = () => aborted.reject(new Error("fold capture aborted"));
263
+ signal.addEventListener("abort", onAbort, { once: true });
264
+ try {
265
+ remainingIntent = await Promise.race([capture, aborted.promise]);
266
+ } finally {
267
+ signal.removeEventListener("abort", onAbort);
268
+ }
269
+ }
270
+ } catch (error) {
271
+ return this.#rollback(job, adapter, releaseFence, error instanceof Error ? error.message : String(error));
272
+ }
273
+
274
+ // T-R3a: revalidate the reservation after the await. A cancellation during
275
+ // capture already retired the slot; it must NOT be recreated here, and the
276
+ // participant must not be left advertised as folding.
277
+ const current = this.#slots.get(job);
278
+ if (!current || current.adapter !== adapter) {
279
+ this.#folding.delete(job);
280
+ releaseFence();
281
+ return { status: "already-terminal", reason: "the wait settled while its receipt was being captured" };
282
+ }
283
+ if (adapter.signal?.aborted || job.status !== "running") {
284
+ return this.#rollback(job, adapter, releaseFence, "the wait settled while its receipt was being captured");
285
+ }
286
+
287
+ const receipt: FoldReceipt = {
288
+ jobId: adapter.jobId,
289
+ jobGeneration: adapter.jobGeneration,
290
+ kind: adapter.kind,
291
+ label: adapter.label,
292
+ outputRef: adapter.outputRef,
293
+ remainingIntent,
294
+ foldedAt: Date.now(),
295
+ cwdSensitive: adapter.cwdSensitive,
296
+ };
297
+
298
+ // A2 promote to present, preserving any payload parked during capture.
299
+ const parked = current.state === "reserved" ? current.parked : undefined;
300
+ this.#slots.set(job, { state: "present", adapter, receipt });
301
+
302
+ // A3 detach the observer, A4 arm the cooperative stop, A5 leave folding.
303
+ const detached = adapter.detachObserver(receipt);
304
+ if (detached === "already-settled") {
305
+ this.#slots.delete(job);
306
+ this.#folding.delete(job);
307
+ releaseFence();
308
+ return { status: "already-terminal", reason: "the foreground observer already settled" };
309
+ }
310
+ if (fencesOriginatingTurn) this.#deps.requestStop();
311
+ this.#folding.delete(job);
312
+
313
+ // A6 flush: replay a parked completion through the same T2 branch so it
314
+ // takes the receipt exactly once instead of being handled specially — and
315
+ // push it into the wake path, because the delivery seam already returned
316
+ // early on it and will not retry.
317
+ if (parked) {
318
+ const replayed = this.onDelivery(job, parked.text);
319
+ if (replayed.kind === "receipt") this.#deps.deliverParked(job, replayed);
320
+ }
321
+
322
+ // A7 the fence stays armed until the turn actually stops; releasing here
323
+ // would readmit old-turn steering into the run being wound down.
324
+ return { status: "folded", receipt };
325
+ }
326
+
327
+ /**
328
+ * T2. Decide what a delivery carries, branching on the DURABLE slot state
329
+ * rather than on transient hand-off state.
330
+ *
331
+ * `present` attaches the receipt to the retry-stable carrier BEFORE clearing
332
+ * the slot, so a retried invocation still finds it.
333
+ */
334
+ onDelivery(job: AsyncJob, text: string): FoldDeliveryDisposition {
335
+ const carried = this.#carriers.get(job);
336
+ if (carried) return { kind: "receipt", receipt: carried, text };
337
+
338
+ const slot = this.#slots.get(job);
339
+ if (!slot) return { kind: "ordinary" };
340
+
341
+ if (slot.state === "reserved") {
342
+ // Park: the receipt does not exist yet, so enqueue nothing. A6 replays it.
343
+ slot.parked = { jobId: slot.adapter.jobId, generation: slot.adapter.jobGeneration, text };
344
+ slot.parkedAt = Date.now();
345
+ const timer = setTimeout(() => this.retire(job, "evict"), FOLD_WAKE_MERGE_WINDOW_MS * 4);
346
+ timer.unref();
347
+ return { kind: "parked" };
348
+ }
349
+
350
+ // Attach first, then release the slot: order matters for retries.
351
+ this.#carriers.set(job, slot.receipt);
352
+ this.#slots.delete(job);
353
+ return { kind: "receipt", receipt: slot.receipt, text };
354
+ }
355
+
356
+ /**
357
+ * Retirement. T-R2: never drop a slot that holds an undelivered payload or
358
+ * that a still-possible delivery could consume.
359
+ *
360
+ * - `cancel` retires freely, because a cancelled job enqueues no delivery at
361
+ * all, so no later consumer can arrive.
362
+ * - `evict` only retires when the job can no longer deliver. An evicted
363
+ * record still delivers with its retained job object, so a completed or
364
+ * failed job keeps its slot.
365
+ */
366
+ retire(job: AsyncJob, reason: FoldRetireReason): boolean {
367
+ const slot = this.#slots.get(job);
368
+ if (!slot) return false;
369
+
370
+ if (reason === "cancel") {
371
+ this.#slots.delete(job);
372
+ this.#folding.delete(job);
373
+ return true;
374
+ }
375
+
376
+ if (slot.state === "reserved" && slot.parked !== undefined) {
377
+ const parkedAt = slot.parkedAt ?? Date.now();
378
+ if (Date.now() - parkedAt < FOLD_WAKE_MERGE_WINDOW_MS * 4) return false;
379
+ const parked = slot.parked;
380
+ this.#slots.delete(job);
381
+ this.#folding.delete(job);
382
+ slot.adapter.resolveForegroundObserver(parked);
383
+ return true;
384
+ }
385
+ if (job.status === "cancelled" || job.status === "paused") {
386
+ this.#slots.delete(job);
387
+ this.#folding.delete(job);
388
+ return true;
389
+ }
390
+ return false;
391
+ }
392
+
393
+ /**
394
+ * Claim the single completion notice for a job.
395
+ *
396
+ * Returns true exactly once per job. A delivery can be retried with the same
397
+ * object, so an unguarded notice would repeat for one completion.
398
+ */
399
+ claimCompletionNotice(job: AsyncJob): boolean {
400
+ if (this.#noticed.has(job)) return false;
401
+ this.#noticed.add(job);
402
+ return true;
403
+ }
404
+
405
+ /** Claim the receipt-bearing queue entry once across manager delivery retries. */
406
+ claimCompletionDelivery(job: AsyncJob): boolean {
407
+ if (this.#delivered.has(job)) return false;
408
+ this.#delivered.add(job);
409
+ return true;
410
+ }
411
+
412
+ /** Test/diagnostic view of the durable slot state for a job. */
413
+ slotStateFor(job: AsyncJob): "none" | "reserved-empty" | "reserved-parked" | "present" | "carried" {
414
+ const slot = this.#slots.get(job);
415
+ if (slot?.state === "present") return "present";
416
+ if (slot?.state === "reserved") return slot.parked === undefined ? "reserved-empty" : "reserved-parked";
417
+ return this.#carriers.has(job) ? "carried" : "none";
418
+ }
419
+
420
+ #rollback(job: AsyncJob, adapter: FoldAdapter, releaseFence: () => void, reason: string): FoldRequestResult {
421
+ const slot = this.#slots.get(job);
422
+ // R1/R2 return the claim and drop the reservation, R3 release the fence so
423
+ // the preserved steering becomes admissible again, R5 never arm the stop.
424
+ this.#slots.delete(job);
425
+ this.#folding.delete(job);
426
+ releaseFence();
427
+ // R4 hand a parked completion back to the STILL-ATTACHED observer, which
428
+ // owns its own acknowledgement path. The coordinator enqueues nothing, so
429
+ // it cannot race that observer.
430
+ if (slot?.state === "reserved" && slot.parked) adapter.resolveForegroundObserver(slot.parked);
431
+ return { status: "capture-failed", reason };
432
+ }
433
+ }
@@ -242,9 +242,8 @@ export interface ManagedFileSnapshot {
242
242
  };
243
243
  }
244
244
  /**
245
- * Descriptor identity of one managed file object. Deliberately excludes the
246
- * content sha256: append receipts must bind the post-operation object without
247
- * re-reading the whole payload.
245
+ * Descriptor identity of one managed file object. Append receipts include a
246
+ * content digest when the append implementation has already computed it.
248
247
  */
249
248
  export interface ManagedFileIdentity {
250
249
  dev: bigint;
@@ -253,6 +252,7 @@ export interface ManagedFileIdentity {
253
252
  size: number;
254
253
  mtimeNs: bigint;
255
254
  ctimeNs: bigint;
255
+ sha256?: string;
256
256
  }
257
257
 
258
258
  /**
@@ -281,6 +281,7 @@ function sameManagedIdentity(left: ManagedFileIdentity, right: ManagedFileIdenti
281
281
  export interface ManagedBoundedAppendExpectation {
282
282
  readonly dev: string;
283
283
  readonly ino: string;
284
+ readonly nlink: string;
284
285
  readonly size: string;
285
286
  readonly mtimeNs: string;
286
287
  readonly ctimeNs: string;
@@ -295,6 +296,7 @@ function managedFileIdentityFromNative(identity: RecoveryFsIdentity): ManagedFil
295
296
  size: Number(identity.size),
296
297
  mtimeNs: BigInt(identity.mtimeNs),
297
298
  ctimeNs: BigInt(identity.ctimeNs),
299
+ ...(identity.sha256 ? { sha256: identity.sha256 } : {}),
298
300
  };
299
301
  }
300
302
 
@@ -1704,6 +1706,7 @@ export class ManagedSessionDescendantStore {
1704
1706
  return {
1705
1707
  dev: identity.dev.toString(),
1706
1708
  ino: identity.ino.toString(),
1709
+ nlink: identity.nlink.toString(),
1707
1710
  size: identity.size.toString(),
1708
1711
  mtimeNs: identity.mtimeNs.toString(),
1709
1712
  ctimeNs: identity.ctimeNs.toString(),
@@ -1718,17 +1721,20 @@ export class ManagedSessionDescendantStore {
1718
1721
  ): ManagedAppendReceipt {
1719
1722
  this.#beforeMutation();
1720
1723
  this.#assertBound();
1724
+ let appendExpectation = expected;
1721
1725
  if (!this.#authority) {
1722
1726
  const current = captureManagedFileIdentityStreamingNoFollow(this.#resolve(relativePath));
1723
- if (
1724
- current.dev.toString() !== expected.dev ||
1725
- current.ino.toString() !== expected.ino ||
1726
- current.size.toString() !== expected.size ||
1727
- current.mtimeNs.toString() !== expected.mtimeNs ||
1728
- current.ctimeNs.toString() !== expected.ctimeNs ||
1729
- current.sha256 !== expected.sha256
1730
- )
1727
+ const identityMatches =
1728
+ current.dev.toString() === expected.dev &&
1729
+ current.ino.toString() === expected.ino &&
1730
+ current.nlink.toString() === expected.nlink &&
1731
+ current.size.toString() === expected.size &&
1732
+ current.sha256 === expected.sha256;
1733
+ if (!identityMatches) {
1731
1734
  throw new Error("managed_append_identity_mismatch");
1735
+ }
1736
+ // mtime/ctime drift on the same file object is harmless. The append
1737
+ // helper performs its own descriptor-bound mutation check.
1732
1738
  const appended = appendManagedFileStreamingSync(
1733
1739
  this.#resolve(relativePath),
1734
1740
  bytes,
@@ -1738,16 +1744,35 @@ export class ManagedSessionDescendantStore {
1738
1744
  this.#assertBound();
1739
1745
  return managedAppendReceiptFromIdentity(appended);
1740
1746
  }
1747
+ const current = this.captureBoundedAppendExpectation(relativePath);
1748
+ if (!current) throw new Error("managed_append_identity_mismatch");
1749
+ if (
1750
+ current.dev !== expected.dev ||
1751
+ current.ino !== expected.ino ||
1752
+ current.nlink !== expected.nlink ||
1753
+ current.size !== expected.size ||
1754
+ current.sha256 !== expected.sha256
1755
+ )
1756
+ throw new Error("managed_append_identity_mismatch");
1757
+ const descriptorMatches =
1758
+ current.dev === expected.dev &&
1759
+ current.ino === expected.ino &&
1760
+ current.nlink === expected.nlink &&
1761
+ current.size === expected.size &&
1762
+ current.mtimeNs === expected.mtimeNs &&
1763
+ current.ctimeNs === expected.ctimeNs &&
1764
+ current.sha256 === expected.sha256;
1765
+ if (!descriptorMatches) appendExpectation = current;
1741
1766
  const relative = this.#relative(this.#resolve(relativePath));
1742
1767
  const appended = this.#authority.appendManaged(
1743
1768
  relative,
1744
1769
  bytes,
1745
- expected.dev,
1746
- expected.ino,
1747
- expected.size,
1748
- expected.mtimeNs,
1749
- expected.ctimeNs,
1750
- expected.sha256,
1770
+ appendExpectation.dev,
1771
+ appendExpectation.ino,
1772
+ appendExpectation.size,
1773
+ appendExpectation.mtimeNs,
1774
+ appendExpectation.ctimeNs,
1775
+ appendExpectation.sha256,
1751
1776
  );
1752
1777
  if (!appended.ok) throw managedAppendFailure(appended.code);
1753
1778
  if (!appended.identity)
@@ -1762,15 +1787,28 @@ export class ManagedSessionDescendantStore {
1762
1787
  expected: ManagedFileIdentity,
1763
1788
  ): ManagedAppendReceipt {
1764
1789
  const bounded = this.captureBoundedAppendExpectation(relativePath);
1765
- if (
1766
- !bounded ||
1767
- bounded.dev !== expected.dev.toString() ||
1768
- bounded.ino !== expected.ino.toString() ||
1769
- bounded.size !== String(expected.size) ||
1770
- bounded.mtimeNs !== expected.mtimeNs.toString() ||
1771
- bounded.ctimeNs !== expected.ctimeNs.toString()
1772
- )
1773
- throw new Error("managed_append_identity_mismatch");
1790
+ if (!bounded) throw new Error("managed_append_identity_mismatch");
1791
+ const descriptorMatches =
1792
+ bounded.dev === expected.dev.toString() &&
1793
+ bounded.ino === expected.ino.toString() &&
1794
+ bounded.nlink === expected.nlink.toString() &&
1795
+ bounded.size === String(expected.size) &&
1796
+ bounded.mtimeNs === expected.mtimeNs.toString() &&
1797
+ bounded.ctimeNs === expected.ctimeNs.toString();
1798
+ if (!descriptorMatches) {
1799
+ // Metadata-only drift is safe only when the same unlinked file object and
1800
+ // byte-exact predecessor are independently proven. Never accept a changed
1801
+ // object, length, link count, or digest as a benign touch.
1802
+ if (
1803
+ !expected.sha256 ||
1804
+ bounded.dev !== expected.dev.toString() ||
1805
+ bounded.ino !== expected.ino.toString() ||
1806
+ bounded.nlink !== expected.nlink.toString() ||
1807
+ bounded.size !== String(expected.size) ||
1808
+ bounded.sha256 !== expected.sha256
1809
+ )
1810
+ throw new Error("managed_append_identity_mismatch");
1811
+ }
1774
1812
  return this.appendExpectedSync(relativePath, bytes, bounded);
1775
1813
  }
1776
1814
 
@@ -0,0 +1,15 @@
1
+ const sdkRunTokenBrand: unique symbol = Symbol("sdkRunToken");
2
+
3
+ export interface SdkRunCapability {
4
+ readonly [sdkRunTokenBrand]: string;
5
+ }
6
+
7
+ export function createSdkRunCapability(token: string): SdkRunCapability {
8
+ return { [sdkRunTokenBrand]: token };
9
+ }
10
+
11
+ export function readSdkRunCapability(value: unknown): string | undefined {
12
+ if (!value || typeof value !== "object") return undefined;
13
+ const token = (value as Partial<SdkRunCapability>)[sdkRunTokenBrand];
14
+ return typeof token === "string" && token.length > 0 ? token : undefined;
15
+ }
@@ -0,0 +1,2 @@
1
+ export type { SdkRunCapability } from "./sdk-run-capability-internal";
2
+ export { readSdkRunCapability } from "./sdk-run-capability-internal";