@gajae-code/coding-agent 0.18.1 → 0.18.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 (128) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/cli-commands.d.ts +10 -0
  4. package/dist/types/cli-ordinary.d.ts +13 -0
  5. package/dist/types/cli.d.ts +12 -3
  6. package/dist/types/commands/contribution-prep.d.ts +0 -3
  7. package/dist/types/commands/sdk.d.ts +3 -0
  8. package/dist/types/commands/update.d.ts +1 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  10. package/dist/types/config/model-profile-activation.d.ts +19 -0
  11. package/dist/types/config/model-registry.d.ts +16 -1
  12. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  13. package/dist/types/edit/modes/replace.d.ts +3 -3
  14. package/dist/types/edit/streaming.d.ts +2 -2
  15. package/dist/types/exa/mcp-client.d.ts +1 -5
  16. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  17. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  18. package/dist/types/hashline/parser.d.ts +28 -5
  19. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  20. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  22. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  23. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  24. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  25. package/dist/types/sdk/broker/runtime.d.ts +21 -1
  26. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  27. package/dist/types/sdk/bus/index.d.ts +1 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  30. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  31. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  32. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  33. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  34. package/dist/types/sdk/index.d.ts +1 -0
  35. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  36. package/dist/types/sdk/router/session-router.d.ts +2 -0
  37. package/dist/types/sdk/session.d.ts +13 -0
  38. package/dist/types/session/agent-session.d.ts +5 -1
  39. package/dist/types/tools/ask-contract.d.ts +75 -254
  40. package/dist/types/tools/bash.d.ts +1 -0
  41. package/package.json +7 -7
  42. package/scripts/build-sdk-package-smoke.ts +5 -0
  43. package/src/cli/public-command-entry.ts +5 -0
  44. package/src/cli/ssh-cli.ts +6 -3
  45. package/src/cli/stats-cli.ts +4 -36
  46. package/src/cli/web-search-cli.ts +9 -3
  47. package/src/cli-commands.ts +14 -0
  48. package/src/cli-main.ts +4 -8
  49. package/src/cli-ordinary.ts +197 -0
  50. package/src/cli.ts +25 -186
  51. package/src/commands/commit.ts +2 -2
  52. package/src/commands/contribution-prep.ts +1 -3
  53. package/src/commands/sdk.ts +60 -2
  54. package/src/commands/update.ts +15 -1
  55. package/src/config/autorouting-tier-map.ts +20 -2
  56. package/src/config/file-lock.ts +91 -2
  57. package/src/config/model-preset-registry.ts +17 -2
  58. package/src/config/model-profile-activation.ts +73 -5
  59. package/src/config/model-profile-contract.ts +4 -1
  60. package/src/config/model-profiles.ts +29 -29
  61. package/src/config/model-registry.ts +45 -17
  62. package/src/config/model-resolver.ts +9 -1
  63. package/src/coordinator-mcp/event-webhook.ts +19 -14
  64. package/src/coordinator-mcp/policy.ts +14 -4
  65. package/src/coordinator-mcp/server.ts +142 -7
  66. package/src/coordinator-mcp/session-reaper.ts +9 -1
  67. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  68. package/src/edit/modes/replace.ts +6 -6
  69. package/src/edit/streaming.ts +15 -15
  70. package/src/exa/mcp-client.ts +41 -8
  71. package/src/exec/bash-shell-guardian.ts +184 -34
  72. package/src/exec/bash-shell-supervisor.ts +60 -3
  73. package/src/hashline/execute.ts +51 -3
  74. package/src/hashline/parser.ts +63 -16
  75. package/src/internal-urls/docs-index.generated.ts +29 -28
  76. package/src/memories/index.ts +8 -2
  77. package/src/modes/acp/acp-agent.ts +688 -371
  78. package/src/modes/components/tool-execution.ts +1 -1
  79. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  80. package/src/prompts/tools/ask.md +2 -0
  81. package/src/sdk/broker/broker.ts +46 -0
  82. package/src/sdk/broker/daemon-entry.ts +67 -6
  83. package/src/sdk/broker/discovery.ts +8 -0
  84. package/src/sdk/broker/ensure.ts +156 -20
  85. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  86. package/src/sdk/broker/lifecycle.ts +108 -34
  87. package/src/sdk/broker/recovery-backoff.ts +135 -0
  88. package/src/sdk/broker/runtime.ts +88 -1
  89. package/src/sdk/broker/startup-budget.ts +6 -1
  90. package/src/sdk/broker/transport.ts +5 -1
  91. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  92. package/src/sdk/bus/index.ts +305 -19
  93. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  94. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  95. package/src/sdk/bus/telegram-daemon.ts +34 -1
  96. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  97. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  98. package/src/sdk/host/control/dispatch.ts +52 -4
  99. package/src/sdk/host/control/operations.ts +1 -1
  100. package/src/sdk/host/session-runtime.test.ts +40 -2
  101. package/src/sdk/host/session-runtime.ts +112 -8
  102. package/src/sdk/index.ts +1 -0
  103. package/src/sdk/lifecycle-session.ts +1 -0
  104. package/src/sdk/prompt-watchdog.ts +17 -4
  105. package/src/sdk/protocol/operation-registry.ts +1 -1
  106. package/src/sdk/router/session-router.ts +7 -1
  107. package/src/sdk/session.ts +37 -8
  108. package/src/session/agent-session.ts +128 -41
  109. package/src/session/blob-store.ts +78 -33
  110. package/src/session/internal/managed-session-storage.ts +119 -3
  111. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  112. package/src/setup/hermes-setup.ts +4 -1
  113. package/src/slash-commands/builtin-registry.ts +8 -5
  114. package/src/tools/ask-contract.test.ts +76 -1
  115. package/src/tools/ask-contract.ts +57 -6
  116. package/src/tools/ask.ts +16 -7
  117. package/src/tools/bash.ts +77 -29
  118. package/src/tools/descriptors.test.ts +29 -0
  119. package/src/tools/tool-catalog.generated.ts +7 -4
  120. package/src/web/search/index.ts +2 -2
  121. package/dist/types/cli/grep-cli.d.ts +0 -17
  122. package/dist/types/cli/shell-cli.d.ts +0 -8
  123. package/dist/types/commands/grep.d.ts +0 -42
  124. package/dist/types/commands/shell.d.ts +0 -21
  125. package/src/cli/grep-cli.ts +0 -160
  126. package/src/cli/shell-cli.ts +0 -176
  127. package/src/commands/grep.ts +0 -48
  128. package/src/commands/shell.ts +0 -29
@@ -93,6 +93,7 @@ const IDEMPOTENCY_TTL_MS = 15 * 60 * 1_000;
93
93
  const MAX_IDEMPOTENCY_ENTRIES = 256;
94
94
 
95
95
  const sessionChains = new WeakMap<ControlSurface, Promise<void>>();
96
+ const pendingReplacementBarriers = new WeakMap<ControlSurface, Promise<void>>();
96
97
  interface IdempotencyEntry {
97
98
  hash: string;
98
99
  expiresAt: number;
@@ -189,6 +190,19 @@ function invalidInput(message: string): never {
189
190
  throw new TypedControlError("invalid_input", message);
190
191
  }
191
192
 
193
+ const EXPECTED_SDK_RUN_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}:[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
194
+
195
+ function enqueueReplacementBarrier(surface: ControlSurface): () => void {
196
+ const previous = pendingReplacementBarriers.get(surface) ?? Promise.resolve();
197
+ const { promise: current, resolve: resolveCurrent } = Promise.withResolvers<void>();
198
+ const barrier = previous.then(() => current);
199
+ pendingReplacementBarriers.set(surface, barrier);
200
+ return () => {
201
+ resolveCurrent();
202
+ if (pendingReplacementBarriers.get(surface) === barrier) pendingReplacementBarriers.delete(surface);
203
+ };
204
+ }
205
+
192
206
  /**
193
207
  * C04 `turn.abort` dispatch.
194
208
  *
@@ -244,7 +258,11 @@ function invoke(
244
258
  case "turn.prompt":
245
259
  return surface.prompt(text(input), input.images, input.clientRef as string | undefined);
246
260
  case "turn.steer":
247
- return surface.steer(text(input), typeof input.clientRef === "string" ? input.clientRef : undefined);
261
+ return surface.steer(
262
+ text(input),
263
+ typeof input.clientRef === "string" ? input.clientRef : undefined,
264
+ Object.hasOwn(input, "expectedSdkRunToken") ? (input.expectedSdkRunToken as string) : undefined,
265
+ );
248
266
  case "turn.follow_up":
249
267
  return surface.followUp(text(input));
250
268
  case "turn.abort":
@@ -486,6 +504,15 @@ export function dispatchControl(
486
504
  );
487
505
  if (!isInput(request.input))
488
506
  return Promise.resolve(failure(request.id, "invalid_input", "Control input must be an object."));
507
+ if (
508
+ row.sdkId === "turn.steer" &&
509
+ Object.hasOwn(request.input, "expectedSdkRunToken") &&
510
+ (typeof request.input.expectedSdkRunToken !== "string" ||
511
+ !EXPECTED_SDK_RUN_TOKEN_PATTERN.test(request.input.expectedSdkRunToken))
512
+ )
513
+ return Promise.resolve(
514
+ failure(request.id, "invalid_input", "expectedSdkRunToken must be a commandId:turnId pair."),
515
+ );
489
516
  if (
490
517
  row.sdkId === "turn.abort" &&
491
518
  ((brokerAbortFieldPresent && !brokerAbortAuthorized) ||
@@ -538,10 +565,31 @@ export function dispatchControl(
538
565
  const work = () => execute(surface, row, dispatchRequest);
539
566
  if (row.sdkId === "turn.abort_and_prompt") {
540
567
  const cancellable = surface as PreflightCancellableSurface;
541
- if (Object.hasOwn(cancellable, "cancelPendingPreflights")) cancellable.cancelPendingPreflights?.();
542
- return serialize(surface, work);
568
+ const releaseBarrier = enqueueReplacementBarrier(surface);
569
+ try {
570
+ if (Object.hasOwn(cancellable, "cancelPendingPreflights")) cancellable.cancelPendingPreflights?.();
571
+ return serialize(surface, async () => {
572
+ try {
573
+ return await work();
574
+ } finally {
575
+ releaseBarrier();
576
+ }
577
+ });
578
+ } catch (error) {
579
+ releaseBarrier();
580
+ throw error;
581
+ }
543
582
  }
544
583
  if (row.idempotency === "idempotent" && dispatchRequest.idempotencyKey)
545
584
  return idempotent(surface, row, dispatchRequest, work);
546
- return row.idempotency === "ordered" && row.sdkId !== "retry.now" ? serialize(surface, work) : work();
585
+ if (row.sdkId === "turn.steer") {
586
+ const replacementBarrier = pendingReplacementBarriers.get(surface);
587
+ return replacementBarrier ? replacementBarrier.then(work) : work();
588
+ }
589
+ // Active feedback must reach the worker even while an earlier ordered control
590
+ // waits for that worker. Correlated steer admission remains deduplicated by
591
+ // the runtime reconciliation store, independently of this dispatch chain.
592
+ return row.idempotency === "ordered" && row.sdkId !== "retry.now" && row.sdkId !== "turn.steer"
593
+ ? serialize(surface, work)
594
+ : work();
547
595
  }
@@ -22,7 +22,7 @@ export type ControlInput = Record<string, unknown>;
22
22
  */
23
23
  export interface ControlSurface {
24
24
  prompt(text: string, images?: ControlValue, clientRef?: string): Promise<ControlValue> | ControlValue;
25
- steer(text: string, clientRef?: string): Promise<ControlValue> | ControlValue;
25
+ steer(text: string, clientRef?: string, expectedSdkRunToken?: string): Promise<ControlValue> | ControlValue;
26
26
  followUp(text: string): Promise<ControlValue> | ControlValue;
27
27
  abort(): Promise<ControlValue> | ControlValue;
28
28
  /** Terminal abort: stop the current root turn (and optionally exact owned work). */
@@ -290,7 +290,7 @@ test("session.last_assistant returns the latest projected readable text past non
290
290
  });
291
291
  });
292
292
 
293
- test("session.last_assistant returns resource_gone when the projected transcript has no readable assistant text", async () => {
293
+ test("session.last_assistant returns null when the projected transcript has no readable assistant text", async () => {
294
294
  const sessionId = "last-assistant-empty";
295
295
  const ctx = extensionContext(sessionId, "/tmp", {
296
296
  transcript: [
@@ -311,7 +311,7 @@ test("session.last_assistant returns resource_gone when the projected transcript
311
311
  ],
312
312
  });
313
313
 
314
- expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok: false, error: { code: "resource_gone" } });
314
+ expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok: true, page: { items: [null] } });
315
315
  });
316
316
 
317
317
  test("native prompt reconciliation fails closed for an explicitly empty assistant result", () => {
@@ -4142,6 +4142,7 @@ describe("SessionSdkSessionRuntime", () => {
4142
4142
  interface PreflightHooks {
4143
4143
  onPreflightAccepted?: () => void;
4144
4144
  onPreflightAcceptCommit?: () => void | Promise<void>;
4145
+ expectedSdkRunToken?: string;
4145
4146
  }
4146
4147
 
4147
4148
  interface ResponseFrame {
@@ -4482,6 +4483,43 @@ function neverSettlingPromise(): Promise<void> {
4482
4483
  return Promise.withResolvers<void>().promise;
4483
4484
  }
4484
4485
 
4486
+ test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
4487
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
4488
+ const staleToken = "ended-command:ended-turn";
4489
+ const clientRef = "stale-steer-ref";
4490
+ const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
4491
+ let harness: InvocationHarness | undefined;
4492
+ try {
4493
+ harness = await invocationHarness("steer-run-token", cwd, {
4494
+ sendUserMessage: async (content, options) => {
4495
+ calls.push({
4496
+ content,
4497
+ deliverAs: options?.deliverAs,
4498
+ expectedSdkRunToken: options?.expectedSdkRunToken,
4499
+ });
4500
+ if (options?.expectedSdkRunToken === staleToken)
4501
+ throw Object.assign(new Error("The expected SDK run is not active."), { code: "turn_not_active" });
4502
+ return "completed";
4503
+ },
4504
+ });
4505
+
4506
+ const rejected = await harness.control("turn.steer", {
4507
+ text: "stale steer",
4508
+ expectedSdkRunToken: staleToken,
4509
+ clientRef,
4510
+ });
4511
+ expect(rejected).toMatchObject({ ok: false, error: { code: "turn_not_active" } });
4512
+ expect(calls).toEqual([{ content: "stale steer", deliverAs: "steer", expectedSdkRunToken: staleToken }]);
4513
+ expect(await harness.query("turn.steer_status", { clientRef })).toMatchObject({
4514
+ ok: true,
4515
+ result: { status: "rejected" },
4516
+ });
4517
+ } finally {
4518
+ if (harness) await harness.stop();
4519
+ await rm(cwd, { recursive: true, force: true });
4520
+ }
4521
+ });
4522
+
4485
4523
  describe("post-acceptance invocation terminalization", () => {
4486
4524
  test.each([
4487
4525
  { status: 402, code: "provider_http_402" },
@@ -50,6 +50,12 @@ import { parseThinkingLevel } from "../../thinking";
50
50
  import { readEndpointFile } from "../broker/endpoint-authority";
51
51
  import { ensureBroker } from "../broker/ensure";
52
52
  import { processIncarnation } from "../broker/process-incarnation";
53
+ import { RecoveryBackoffTracker } from "../broker/recovery-backoff";
54
+ import {
55
+ captureRuntimeImageIdentity,
56
+ isSdkInternalRuntimeImageReplaced,
57
+ sdkInternalRuntimeImage,
58
+ } from "../broker/runtime";
53
59
  import {
54
60
  type MasterRoleAttestationV2,
55
61
  resolveSessionLocator,
@@ -716,6 +722,15 @@ export interface CreateSdkSessionRuntimeOptions {
716
722
  onFailureDiagnosticKeyCountForTests?: (count: number) => void;
717
723
  /** Test-only observation after a resolved invocation completion is reconciled. */
718
724
  onInvocationCompletionReconciledForTests?: (kind: InvocationKind, correlation: InvocationCorrelation) => void;
725
+ /** Test hook: provides a function to inject a controlled clock for broker recovery backoff assertions. */
726
+ setRecoveryBackoffClockForTest?: (inject: (clock: { now(): number }) => void) => void;
727
+ /** Test hook: inject mock runtime image identity and replacement detection for broker recovery tests. */
728
+ setRuntimeImageIdentityForTest?: (
729
+ register: (
730
+ captureFn: () => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined,
731
+ isReplacedFn: () => Promise<boolean>,
732
+ ) => void,
733
+ ) => void;
719
734
  }
720
735
 
721
736
  function unavailable(operation: string): () => never {
@@ -2044,7 +2059,7 @@ function createQuerySurface(
2044
2059
  : entry.content?.flatMap(block => (block.type === "text" ? [block.text] : [])).join("\n");
2045
2060
  if (text !== undefined && text.trim().length > 0) return text;
2046
2061
  }
2047
- return undefined;
2062
+ return null;
2048
2063
  };
2049
2064
  const getProfileCredentialSessionId = () => ctx.credentialSessionId ?? id;
2050
2065
  const profileSettings = (options.settings ?? ctx.settings) as Pick<Settings, "get"> | undefined;
@@ -2899,6 +2914,7 @@ function createControlSurface(
2899
2914
  };
2900
2915
  type InternalSendOptions = NonNullable<Parameters<ExtensionAPI["sendUserMessage"]>[1]> & {
2901
2916
  sdkRunCapability?: SdkRunCapability;
2917
+ expectedSdkRunToken?: string;
2902
2918
  };
2903
2919
  type InternalSdkApi = Omit<ExtensionAPI, "sendUserMessage"> & {
2904
2920
  sendUserMessage: (
@@ -4228,23 +4244,30 @@ function createControlSurface(
4228
4244
  ),
4229
4245
  );
4230
4246
  },
4231
- steer: async (text, clientRef) => {
4247
+ steer: async (text, clientRef, expectedSdkRunToken) => {
4232
4248
  const invalid = validateRequiredPromptText("turn.steer", { text });
4233
4249
  if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code });
4234
4250
  const retainedClientRef = normalizeClientRef(clientRef);
4251
+ const sendSteer = () =>
4252
+ sendSdkUserMessage(text, {
4253
+ deliverAs: "steer",
4254
+ ...(expectedSdkRunToken === undefined ? {} : { expectedSdkRunToken }),
4255
+ });
4235
4256
  if (retainedClientRef === undefined) {
4236
4257
  const correlation = newCorrelation();
4237
- await sendSdkUserMessage(text, { deliverAs: "steer" });
4258
+ await sendSteer();
4238
4259
  return { accepted: true, ...correlation };
4239
4260
  }
4240
4261
  const durable = steerReconciliation;
4241
4262
  const reservation = await durable.reserveSteer(retainedClientRef, text);
4242
4263
  if (reservation.replay) return { accepted: reservation.result.status === "accepted", ...reservation.result };
4243
4264
  try {
4244
- await sendSdkUserMessage(text, { deliverAs: "steer" });
4265
+ await sendSteer();
4245
4266
  return { accepted: true, ...(await durable.settleSteer(retainedClientRef, "accepted")) };
4246
4267
  } catch (error) {
4247
- return { accepted: false, ...(await durable.settleSteer(retainedClientRef, "rejected", error)) };
4268
+ const settled = await durable.settleSteer(retainedClientRef, "rejected", error);
4269
+ if (expectedSdkRunToken !== undefined) throw error;
4270
+ return { accepted: false, ...settled };
4248
4271
  }
4249
4272
  },
4250
4273
  followUp: async text => {
@@ -7013,6 +7036,43 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
7013
7036
  let brokerRegistered = false;
7014
7037
  let brokerRegistrationInFlight: Promise<void> | undefined;
7015
7038
  let brokerRecoveryStopped = false;
7039
+ let brokerRecoveryRestartRequired = false;
7040
+ const brokerRecoveryBackoff = new RecoveryBackoffTracker();
7041
+ if (options.setRecoveryBackoffClockForTest) {
7042
+ options.setRecoveryBackoffClockForTest((clock: { now(): number }) =>
7043
+ brokerRecoveryBackoff.setClockForTest(clock),
7044
+ );
7045
+ }
7046
+ // Capture runtime image identity at startup to detect replacements
7047
+ let startupRuntimeImageIdentity: Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined;
7048
+ let captureStartupRuntimeImageForTest:
7049
+ | (() => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined)
7050
+ | undefined;
7051
+ let isRuntimeImageReplacedForTest: (() => Promise<boolean>) | undefined;
7052
+ if (options.setRuntimeImageIdentityForTest) {
7053
+ options.setRuntimeImageIdentityForTest(
7054
+ (
7055
+ captureFn: () => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined,
7056
+ isReplacedFn: () => Promise<boolean>,
7057
+ ) => {
7058
+ captureStartupRuntimeImageForTest = captureFn;
7059
+ isRuntimeImageReplacedForTest = isReplacedFn;
7060
+ },
7061
+ );
7062
+ }
7063
+ const captureStartupRuntimeImage = async (): Promise<void> => {
7064
+ if (options.setRuntimeImageIdentityForTest) {
7065
+ // Test mode: use injected identity
7066
+ startupRuntimeImageIdentity = captureStartupRuntimeImageForTest?.();
7067
+ } else {
7068
+ // Production mode: capture real runtime image
7069
+ const runtimeImage = sdkInternalRuntimeImage();
7070
+ if (runtimeImage) {
7071
+ startupRuntimeImageIdentity = await captureRuntimeImageIdentity(runtimeImage);
7072
+ }
7073
+ }
7074
+ };
7075
+ const startupImageCapture = captureStartupRuntimeImage();
7016
7076
  const registerBroker = async (): Promise<void> => {
7017
7077
  if (brokerRegistered) return;
7018
7078
  if (brokerRegistrationInFlight !== undefined) {
@@ -7143,17 +7203,58 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
7143
7203
  let brokerRecoveryTimer: NodeJS.Timeout | undefined;
7144
7204
  let brokerRecoveryInFlight: Promise<void> | undefined;
7145
7205
  const runBrokerRecovery = async (): Promise<void> => {
7146
- if (brokerRecoveryStopped || brokerRecoveryInFlight !== undefined) return;
7206
+ if (brokerRecoveryStopped || brokerRecoveryInFlight !== undefined || brokerRecoveryRestartRequired) return;
7207
+ // Checked synchronously so an interval tick cannot start an attempt the backoff forbids.
7208
+ if (!brokerRecoveryBackoff.canAttemptRecovery(options.agentDir)) return;
7209
+
7210
+ // The single-flight slot is claimed before the first await: the interval does not wait
7211
+ // for prior callbacks, so a slow runtime-image probe must not let a second tick through
7212
+ // to ensure the broker and record the same failure twice.
7147
7213
  const recovery = (async (): Promise<void> => {
7214
+ // A replaced runtime image cannot launch a broker; restarting the session is the only fix.
7215
+ const imageReplaced = await (isRuntimeImageReplacedForTest
7216
+ ? isRuntimeImageReplacedForTest()
7217
+ : isSdkInternalRuntimeImageReplaced(startupRuntimeImageIdentity).catch(() => false));
7218
+ if (imageReplaced) {
7219
+ brokerRecoveryRestartRequired = true;
7220
+ stopBrokerRecovery();
7221
+ logger.warn("sdk broker recovery requires session restart", {
7222
+ reason: "runtime_image_replaced",
7223
+ });
7224
+ return;
7225
+ }
7148
7226
  if (brokerRecoveryStopped) return;
7149
7227
  if (brokerRegistered) await (options.ensureBrokerImpl ?? ensureBroker)({ agentDir: options.agentDir });
7150
- else await registerBroker();
7228
+ else {
7229
+ // Optional registration logs and resolves on failure; recovery must still count it
7230
+ // as a failed attempt so the backoff holds instead of resetting every tick.
7231
+ await registerBroker();
7232
+ if (!brokerRegistered && !brokerRecoveryStopped)
7233
+ throw Object.assign(new Error("SDK broker registration did not complete."), {
7234
+ code: "registration_incomplete",
7235
+ });
7236
+ }
7151
7237
  })();
7152
7238
  brokerRecoveryInFlight = recovery;
7153
7239
  try {
7154
7240
  await recovery;
7241
+ if (brokerRecoveryRestartRequired || brokerRecoveryStopped) return;
7242
+ brokerRecoveryBackoff.recordSuccess(options.agentDir);
7155
7243
  } catch (error) {
7156
- logger.warn("sdk broker recovery unavailable", { code: brokerDiagnosticCode(error) });
7244
+ const shouldContinue = brokerRecoveryBackoff.recordFailure(options.agentDir);
7245
+ if (!shouldContinue) {
7246
+ // Max recovery attempts reached; mark that restart is required.
7247
+ brokerRecoveryRestartRequired = true;
7248
+ stopBrokerRecovery();
7249
+ logger.warn("sdk broker recovery max attempts exceeded", {
7250
+ code: brokerDiagnosticCode(error),
7251
+ });
7252
+ } else {
7253
+ logger.warn("sdk broker recovery unavailable", {
7254
+ code: brokerDiagnosticCode(error),
7255
+ backoffWaitMs: brokerRecoveryBackoff.getBackoffWaitMs(options.agentDir),
7256
+ });
7257
+ }
7157
7258
  } finally {
7158
7259
  if (brokerRecoveryInFlight === recovery) brokerRecoveryInFlight = undefined;
7159
7260
  }
@@ -7210,6 +7311,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
7210
7311
  try {
7211
7312
  publishedEndpointUrl = (await runtime.start()).url;
7212
7313
  await registerBroker();
7314
+ // Await the startup runtime image capture before arming recovery.
7315
+ // This ensures replacement detection works correctly on the first recovery check.
7316
+ await startupImageCapture;
7213
7317
  if (!brokerRecoveryStopped) startBrokerRecovery();
7214
7318
  } catch (error) {
7215
7319
  active = undefined;
package/src/sdk/index.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  TrackedSendUserMessageOptions,
22
22
  } from "../session/agent-session";
23
23
  export * as bus from "./bus";
24
+ export * from "./diagnostics/observe-broker";
24
25
  export * as host from "./host";
25
26
  export * as lifecycle from "./lifecycle";
26
27
  export * as mcp from "./mcp";
@@ -123,6 +123,7 @@ export async function createLifecycleAgentSession(
123
123
  // inside the broker's readiness window is what kills the child at the
124
124
  // cutoff; the host resumes it once readiness is published.
125
125
  deferMemoryBackendStartup: true,
126
+ deferOptionalModelRefresh: true,
126
127
  [lifecycleStartupCapabilityOption]: capability,
127
128
  ...(mcpStartupTimeoutMs !== undefined ? { [lifecycleMcpStartupTimeoutOption]: mcpStartupTimeoutMs } : {}),
128
129
  } as CreateAgentSessionOptions & {
@@ -97,8 +97,13 @@ export class PromptActivity {
97
97
  #awaitingModel = false;
98
98
 
99
99
  /** Folds one inbound frame's lifecycle event into the in-flight tool and model state. */
100
- observe(eventType: string | undefined, toolCallId: string | undefined, messageRole: string | undefined): void {
101
- this.#observeInference(eventType, messageRole);
100
+ observe(
101
+ eventType: string | undefined,
102
+ toolCallId: string | undefined,
103
+ messageRole: string | undefined,
104
+ messageHasToolCall = false,
105
+ ): void {
106
+ this.#observeInference(eventType, messageRole, messageHasToolCall);
102
107
  if (toolCallId === undefined) return;
103
108
  if (eventType === "tool_execution_start" || eventType === "tool_execution_update") this.#running.add(toolCallId);
104
109
  else if (eventType === "tool_execution_end") this.#running.delete(toolCallId);
@@ -110,7 +115,11 @@ export class PromptActivity {
110
115
  * re-invocation that carries a tool result back — until the first frame of that
111
116
  * response proves the model answered.
112
117
  */
113
- #observeInference(eventType: string | undefined, messageRole: string | undefined): void {
118
+ #observeInference(
119
+ eventType: string | undefined,
120
+ messageRole: string | undefined,
121
+ messageHasToolCall: boolean,
122
+ ): void {
114
123
  if (eventType === "agent_start" || eventType === "tool_execution_end") {
115
124
  this.#awaitingModel = true;
116
125
  return;
@@ -132,7 +141,11 @@ export class PromptActivity {
132
141
  // Providers that do not stream emit no `message_update`, so the assistant message
133
142
  // itself is the first proof. `message_start`/`message_end` also carry the user and
134
143
  // tool-result messages the host echoes back, which prove nothing about the model.
135
- if ((eventType === "message_start" || eventType === "message_end") && messageRole === "assistant")
144
+ if (
145
+ (eventType === "message_start" || eventType === "message_end") &&
146
+ messageRole === "assistant" &&
147
+ !messageHasToolCall
148
+ )
136
149
  this.#awaitingModel = false;
137
150
  }
138
151
 
@@ -267,7 +267,7 @@ function controlErrors(id: string): string[] {
267
267
  // refuses a session with no selected model before admission. The code is
268
268
  // appended: ACP machine-only mapping reads errorCodes[0].
269
269
  C01: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed", "model_not_selected"],
270
- C02: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
270
+ C02: ["turn_not_active", "client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
271
271
  C09: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
272
272
  C06: ["action_claimed"],
273
273
  C07: ["action_claimed", "terminal_uncertain"],
@@ -287,6 +287,8 @@ export interface SessionRouterOptions {
287
287
  agentDir: string;
288
288
  /** Limits attachment to exact ids selected by a Broker-scoped operation. */
289
289
  sessionIds?: readonly string[];
290
+ /** Dynamically limits attachment to sessions currently owned by the caller. */
291
+ attachFilter?: (sessionId: string) => boolean;
290
292
  /** Marks default SDK clients as notification-only observers; demanding by default. */
291
293
  observer?: boolean;
292
294
  deps?: SessionRouterDeps;
@@ -615,6 +617,7 @@ type AdoptedSession = {
615
617
  export class SessionRouter {
616
618
  readonly #agentDir: string;
617
619
  readonly #sessionIds: ReadonlySet<string> | undefined;
620
+ readonly #attachFilter: ((sessionId: string) => boolean) | undefined;
618
621
  readonly #observer: boolean;
619
622
  readonly #deps: SessionRouterDeps;
620
623
  readonly #correlateFrame: SessionRouterFrameCorrelator;
@@ -657,6 +660,7 @@ export class SessionRouter {
657
660
  constructor(options: SessionRouterOptions) {
658
661
  this.#agentDir = options.agentDir;
659
662
  this.#sessionIds = options.sessionIds === undefined ? undefined : new Set(options.sessionIds);
663
+ this.#attachFilter = options.attachFilter;
660
664
  this.#observer = options.observer === true;
661
665
  this.#deps = options.deps ?? {};
662
666
  this.#correlateFrame = options.correlateFrame ?? fallbackCorrelation;
@@ -797,6 +801,7 @@ export class SessionRouter {
797
801
  if (
798
802
  sessionId !== fallback.sessionId ||
799
803
  (this.#sessionIds !== undefined && !this.#sessionIds.has(sessionId ?? "")) ||
804
+ (this.#attachFilter !== undefined && !this.#attachFilter(sessionId ?? "")) ||
800
805
  endpointGeneration === undefined ||
801
806
  pid === undefined ||
802
807
  endpointMtimeMs === undefined ||
@@ -1294,7 +1299,8 @@ export class SessionRouter {
1294
1299
  session.live &&
1295
1300
  isSessionAuthorityEligible(session) &&
1296
1301
  !session.terminalUncertain &&
1297
- (this.#sessionIds === undefined || this.#sessionIds.has(session.sessionId)),
1302
+ (this.#sessionIds === undefined || this.#sessionIds.has(session.sessionId)) &&
1303
+ (this.#attachFilter === undefined || this.#attachFilter(session.sessionId)),
1298
1304
  );
1299
1305
  const liveIds = new Set(live.map(session => session.sessionId));
1300
1306
  const attachedIds = new Set<string>();
@@ -59,7 +59,11 @@ import { loadCapability, reset as resetCapabilities } from "../capability";
59
59
  import { type Rule, ruleCapability, setActiveRules } from "../capability/rule";
60
60
  import type { SourceMeta } from "../capability/types";
61
61
  import { AUTOROUTING_INACTIVE_WARNING } from "../config/autorouting-contract";
62
- import { ModelProfileCredentialError, resolveMissingSessionModelRecovery } from "../config/model-profile-activation";
62
+ import {
63
+ isSessionCredentialPinBlocking,
64
+ ModelProfileCredentialError,
65
+ resolveMissingSessionModelRecovery,
66
+ } from "../config/model-profile-activation";
63
67
  import { resolveModelProfileName } from "../config/model-profile-contract";
64
68
  import { resolveProfileBindings } from "../config/model-profiles";
65
69
  import { kNoAuth, ModelRegistry } from "../config/model-registry";
@@ -577,6 +581,12 @@ export interface CreateAgentSessionOptions {
577
581
  * @internal CLI-only ordering guard; SDK callers retain immediate startup by default.
578
582
  */
579
583
  deferMemoryBackendStartup?: boolean;
584
+ /**
585
+ * Skip optional model-catalog discovery during lifecycle startup. ACP hosts must
586
+ * publish their endpoint before unrelated provider credential refreshes begin.
587
+ * @internal lifecycle-only startup guard.
588
+ */
589
+ deferOptionalModelRefresh?: boolean;
580
590
 
581
591
  /** Enable LSP integration (tool, formatting, diagnostics, warmup). Default: true */
582
592
  enableLsp?: boolean;
@@ -695,6 +705,22 @@ export interface CreateAgentSessionResult {
695
705
  gjcRuntimeSnapshot?: GjcRuntimeSnapshotProvider;
696
706
  }
697
707
 
708
+ /**
709
+ * Start optional provider/model discovery only when it is safe to add work to
710
+ * the caller's startup graph. Lifecycle hosts deliberately skip this during
711
+ * construction: discovery preflights every configured provider and may refresh
712
+ * an unrelated OAuth credential while several hosts are opening the same DB.
713
+ */
714
+ export function startOptionalModelRefresh(
715
+ modelRegistry: Pick<ModelRegistry, "refreshInBackground">,
716
+ credentialSessionId: string | undefined,
717
+ deferred: boolean,
718
+ ): boolean {
719
+ if (deferred) return false;
720
+ modelRegistry.refreshInBackground("online-if-uncached", credentialSessionId);
721
+ return true;
722
+ }
723
+
698
724
  export interface DeferredMcpConfigStartupResult {
699
725
  loadedToolCount: number;
700
726
  hasErrors: boolean;
@@ -1970,11 +1996,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1970
1996
  options.preferredCredentialSelector.selector,
1971
1997
  );
1972
1998
  }
1999
+ // A runtime key or this registry's literal models.yml apiKey outranks an
2000
+ // unavailable pin; without one the provider stays blocked (never retargeted).
2001
+ const isCredentialPinBlocking = (provider: string): boolean =>
2002
+ isSessionCredentialPinBlocking(modelRegistry, provider, credentialSessionId);
1973
2003
  const modelApiKeyAvailability = new Map<string, boolean>();
1974
2004
  const getModelAvailabilityKey = (candidate: Model): string =>
1975
2005
  `${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
1976
2006
  const hasModelApiKey = async (candidate: Model): Promise<boolean> => {
1977
- if (authStorage.hasSessionCredentialUnavailable(candidate.provider, credentialSessionId)) return false;
2007
+ if (isCredentialPinBlocking(candidate.provider)) return false;
1978
2008
  const availabilityKey = getModelAvailabilityKey(candidate);
1979
2009
  const cached = modelApiKeyAvailability.get(availabilityKey);
1980
2010
  if (cached !== undefined) {
@@ -2000,6 +2030,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2000
2030
  const key = await modelRegistry
2001
2031
  .getApiKey(candidate, credentialSessionId, { credentialSelector })
2002
2032
  .catch(error => {
2033
+ if (isCredentialPinBlocking(candidate.provider)) return undefined;
2003
2034
  if (credentialSelector) {
2004
2035
  logger.debug("Credential selector did not match model availability candidate", {
2005
2036
  provider: candidate.provider,
@@ -2148,7 +2179,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2148
2179
  settings,
2149
2180
  );
2150
2181
  await refreshMissingQualifiedModelProviders(startupModelSelectors, modelRegistry, credentialSessionId);
2151
- modelRegistry.refreshInBackground("online-if-uncached", credentialSessionId);
2182
+ startOptionalModelRefresh(modelRegistry, credentialSessionId, options.deferOptionalModelRefresh === true);
2152
2183
  }
2153
2184
 
2154
2185
  const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
@@ -2241,8 +2272,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2241
2272
  {
2242
2273
  managedFallback: defaultModelEntries.length > 1,
2243
2274
  canonicalSessionId: providerSessionId,
2244
- isCredentialUnavailable: provider =>
2245
- authStorage.hasSessionCredentialUnavailable(provider, credentialSessionId),
2275
+ isCredentialUnavailable: isCredentialPinBlocking,
2246
2276
  ...(persistedProfileOwnsDefault ? { aliasIntent: "preset-equivalent" as const } : {}),
2247
2277
  },
2248
2278
  );
@@ -2297,7 +2327,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2297
2327
  !hasExplicitModel &&
2298
2328
  !model &&
2299
2329
  defaultRoleSpec.model &&
2300
- !authStorage.hasSessionCredentialUnavailable(defaultRoleSpec.model.provider, credentialSessionId) &&
2330
+ !isCredentialPinBlocking(defaultRoleSpec.model.provider) &&
2301
2331
  (!preferredCredentialProvider || defaultRoleSpec.model.provider === preferredCredentialProvider)
2302
2332
  ) {
2303
2333
  const settingsDefaultModel = defaultRoleSpec.model;
@@ -4236,8 +4266,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
4236
4266
  {
4237
4267
  managedFallback: defaultModelEntries.length > 1,
4238
4268
  canonicalSessionId: providerSessionId,
4239
- isCredentialUnavailable: provider =>
4240
- authStorage.hasSessionCredentialUnavailable(provider, credentialSessionId),
4269
+ isCredentialUnavailable: isCredentialPinBlocking,
4241
4270
  ...(persistedProfileOwnsDefault ? { aliasIntent: "preset-equivalent" as const } : {}),
4242
4271
  },
4243
4272
  );