@gajae-code/coding-agent 0.16.4 → 0.16.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 (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -427,14 +427,12 @@ export class SessionSdkSessionRuntime {
427
427
  * asked for. Delivery failure is ignored: a disconnected consumer must never
428
428
  * disturb the turn producing the content.
429
429
  */
430
- sendFrameTo(connectionIds: Iterable<string>, frame: SdkFrame): void {
431
- for (const connectionId of connectionIds) {
432
- try {
433
- const result = this.transport.sendFrame(connectionId, frame);
434
- if (result instanceof Promise) result.catch(() => undefined);
435
- } catch {
436
- // A dead connection is reaped by the transport's own close handling.
437
- }
430
+ sendFrameTo(connectionId: string, frame: SdkFrame): void {
431
+ try {
432
+ const result = this.transport.sendFrame(connectionId, frame);
433
+ if (result instanceof Promise) result.catch(() => undefined);
434
+ } catch {
435
+ // A dead connection is reaped by the transport's own close handling.
438
436
  }
439
437
  }
440
438
 
@@ -491,13 +489,13 @@ export class SessionSdkSessionRuntime {
491
489
  }
492
490
  }
493
491
 
494
- async stop(): Promise<void> {
492
+ async stop(options: { allowLockContention?: boolean } = {}): Promise<void> {
495
493
  this.#connectionDisposer?.();
496
494
  this.#capabilitiesDisposer?.();
497
495
  this.#malformedDisposer?.();
498
496
  let hostError: unknown;
499
497
  try {
500
- await this.host.stop();
498
+ await this.host.stop(options);
501
499
  } catch (error) {
502
500
  hostError = error;
503
501
  } finally {
@@ -2628,6 +2626,19 @@ function createControlSurface(
2628
2626
  // so their recovery owner is this bounded in-place retry loop: the same
2629
2627
  // order, retried, until both writes land or the budget is spent.
2630
2628
  const attemptRejectionTerminalization = async (remaining: number): Promise<void> => {
2629
+ // AgentSession can reject cleanup after its durable terminal was published.
2630
+ // That rejection is diagnostic, not another outcome for this correlation.
2631
+ const record = reconciliation.lookup(kind, correlation) as { status?: string };
2632
+ if (record.status === "terminal_ok" || record.status === "failed") {
2633
+ logger.warn("SDK submission cleanup failed after terminal publication", {
2634
+ kind,
2635
+ commandId: correlation.commandId,
2636
+ turnId: correlation.turnId,
2637
+ error: sanitizePromptFailure(error),
2638
+ });
2639
+ retirePendingOwner?.(kind, correlation, "always");
2640
+ return;
2641
+ }
2631
2642
  try {
2632
2643
  await reconciliation.noteTransition(kind, correlation, { type: "agent_failed", error });
2633
2644
  } catch {
@@ -4037,6 +4048,22 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4037
4048
  correlation: InvocationCorrelation;
4038
4049
  connectionId: string | undefined;
4039
4050
  }>;
4051
+ /**
4052
+ * Content frames observed before this batch's correlated agent_start
4053
+ * reached the wire. The synchronous session subscription can see a
4054
+ * message_update before emitLifecycle finishes awaiting durable start
4055
+ * persistence; holding them here keeps the producer's start/content
4056
+ * boundary for every SDK consumer. Each entry carries the recipients
4057
+ * that owned the run when the event was produced: an owner attached
4058
+ * later must not receive content from before its attachment.
4059
+ */
4060
+ startPublished: boolean;
4061
+ /** Set once the hold bound dropped content for this batch; gates the single warning. */
4062
+ heldContentDropped: boolean;
4063
+ heldContent: Array<{
4064
+ event: AgentSessionEvent;
4065
+ recipients: Array<{ correlation: InvocationCorrelation; connectionId: string | undefined }>;
4066
+ }>;
4040
4067
  }>;
4041
4068
  disposeGate?: () => void;
4042
4069
  lifecycleActive: boolean;
@@ -4220,6 +4247,56 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4220
4247
  owner.lifecycleTasks.add(task);
4221
4248
  return task;
4222
4249
  };
4250
+ /**
4251
+ * Publish one content frame per owning invocation, each carrying its own
4252
+ * correlation, so a shared run lets every submitter attribute the content to
4253
+ * its own prompt. Content is best-effort and bypasses the lifecycle replay
4254
+ * ring; the turn producing it is authoritative.
4255
+ */
4256
+ const publishContentFrames = (
4257
+ current: RuntimeState,
4258
+ event: AgentSessionEvent,
4259
+ invocations: ReadonlyArray<{ correlation: InvocationCorrelation; connectionId: string | undefined }>,
4260
+ ): void => {
4261
+ try {
4262
+ const payload = toAgentWireEventPayload(event);
4263
+ for (const invocation of invocations) {
4264
+ if (invocation.connectionId === undefined) continue;
4265
+ current.runtime.sendFrameTo(invocation.connectionId, {
4266
+ type: "event",
4267
+ kind: event.type,
4268
+ payload,
4269
+ ...invocation.correlation,
4270
+ });
4271
+ }
4272
+ } catch {
4273
+ // Streamed content is best-effort; the turn producing it is authoritative.
4274
+ }
4275
+ };
4276
+ /**
4277
+ * Upper bound on content events held for one batch while its correlated
4278
+ * agent_start is still being durably recorded. A start write normally settles
4279
+ * in milliseconds, so this is only reached when persistence is wedged. Past
4280
+ * the bound the OLDEST held content is dropped (with one warning per batch):
4281
+ * content is never published before its start, so a wedged write degrades
4282
+ * to a gap in the stream, not an inverted start/content boundary, and it
4283
+ * can never accumulate an entire response in memory. The turn's terminal
4284
+ * result remains authoritative for anything a consumer did not see.
4285
+ */
4286
+ const MAX_HELD_CONTENT_EVENTS = 256;
4287
+ /**
4288
+ * Release content that arrived before the batch's correlated agent_start
4289
+ * reached the wire. Called once per batch, after the start frames are
4290
+ * published (or after the start publication path gave up), so SDK consumers
4291
+ * observe start before any content, in producer order.
4292
+ */
4293
+ const releaseHeldContent = (current: RuntimeState, batch: LifecycleBatch): void => {
4294
+ if (batch.startPublished) return;
4295
+ batch.startPublished = true;
4296
+ const held = batch.heldContent;
4297
+ batch.heldContent = [];
4298
+ for (const { event, recipients } of held) publishContentFrames(current, event, recipients);
4299
+ };
4223
4300
  const emitLifecycle = async (
4224
4301
  type: "agent_start" | "agent_end" | "agent_failed",
4225
4302
  ctx: ExtensionContext,
@@ -4231,6 +4308,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4231
4308
  terminalOutcome?: InvocationOutcome,
4232
4309
  stopReason?: AgentEndEvent["stopReason"],
4233
4310
  startToken?: string,
4311
+ startTokens?: string[],
4234
4312
  ): Promise<void> => {
4235
4313
  const current = lifecycleOwner?.state ?? active;
4236
4314
  if (!current) return;
@@ -4305,10 +4383,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4305
4383
  // turn (queued-while-streaming submissions never push), so a mid-prompt
4306
4384
  // continuation agent_start with an empty queue leaves the current owner
4307
4385
  // untouched (review thread P1).
4386
+ const cohort = new Set(startTokens);
4387
+ if (startToken) cohort.add(startToken);
4308
4388
  const matchingPending =
4309
- typeof startToken === "string" && startToken.length > 0
4310
- ? current.pending.filter(entry => entry.sdkRunToken === startToken)
4311
- : current.pending.slice();
4389
+ cohort.size > 0 ? current.pending.filter(entry => cohort.has(entry.sdkRunToken)) : current.pending.slice();
4312
4390
  const matchingKeys = new Set(matchingPending.map(entry => entry.sdkRunToken));
4313
4391
  const pendingSnapshot = current.pending.splice(0);
4314
4392
  const drained = pendingSnapshot
@@ -4324,14 +4402,16 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4324
4402
  epoch: current.lifecycleEpoch,
4325
4403
  invocations: drained,
4326
4404
  attachedInvocations: [],
4405
+ startPublished: false,
4406
+ heldContentDropped: false,
4407
+ heldContent: [],
4327
4408
  };
4328
4409
  current.openLifecycleBatches.push(batch);
4329
- if (typeof startToken === "string" && startToken.length > 0) {
4330
- const entry = drained.find(item => item.sdkRunToken === startToken);
4331
- lifecycleRunOwners.set(startToken, {
4410
+ for (const entry of drained) {
4411
+ lifecycleRunOwners.set(entry.sdkRunToken, {
4332
4412
  state: current,
4333
4413
  batch,
4334
- ...(entry ? { correlationKey: lifecycleCorrelationKey(entry.correlation) } : {}),
4414
+ correlationKey: lifecycleCorrelationKey(entry.correlation),
4335
4415
  });
4336
4416
  }
4337
4417
  adoptLifecycleBatch(drained);
@@ -4599,6 +4679,27 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4599
4679
  observed = false;
4600
4680
  }
4601
4681
  }
4682
+ } else if (type === "agent_start" && transitions.length > 0) {
4683
+ for (const invocation of transitions) {
4684
+ try {
4685
+ current.runtime.emitEvent({ type, sessionId, ...invocation.correlation });
4686
+ } catch {
4687
+ observed = false;
4688
+ }
4689
+ }
4690
+ // The start frames are on the wire (or definitively failed); content
4691
+ // that the synchronous session subscription observed meanwhile must
4692
+ // follow them, never precede them.
4693
+ const started = current.openLifecycleBatches.find(candidate =>
4694
+ candidate.invocations.some(entry =>
4695
+ transitions.some(
4696
+ ({ correlation }) =>
4697
+ correlation.commandId === entry.correlation.commandId &&
4698
+ correlation.turnId === entry.correlation.turnId,
4699
+ ),
4700
+ ),
4701
+ );
4702
+ if (started) releaseHeldContent(current, started);
4602
4703
  } else if (type === "agent_end" && transitions.length > 0) {
4603
4704
  // An invocation-owned terminal must carry the identity it was accepted
4604
4705
  // under and the normalized outcome. A client that submitted this turn
@@ -4621,9 +4722,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4621
4722
  }
4622
4723
  }
4623
4724
  } else {
4624
- // Everything that does not terminalize an owned invocation keeps the one
4625
- // uncorrelated frame: `agent_start` and a host-originated run (autonomous
4626
- // continuation, cron, monitor) that owns no invocation.
4725
+ // Host-originated runs without an invocation keep an uncorrelated frame.
4627
4726
  try {
4628
4727
  current.runtime.emitEvent({ type, sessionId });
4629
4728
  } catch {
@@ -4704,6 +4803,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4704
4803
  undefined,
4705
4804
  undefined,
4706
4805
  typeof event.sdkRunToken === "string" ? event.sdkRunToken : undefined,
4806
+ event.sdkRunTokens,
4707
4807
  ),
4708
4808
  owner,
4709
4809
  );
@@ -4880,7 +4980,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4880
4980
  "tool_execution_update",
4881
4981
  "tool_execution_end",
4882
4982
  ]);
4883
- const streamTurnEvent = (event: { type?: unknown } | null | undefined, ctx: ExtensionContext): void => {
4983
+ const streamTurnEvent = (event: AgentSessionEvent | null | undefined, ctx: ExtensionContext): void => {
4884
4984
  const current = lifecycleStateForContext(ctx, "agent_start");
4885
4985
  const activeInvocation = current?.activeInvocation;
4886
4986
  if (!current) return;
@@ -4899,42 +4999,37 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4899
4999
  ? (current.attachedInvocations ?? [])
4900
5000
  : [];
4901
5001
  if (invocations.length === 0) return;
4902
- // The handlers below also renew prompt deadlines, so streaming must never
4903
- // throw out of them: a frame the wire producer does not recognize is dropped.
5002
+ // Content bypasses the lifecycle replay ring and must never interrupt its producer.
4904
5003
  if (!event || typeof event.type !== "string" || !STREAMED_TURN_EVENT_TYPES.has(event.type)) return;
4905
- try {
4906
- const payload = toAgentWireEventPayload(event as AgentSessionEvent);
4907
- // One frame per owning invocation, each carrying its own correlation, so a
4908
- // shared run lets every submitter attribute the content to its own prompt.
4909
- for (const invocation of invocations) {
4910
- if (invocation.connectionId === undefined) continue;
4911
- current.runtime.sendFrameTo([invocation.connectionId], {
4912
- type: "event",
4913
- kind: event.type,
4914
- payload,
4915
- ...invocation.correlation,
4916
- });
5004
+ // emitLifecycle("agent_start") awaits durable persistence before it
5005
+ // publishes the correlated start frame, while this subscription is
5006
+ // synchronous. Hold content until that start is on the wire so no consumer
5007
+ // sees turn content before the turn it belongs to.
5008
+ if (batch && !batch.startPublished) {
5009
+ batch.heldContent.push({ event: event as AgentSessionEvent, recipients: invocations });
5010
+ if (batch.heldContent.length > MAX_HELD_CONTENT_EVENTS) {
5011
+ if (!batch.heldContentDropped) {
5012
+ batch.heldContentDropped = true;
5013
+ logger.warn(
5014
+ "sdk: agent_start persistence still pending; dropping oldest held turn content to bound memory",
5015
+ {
5016
+ epoch: batch.epoch,
5017
+ bound: MAX_HELD_CONTENT_EVENTS,
5018
+ invocations: batch.invocations.length,
5019
+ },
5020
+ );
5021
+ }
5022
+ batch.heldContent.splice(0, batch.heldContent.length - MAX_HELD_CONTENT_EVENTS);
4917
5023
  }
4918
- } catch {
4919
- // Streamed content is best-effort; the turn producing it is authoritative.
5024
+ return;
4920
5025
  }
5026
+ publishContentFrames(current, event as AgentSessionEvent, invocations);
4921
5027
  };
4922
- api.on("tool_execution_start", async (event, ctx) => {
5028
+ api.on("tool_execution_start", async (_event, ctx) => {
4923
5029
  renewAttributableProgress("tool_execution_start", ctx);
4924
- streamTurnEvent(event, ctx);
4925
5030
  });
4926
- api.on("tool_execution_update", async (event, ctx) => {
4927
- streamTurnEvent(event, ctx);
4928
- });
4929
- api.on("tool_execution_end", async (event, ctx) => {
5031
+ api.on("tool_execution_end", async (_event, ctx) => {
4930
5032
  renewAttributableProgress("tool_execution_end", ctx);
4931
- streamTurnEvent(event, ctx);
4932
- });
4933
- api.on("message_update", async (event, ctx) => {
4934
- streamTurnEvent(event, ctx);
4935
- });
4936
- api.on("message_end", async (event, ctx) => {
4937
- streamTurnEvent(event, ctx);
4938
5033
  });
4939
5034
  const errorCode = (error: unknown): string | undefined =>
4940
5035
  typeof error === "object" &&
@@ -5003,6 +5098,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5003
5098
  correlation: InvocationCorrelation;
5004
5099
  connectionId: string | undefined;
5005
5100
  }>;
5101
+ startPublished: boolean;
5102
+ heldContentDropped: boolean;
5103
+ heldContent: Array<{
5104
+ event: AgentSessionEvent;
5105
+ recipients: Array<{ correlation: InvocationCorrelation; connectionId: string | undefined }>;
5106
+ }>;
5006
5107
  }> = [];
5007
5108
  const configRevision = { current: 0 };
5008
5109
  let acceptingGateResolutions = true;
@@ -5598,9 +5699,15 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5598
5699
  });
5599
5700
  }
5600
5701
  });
5702
+ // Extension message_update hooks are queued while final/tool hooks are not.
5703
+ // Use only the synchronous session boundary so those paths cannot reorder or duplicate content.
5704
+ const disposeStream = ctx.onSessionEvent?.(event => {
5705
+ if (STREAMED_TURN_EVENT_TYPES.has(event.type)) streamTurnEvent(event, ctx);
5706
+ });
5601
5707
  const disposeGate = (): void => {
5602
5708
  disposeWorkflowGate?.();
5603
5709
  disposeFold?.();
5710
+ disposeStream?.();
5604
5711
  };
5605
5712
  let publishedEndpointUrl: string | undefined;
5606
5713
  let brokerRegistered = false;
@@ -5811,7 +5918,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5811
5918
  retiredLifecycleOwnerTimers.set(current, timer);
5812
5919
  } else current.deadlineManager.clearAll();
5813
5920
  current.disposeGate?.();
5814
- await current.runtime.stop();
5921
+ await current.runtime.stop({ allowLockContention: cancelSkillRecovery });
5815
5922
  } catch (error) {
5816
5923
  // Keep the immutable owner available for a retry when transport teardown
5817
5924
  // fails after quiescing. Clearing `active` before stop prevents a second
@@ -200,6 +200,7 @@ export interface SessionLifecycleSessionResult {
200
200
  readonly sessionId: string;
201
201
  readonly cwd?: string;
202
202
  readonly endpointGeneration?: number;
203
+ readonly endpointIncarnation?: string;
203
204
  readonly reused?: boolean;
204
205
  readonly note?: string;
205
206
  }
@@ -511,6 +512,7 @@ function sessionResult(value: unknown, expectedSessionId?: string): SessionLifec
511
512
  sessionId: string;
512
513
  cwd?: string;
513
514
  endpointGeneration?: number;
515
+ endpointIncarnation?: string;
514
516
  reused?: boolean;
515
517
  note?: string;
516
518
  } = { sessionId };
@@ -518,6 +520,8 @@ function sessionResult(value: unknown, expectedSessionId?: string): SessionLifec
518
520
  const endpointGeneration = record.endpointGeneration;
519
521
  if (typeof endpointGeneration === "number" && Number.isSafeInteger(endpointGeneration) && endpointGeneration > 0)
520
522
  result.endpointGeneration = endpointGeneration;
523
+ if (typeof record.endpointIncarnation === "string" && /^[a-f0-9]{64}$/.test(record.endpointIncarnation))
524
+ result.endpointIncarnation = record.endpointIncarnation;
521
525
  if (typeof record.reused === "boolean") result.reused = record.reused;
522
526
  if (typeof record.note === "string") result.note = record.note;
523
527
  return result;
@@ -2940,6 +2940,17 @@
2940
2940
  "testIds": "not_applicable"
2941
2941
  }
2942
2942
  },
2943
+ {
2944
+ "sourceId": "agent_session:awaitCoordinatorRuntimeStatePersistenceForTests",
2945
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
2946
+ "sourceKind": "agent_session",
2947
+ "decision": "exclude",
2948
+ "rationale": "test-only coordinator persistence drain barrier, not a user-facing SDK control seam",
2949
+ "exclusionMetadata": {
2950
+ "adapterMappings": "not_applicable",
2951
+ "testIds": "not_applicable"
2952
+ }
2953
+ },
2943
2954
  {
2944
2955
  "sourceId": "agent_session:queueCoordinatorRuntimeStatePersistForTests",
2945
2956
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { logger, resolveEquivalentPath } from "@gajae-code/utils";
5
+ import { matchesIndexedEndpointFile } from "../broker/endpoint-authority";
5
6
  import {
6
7
  canonicalSessionCwd,
7
8
  SessionIndex as DefaultSessionIndex,
@@ -11,7 +12,13 @@ import {
11
12
  type SessionIndex,
12
13
  } from "../broker/session-index";
13
14
  import { cancellableSleep, lifecycleRequestTimeoutMs } from "../broker/startup-budget";
14
- import { SdkClient, SdkClientError, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
15
+ import {
16
+ SdkClient,
17
+ SdkClientError,
18
+ type SdkDispatchContext,
19
+ type SdkDispatchHandler,
20
+ SdkPreparedDispatchError,
21
+ } from "../client/client";
15
22
  import {
16
23
  endpointDirectory,
17
24
  readSdkBrokerDiscovery,
@@ -27,6 +34,7 @@ import {
27
34
  import { ACP_SESSION_RECONNECT, SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
28
35
 
29
36
  export interface SessionEndpointIdentity {
37
+ readonly dev: bigint;
30
38
  readonly mtimeMs: number;
31
39
  readonly mtimeNs: bigint;
32
40
  readonly ctimeNs: bigint;
@@ -56,6 +64,7 @@ export function sessionAttachmentAuthorityId(input: {
56
64
  .digest("hex");
57
65
  const endpointIdentity = input.endpointIdentity
58
66
  ? {
67
+ dev: input.endpointIdentity.dev.toString(),
59
68
  mtimeMs: input.endpointIdentity.mtimeMs,
60
69
  mtimeNs: input.endpointIdentity.mtimeNs.toString(),
61
70
  ctimeNs: input.endpointIdentity.ctimeNs.toString(),
@@ -86,7 +95,13 @@ export interface SessionAttachment {
86
95
  readonly connectionId?: string;
87
96
  readonly generation: number;
88
97
  isCurrent(): boolean;
89
- send(frame: Record<string, unknown>): unknown;
98
+ send(
99
+ frame: Record<string, unknown>,
100
+ options?: {
101
+ beforeDispatch?: () => void;
102
+ dispatchFence?: (dispatch: () => Promise<void>) => Promise<void>;
103
+ },
104
+ ): unknown;
90
105
  /**
91
106
  * Idempotent provider-lease heartbeat send that skips the pre-send authority
92
107
  * reconcile (#4689).
@@ -179,6 +194,7 @@ export interface SessionRouterClient {
179
194
  frame: Record<string, unknown>,
180
195
  options?: {
181
196
  timeoutMs?: number;
197
+ connectedOnly?: boolean;
182
198
  /** Synchronous pre-send observer; a throw aborts the dispatch before the wire. */
183
199
  beforeDispatch?: (context: SdkDispatchContext) => void;
184
200
  /** Synchronous post-send boundary observer for transport-close-aware consumers. */
@@ -456,6 +472,7 @@ async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | un
456
472
  )
457
473
  return undefined;
458
474
  return {
475
+ dev: identity.dev,
459
476
  mtimeMs: stat.mtimeMs,
460
477
  mtimeNs: identity.mtimeNs,
461
478
  ctimeNs: identity.ctimeNs,
@@ -470,6 +487,7 @@ function warningAffectsSession(warning: string, sessionId: string): boolean {
470
487
 
471
488
  function sameEndpointIdentity(expected: SessionEndpointIdentity, current: SessionEndpointIdentity): boolean {
472
489
  return (
490
+ expected.dev === current.dev &&
473
491
  expected.mtimeMs === current.mtimeMs &&
474
492
  expected.mtimeNs === current.mtimeNs &&
475
493
  expected.ctimeNs === current.ctimeNs &&
@@ -909,6 +927,7 @@ export class SessionRouter {
909
927
  timeoutMs?: number;
910
928
  beforeDispatch?: (context: SdkDispatchContext) => void;
911
929
  onDispatch?: SdkDispatchHandler;
930
+ dispatchFence?: (dispatch: () => Promise<Record<string, unknown>>) => Promise<Record<string, unknown>>;
912
931
  },
913
932
  ): Promise<Record<string, unknown>> {
914
933
  const matchesExpectedAuthority = (attachment: SessionAttachment): boolean =>
@@ -946,31 +965,34 @@ export class SessionRouter {
946
965
  // token: the wire frame alone carries credentials, and the observer
947
966
  // context is a deep-frozen, token-redacted copy (#4640 review).
948
967
  const wireFrame = this.#prepareFrame(attached, frame);
949
- const { beforeDispatch, onDispatch, ...requestOptions } = options ?? {};
950
- const response = await attached.client.request(wireFrame, {
951
- ...requestOptions,
952
- timeoutMs: requestOptions.timeoutMs ?? SESSION_REQUEST_TIMEOUT_MS,
953
- ...(beforeDispatch
954
- ? {
955
- beforeDispatch: (context: SdkDispatchContext) => {
956
- return beforeDispatch({
957
- ...context,
958
- frame: redactDispatchFrame(context.frame),
959
- });
960
- },
961
- }
962
- : {}),
963
- ...(onDispatch
964
- ? {
965
- onDispatch: (context: SdkDispatchContext) => {
966
- return onDispatch({
967
- ...context,
968
- frame: redactDispatchFrame(context.frame),
969
- });
970
- },
971
- }
972
- : {}),
973
- });
968
+ const { beforeDispatch, onDispatch, dispatchFence, ...requestOptions } = options ?? {};
969
+ try {
970
+ await attached.client.connect?.();
971
+ } catch (error) {
972
+ if (error instanceof SdkClientError) throw new SdkPreparedDispatchError(error);
973
+ throw error;
974
+ }
975
+ if (!this.#attachmentPublished(attached))
976
+ throw new SessionRouterError("pre_send", "SDK session attachment changed during transport preparation.");
977
+ const dispatch = () =>
978
+ attached.client.request(wireFrame, {
979
+ ...requestOptions,
980
+ connectedOnly: true,
981
+ timeoutMs: requestOptions.timeoutMs ?? SESSION_REQUEST_TIMEOUT_MS,
982
+ ...(beforeDispatch
983
+ ? {
984
+ beforeDispatch: (context: SdkDispatchContext) =>
985
+ beforeDispatch({ ...context, frame: redactDispatchFrame(context.frame) }),
986
+ }
987
+ : {}),
988
+ ...(onDispatch
989
+ ? {
990
+ onDispatch: (context: SdkDispatchContext) =>
991
+ onDispatch({ ...context, frame: redactDispatchFrame(context.frame) }),
992
+ }
993
+ : {}),
994
+ });
995
+ const response = await (dispatchFence ? dispatchFence(dispatch) : dispatch());
974
996
  const settled = this.#sessions.get(sessionId);
975
997
  if (
976
998
  !settled ||
@@ -1141,7 +1163,12 @@ export class SessionRouter {
1141
1163
  }
1142
1164
 
1143
1165
  /** Lists saved sessions through Router-owned Broker discovery without exposing credentials or mutation authority. */
1144
- async listBrokerSessions(input: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>> {
1166
+ async listBrokerSessions(
1167
+ input: Record<string, unknown>,
1168
+ idempotencyKey: string,
1169
+ options?: { beforeDispatch?: (context: SdkDispatchContext) => void },
1170
+ dispatchFence?: (dispatch: () => Promise<Record<string, unknown>>) => Promise<Record<string, unknown>>,
1171
+ ): Promise<Record<string, unknown>> {
1145
1172
  const operation = "session.list";
1146
1173
  const discovery = await readSdkBrokerDiscovery(this.#agentDir);
1147
1174
  if (!discovery) throw new SessionRouterError("pre_send", "SDK broker discovery is unavailable.");
@@ -1155,10 +1182,18 @@ export class SessionRouter {
1155
1182
  }
1156
1183
  try {
1157
1184
  const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
1158
- return await client.request(
1159
- { type: "broker_request", operation, input, idempotencyKey },
1160
- timeoutMs === undefined ? undefined : { timeoutMs },
1161
- );
1185
+ try {
1186
+ await client.connect?.();
1187
+ } catch (error) {
1188
+ if (error instanceof SdkClientError) throw new SdkPreparedDispatchError(error);
1189
+ throw error;
1190
+ }
1191
+ const dispatch = () =>
1192
+ client.request(
1193
+ { type: "broker_request", operation, input, idempotencyKey },
1194
+ { ...(timeoutMs === undefined ? {} : { timeoutMs }), ...options, connectedOnly: true },
1195
+ );
1196
+ return await (dispatchFence ? dispatchFence(dispatch) : dispatch());
1162
1197
  } finally {
1163
1198
  await client.close().catch(error => {
1164
1199
  logger.warn(`SDK Broker session.list transport cleanup failed (${String(error)}).`);
@@ -1360,7 +1395,7 @@ export class SessionRouter {
1360
1395
  // cannot keep the old attachment authorized.
1361
1396
  const identityBefore = await lstatEndpoint(attached.endpoint.path);
1362
1397
  if (!identityBefore || !sameEndpointIdentity(attached.endpointIdentity, identityBefore)) return false;
1363
- if (identityBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
1398
+ if (!matchesIndexedEndpointFile(identityBefore, indexed)) return false;
1364
1399
  let raw: Record<string, unknown>;
1365
1400
  try {
1366
1401
  const parsed = JSON.parse(await Bun.file(attached.endpoint.path).text());
@@ -1384,7 +1419,7 @@ export class SessionRouter {
1384
1419
  const identityAfter = await lstatEndpoint(attached.endpoint.path);
1385
1420
  return (
1386
1421
  identityAfter !== undefined &&
1387
- identityAfter.mtimeMs === indexed.endpointMtimeMs &&
1422
+ matchesIndexedEndpointFile(identityAfter, indexed) &&
1388
1423
  sameEndpointIdentity(attached.endpointIdentity, identityAfter)
1389
1424
  );
1390
1425
  }
@@ -1416,7 +1451,7 @@ export class SessionRouter {
1416
1451
  const endpointIdentity = await lstatEndpoint(endpointPath);
1417
1452
  const endpoint = await readSdkSessionEndpoint(cwd, indexed.sessionId, scope);
1418
1453
  if (!endpoint || endpoint.stale || endpoint.pid !== indexed.pid) return null;
1419
- if (!endpointIdentity || endpointIdentity.mtimeMs !== indexed.endpointMtimeMs) return null;
1454
+ if (!endpointIdentity || !matchesIndexedEndpointFile(endpointIdentity, indexed)) return null;
1420
1455
  // Identity is proven INSIDE this authority read (#4730 review): sampling it
1421
1456
  // afterwards would let an identical rename between the read and the sample
1422
1457
  // install the replacement's inode as the trusted baseline.
@@ -1441,7 +1476,7 @@ export class SessionRouter {
1441
1476
  const endpointIdentityAfterRead = await lstatEndpoint(endpoint.path);
1442
1477
  if (
1443
1478
  !endpointIdentityAfterRead ||
1444
- endpointIdentityAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1479
+ !matchesIndexedEndpointFile(endpointIdentityAfterRead, indexed) ||
1445
1480
  !sameEndpointIdentity(endpointIdentity, endpointIdentityAfterRead)
1446
1481
  )
1447
1482
  return null;
@@ -1641,7 +1676,13 @@ export class SessionRouter {
1641
1676
  return attached?.client.connectionId;
1642
1677
  },
1643
1678
  isCurrent: () => attached !== undefined && this.#attachmentPublished(attached),
1644
- send: async (frame: Record<string, unknown>) => {
1679
+ send: async (
1680
+ frame: Record<string, unknown>,
1681
+ options?: {
1682
+ beforeDispatch?: () => void;
1683
+ dispatchFence?: (dispatch: () => Promise<void>) => Promise<void>;
1684
+ },
1685
+ ) => {
1645
1686
  if (!attached || !this.#attachmentPublished(attached))
1646
1687
  throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1647
1688
  if (attached.initializingPublication) {
@@ -1659,7 +1700,17 @@ export class SessionRouter {
1659
1700
  } else await this.#serialReconcile(runEpoch, true, true);
1660
1701
  if (!attached || !this.#attachmentPublished(attached))
1661
1702
  throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1662
- attached.client.send(this.#prepareFrame(attached, frame));
1703
+ const dispatchAttachment = attached;
1704
+ const dispatch = async () => {
1705
+ options?.beforeDispatch?.();
1706
+ try {
1707
+ dispatchAttachment.client.send(this.#prepareFrame(dispatchAttachment, frame));
1708
+ } catch (error) {
1709
+ if (error instanceof SdkClientError) throw new SdkPreparedDispatchError(error);
1710
+ throw error;
1711
+ }
1712
+ };
1713
+ await (options?.dispatchFence ? options.dispatchFence(dispatch) : dispatch());
1663
1714
  },
1664
1715
  /**
1665
1716
  * Provider-lease heartbeats skip the pre-send authority reconcile