@gajae-code/coding-agent 0.18.0 → 0.18.5

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 (86) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  4. package/dist/types/config/model-registry.d.ts +16 -1
  5. package/dist/types/config/settings-schema.d.ts +2 -2
  6. package/dist/types/exa/mcp-client.d.ts +1 -5
  7. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  8. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +1 -0
  9. package/dist/types/hashline/parser.d.ts +28 -5
  10. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  11. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  12. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  13. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  14. package/dist/types/sdk/broker/runtime.d.ts +20 -0
  15. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  16. package/dist/types/sdk/bus/index.d.ts +1 -0
  17. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  18. package/dist/types/sdk/client/client.d.ts +5 -1
  19. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  20. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  21. package/dist/types/session/agent-session.d.ts +2 -0
  22. package/dist/types/task/index.d.ts +0 -1
  23. package/dist/types/tools/bash.d.ts +1 -0
  24. package/dist/types/tools/descriptors.d.ts +5 -0
  25. package/dist/types/tools/eval-backends.d.ts +37 -0
  26. package/dist/types/tools/eval.d.ts +1 -6
  27. package/dist/types/tools/index.d.ts +1 -36
  28. package/dist/types/tools/session-descriptions.d.ts +22 -0
  29. package/package.json +7 -7
  30. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  31. package/src/cli/doctor/managed-link.ts +4 -4
  32. package/src/cli/doctor/repairs.ts +9 -1
  33. package/src/cli/stats-cli.ts +4 -36
  34. package/src/cli-main.ts +4 -8
  35. package/src/cli.ts +4 -8
  36. package/src/commands/commit.ts +2 -2
  37. package/src/commands/sdk.ts +1 -2
  38. package/src/config/autorouting-tier-map.ts +20 -2
  39. package/src/config/model-preset-registry.ts +17 -2
  40. package/src/config/model-profile-activation.ts +3 -1
  41. package/src/config/model-profiles.ts +11 -3
  42. package/src/config/model-registry.ts +45 -17
  43. package/src/config/settings-schema.ts +8 -2
  44. package/src/exa/mcp-client.ts +41 -8
  45. package/src/exec/bash-shell-guardian.ts +214 -48
  46. package/src/gjc-runtime/session-state-sidecar.ts +88 -58
  47. package/src/hashline/execute.ts +51 -3
  48. package/src/hashline/parser.ts +63 -16
  49. package/src/internal-urls/docs-index.generated.ts +25 -25
  50. package/src/modes/acp/acp-agent.ts +136 -20
  51. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  52. package/src/modes/interactive-mode.ts +10 -0
  53. package/src/modes/utils/context-usage.ts +2 -6
  54. package/src/prompts/tools/task.md +8 -27
  55. package/src/sdk/acp/adapter.ts +38 -3
  56. package/src/sdk/broker/doctor-restart.ts +38 -3
  57. package/src/sdk/broker/lifecycle.ts +408 -218
  58. package/src/sdk/broker/recovery-backoff.ts +135 -0
  59. package/src/sdk/broker/runtime.ts +83 -0
  60. package/src/sdk/broker/startup-budget.ts +6 -1
  61. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  62. package/src/sdk/bus/index.ts +308 -22
  63. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  64. package/src/sdk/client/client.ts +53 -35
  65. package/src/sdk/host/session-runtime.ts +99 -3
  66. package/src/sdk/prompt-watchdog.ts +17 -4
  67. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  68. package/src/session/agent-session.ts +79 -17
  69. package/src/session/internal/managed-session-storage.ts +23 -0
  70. package/src/task/index.ts +21 -66
  71. package/src/tools/bash.ts +39 -0
  72. package/src/tools/descriptors.ts +27 -1
  73. package/src/tools/eval-backends.ts +95 -0
  74. package/src/tools/eval.ts +3 -15
  75. package/src/tools/index.ts +9 -88
  76. package/src/tools/search.ts +2 -7
  77. package/src/tools/session-descriptions.ts +135 -0
  78. package/src/tools/tool-catalog.generated.ts +1 -1
  79. package/dist/types/cli/grep-cli.d.ts +0 -17
  80. package/dist/types/cli/shell-cli.d.ts +0 -8
  81. package/dist/types/commands/grep.d.ts +0 -42
  82. package/dist/types/commands/shell.d.ts +0 -21
  83. package/src/cli/grep-cli.ts +0 -160
  84. package/src/cli/shell-cli.ts +0 -176
  85. package/src/commands/grep.ts +0 -48
  86. package/src/commands/shell.ts +0 -29
@@ -14,6 +14,8 @@ export type SdkErrorCode =
14
14
  export class SdkClientError extends Error {
15
15
  readonly code: SdkErrorCode;
16
16
  readonly details: unknown;
17
+ /** True only when the SDK client itself reported a transport failure, never for a server error frame. */
18
+ readonly transport: boolean;
17
19
  /**
18
20
  * Reconnect-cycle diagnostics, separate from `details` because `details` is an
19
21
  * established contract: callers read the terminating transport error straight off
@@ -22,19 +24,35 @@ export class SdkClientError extends Error {
22
24
  * break every such reader, so the new attribution rides alongside it instead.
23
25
  */
24
26
  readonly reconnect?: SdkReconnectExhaustedDetails;
25
- constructor(code: SdkErrorCode, message: string, details?: unknown, reconnect?: SdkReconnectExhaustedDetails) {
27
+ constructor(
28
+ code: SdkErrorCode,
29
+ message: string,
30
+ details?: unknown,
31
+ reconnect?: SdkReconnectExhaustedDetails,
32
+ options: { transport?: boolean } = {},
33
+ ) {
26
34
  super(message);
27
35
  this.name = "SdkClientError";
28
36
  this.code = code;
29
37
  this.details = details;
38
+ this.transport = options.transport === true;
30
39
  if (reconnect) this.reconnect = reconnect;
31
40
  }
32
41
  }
33
42
 
43
+ function transportError(
44
+ code: SdkErrorCode,
45
+ message: string,
46
+ details?: unknown,
47
+ reconnect?: SdkReconnectExhaustedDetails,
48
+ ): SdkClientError {
49
+ return new SdkClientError(code, message, details, reconnect, { transport: true });
50
+ }
51
+
34
52
  /** A prepared transport operation failed before any wire handoff. */
35
53
  export class SdkPreparedDispatchError extends SdkClientError {
36
54
  constructor(error: SdkClientError) {
37
- super(error.code, error.message, error.details, error.reconnect);
55
+ super(error.code, error.message, error.details, error.reconnect, { transport: error.transport });
38
56
  this.name = "SdkPreparedDispatchError";
39
57
  }
40
58
  }
@@ -350,18 +368,18 @@ export class SdkClient {
350
368
  }
351
369
 
352
370
  send(frame: SdkFrame): void {
353
- if (this.#closed) throw new SdkClientError("connection_closed", "SDK client closed");
371
+ if (this.#closed) throw transportError("connection_closed", "SDK client closed");
354
372
  this.#throwIfDeadlineElapsed();
355
373
  const current = this.#currentSocketRecord ?? this.#opening?.candidate;
356
374
  const authoritative =
357
375
  this.#isActive(current ?? null) ||
358
376
  (!!current && current.phase === "hello" && this.#isCandidate(current.cycle, current));
359
377
  if (!current || !authoritative || current.socket.readyState !== WebSocket.OPEN)
360
- throw new SdkClientError("connection_closed", "SDK WebSocket is not connected");
378
+ throw transportError("connection_closed", "SDK WebSocket is not connected");
361
379
  try {
362
380
  current.socket.send(JSON.stringify(frame));
363
381
  } catch (error) {
364
- throw new SdkClientError("unavailable", "SDK WebSocket send failed", error);
382
+ throw transportError("unavailable", "SDK WebSocket send failed", error);
365
383
  }
366
384
  }
367
385
 
@@ -383,19 +401,19 @@ export class SdkClient {
383
401
  if (cycle.backoffTimer) clearTimeout(cycle.backoffTimer);
384
402
  if (cycle.candidate) {
385
403
  transports.add(cycle.candidate);
386
- this.#retire(cycle.candidate, new SdkClientError("connection_closed", "SDK client closed"), false);
404
+ this.#retire(cycle.candidate, transportError("connection_closed", "SDK client closed"), false);
387
405
  }
388
- cycle.rejectBackoff?.(new SdkClientError("connection_closed", "SDK client closed"));
406
+ cycle.rejectBackoff?.(transportError("connection_closed", "SDK client closed"));
389
407
  cycle.rejectBackoff = undefined;
390
408
  if (this.#opening === cycle) this.#opening = null;
391
409
  }
392
410
  const current = this.#currentSocketRecord;
393
411
  if (current) {
394
412
  transports.add(current);
395
- this.#retire(current, new SdkClientError("connection_closed", "SDK client closed"), false, true);
413
+ this.#retire(current, transportError("connection_closed", "SDK client closed"), false, true);
396
414
  }
397
415
  for (const [id, pending] of this.#pending)
398
- this.#settlePending(id, pending, new SdkClientError("connection_closed", "SDK client closed"), true);
416
+ this.#settlePending(id, pending, transportError("connection_closed", "SDK client closed"), true);
399
417
  try {
400
418
  await Promise.all([...transports].map(incarnation => this.#closeTransport(incarnation)));
401
419
  } finally {
@@ -469,7 +487,7 @@ export class SdkClient {
469
487
 
470
488
  async #request(frame: Frame, options: SdkRequestOptions, onResponse?: () => void): Promise<unknown> {
471
489
  if (this.#closed) {
472
- const error = new SdkClientError("connection_closed", "SDK client closed");
490
+ const error = transportError("connection_closed", "SDK client closed");
473
491
  throw options.connectedOnly ? new SdkPreparedDispatchError(error) : error;
474
492
  }
475
493
  try {
@@ -526,7 +544,7 @@ export class SdkClient {
526
544
  this.#settlePending(
527
545
  id,
528
546
  pending,
529
- new SdkClientError("timeout", `SDK request timed out after ${timeoutMs}ms`, {
547
+ transportError("timeout", `SDK request timed out after ${timeoutMs}ms`, {
530
548
  requestId: id,
531
549
  requestSent: pending.sent,
532
550
  } satisfies SdkRequestTimeoutDetails),
@@ -537,7 +555,7 @@ export class SdkClient {
537
555
  };
538
556
  this.#pending.set(id, pending);
539
557
  if (!this.#isActive(incarnation) || incarnation.socket.readyState !== WebSocket.OPEN) {
540
- const error = new SdkClientError("unavailable", "SDK WebSocket is not connected");
558
+ const error = transportError("unavailable", "SDK WebSocket is not connected");
541
559
  this.#settlePending(id, pending, options.connectedOnly ? new SdkPreparedDispatchError(error) : error);
542
560
  return await deferred.promise;
543
561
  }
@@ -591,9 +609,9 @@ export class SdkClient {
591
609
  pending,
592
610
  options.connectedOnly
593
611
  ? new SdkPreparedDispatchError(
594
- new SdkClientError("connection_closed", "SDK client closed during dispatch"),
612
+ transportError("connection_closed", "SDK client closed during dispatch"),
595
613
  )
596
- : new SdkClientError("connection_closed", "SDK client closed during dispatch"),
614
+ : transportError("connection_closed", "SDK client closed during dispatch"),
597
615
  );
598
616
  return await deferred.promise;
599
617
  }
@@ -603,7 +621,7 @@ export class SdkClient {
603
621
  return await deferred.promise;
604
622
  }
605
623
  if (incarnation.socket.readyState !== WebSocket.OPEN) {
606
- const error = new SdkClientError("unavailable", "SDK WebSocket is not connected");
624
+ const error = transportError("unavailable", "SDK WebSocket is not connected");
607
625
  this.#settlePending(id, pending, options.connectedOnly ? new SdkPreparedDispatchError(error) : error);
608
626
  return await deferred.promise;
609
627
  }
@@ -638,7 +656,7 @@ export class SdkClient {
638
656
  const sendError =
639
657
  error instanceof SdkClientError
640
658
  ? error
641
- : new SdkClientError("unavailable", "SDK WebSocket send failed", error);
659
+ : transportError("unavailable", "SDK WebSocket send failed", error);
642
660
  this.#settlePending(
643
661
  id,
644
662
  pending,
@@ -665,7 +683,7 @@ export class SdkClient {
665
683
  }
666
684
 
667
685
  #deadlineError(reconnect?: SdkReconnectExhaustedDetails): SdkClientError {
668
- return new SdkClientError("timeout", "SDK client deadline elapsed.", undefined, reconnect);
686
+ return transportError("timeout", "SDK client deadline elapsed.", undefined, reconnect);
669
687
  }
670
688
 
671
689
  #remainingTimeout(limit = this.#timeoutMs): number {
@@ -682,7 +700,7 @@ export class SdkClient {
682
700
  const current = this.#currentSocketRecord;
683
701
  if (current && this.#isActive(current) && current.socket.readyState === WebSocket.OPEN) return current;
684
702
  if (current)
685
- this.#retire(current, new SdkClientError("connection_closed", "SDK WebSocket connection closed"), true, true);
703
+ this.#retire(current, transportError("connection_closed", "SDK WebSocket connection closed"), true, true);
686
704
 
687
705
  let cycle = this.#opening;
688
706
  if (!cycle) {
@@ -696,7 +714,7 @@ export class SdkClient {
696
714
  #requireConnectedIncarnation(): Incarnation {
697
715
  const current = this.#currentSocketRecord;
698
716
  if (!current || !this.#isActive(current) || current.socket.readyState !== WebSocket.OPEN)
699
- throw new SdkClientError("connection_closed", "SDK WebSocket disconnected before prepared dispatch.");
717
+ throw transportError("connection_closed", "SDK WebSocket disconnected before prepared dispatch.");
700
718
  return current;
701
719
  }
702
720
 
@@ -711,7 +729,7 @@ export class SdkClient {
711
729
  reason,
712
730
  });
713
731
  const cancelled = (): SdkClientError =>
714
- new SdkClientError("connection_closed", "SDK client closed", lastError, diagnostics("cancelled"));
732
+ transportError("connection_closed", "SDK client closed", lastError, diagnostics("cancelled"));
715
733
  /**
716
734
  * A deadline and retry budget measure different failure axes. One-shot clients
717
735
  * need the deadline to fast-fail their operation, while long-lived ACP sessions
@@ -730,10 +748,10 @@ export class SdkClient {
730
748
  try {
731
749
  const incarnation = await this.#open(cycle);
732
750
  if (!this.#isActive(incarnation) && (!this.#isOpening(cycle) || cycle.candidate !== incarnation))
733
- throw new SdkClientError("connection_closed", "SDK WebSocket is not connected");
751
+ throw transportError("connection_closed", "SDK WebSocket is not connected");
734
752
  await this.#waitForHello(incarnation);
735
753
  if (this.#isActive(incarnation)) return incarnation;
736
- throw new SdkClientError("connection_closed", "SDK WebSocket is not connected");
754
+ throw transportError("connection_closed", "SDK WebSocket is not connected");
737
755
  } catch (error) {
738
756
  lastError = error;
739
757
  if (!this.#isOpening(cycle)) throw cancelled();
@@ -743,7 +761,7 @@ export class SdkClient {
743
761
  candidate,
744
762
  error instanceof SdkClientError
745
763
  ? error
746
- : new SdkClientError("unavailable", "SDK WebSocket connection failed", error),
764
+ : transportError("unavailable", "SDK WebSocket connection failed", error),
747
765
  true,
748
766
  );
749
767
  if (attempt < this.#reconnectAttempts) {
@@ -780,7 +798,7 @@ export class SdkClient {
780
798
  }
781
799
  cycle.phase = "complete";
782
800
  if (this.#opening === cycle) this.#opening = null;
783
- const error = new SdkClientError(
801
+ const error = transportError(
784
802
  "reconnect_exhausted",
785
803
  "SDK WebSocket reconnect attempts exhausted",
786
804
  lastError,
@@ -891,7 +909,7 @@ export class SdkClient {
891
909
  if (incarnation.failure) return Promise.reject(incarnation.failure);
892
910
  if (this.#isActive(incarnation)) return Promise.resolve();
893
911
  if (!this.#isCandidate(incarnation.cycle, incarnation) || incarnation.phase !== "hello")
894
- return Promise.reject(new SdkClientError("connection_closed", "SDK WebSocket is not connected"));
912
+ return Promise.reject(transportError("connection_closed", "SDK WebSocket is not connected"));
895
913
  const deferred = Promise.withResolvers<void>();
896
914
  incarnation.resolveHello = deferred.resolve;
897
915
  incarnation.rejectHello = deferred.reject;
@@ -903,7 +921,7 @@ export class SdkClient {
903
921
  const error =
904
922
  this.#deadline !== undefined && Date.now() >= this.#deadline
905
923
  ? this.#deadlineError()
906
- : new SdkClientError("timeout", `SDK WebSocket connection timed out after ${timeoutMs}ms`);
924
+ : transportError("timeout", `SDK WebSocket connection timed out after ${timeoutMs}ms`);
907
925
  incarnation.rejectOpen?.(error);
908
926
  this.#retire(incarnation, error, true);
909
927
  }
@@ -911,20 +929,20 @@ export class SdkClient {
911
929
  #onSocketFailure(incarnation: Incarnation, event?: Event): void {
912
930
  if (!this.#isCandidate(incarnation.cycle, incarnation) && !this.#isActive(incarnation)) return;
913
931
  const detail = event as (Event & { error?: unknown; message?: unknown }) | undefined;
932
+ let failureMessage = "SDK WebSocket connection closed";
933
+ if (detail?.error instanceof Error) failureMessage = detail.error.message;
934
+ else if (typeof detail?.message === "string") failureMessage = detail.message;
914
935
  const error =
915
- detail?.error instanceof Error
936
+ detail?.error instanceof SdkClientError && detail.error.transport
916
937
  ? detail.error
917
- : new SdkClientError(
918
- "connection_closed",
919
- typeof detail?.message === "string" ? detail.message : "SDK WebSocket connection closed",
920
- );
938
+ : transportError("connection_closed", failureMessage);
921
939
  if (incarnation.phase === "opening") incarnation.rejectOpen?.(error);
922
940
  if (incarnation.phase === "hello") incarnation.rejectHello?.(error);
923
941
  this.#retire(
924
942
  incarnation,
925
943
  error instanceof SdkClientError
926
944
  ? error
927
- : new SdkClientError("unavailable", "SDK WebSocket connection failed", error),
945
+ : transportError("unavailable", "SDK WebSocket connection failed", error),
928
946
  true,
929
947
  true,
930
948
  );
@@ -1115,7 +1133,7 @@ export class SdkClient {
1115
1133
  const onClose = (): void => resolve();
1116
1134
  socket.addEventListener("close", onClose, { once: true });
1117
1135
  const timer = setTimeout(
1118
- () => reject(new SdkClientError("timeout", `SDK WebSocket close timed out after ${timeoutMs}ms`)),
1136
+ () => reject(transportError("timeout", `SDK WebSocket close timed out after ${timeoutMs}ms`)),
1119
1137
  timeoutMs,
1120
1138
  );
1121
1139
  timer.unref?.();
@@ -1124,9 +1142,9 @@ export class SdkClient {
1124
1142
  if (Number(socket.readyState) === WebSocket.CLOSED) resolve();
1125
1143
  await promise;
1126
1144
  } catch (error) {
1127
- if (error instanceof SdkClientError) throw error;
1145
+ if (error instanceof SdkClientError && error.transport) throw error;
1128
1146
  if (Number(socket.readyState) !== WebSocket.CLOSED)
1129
- throw new SdkClientError("connection_closed", "SDK WebSocket close failed", error);
1147
+ throw transportError("connection_closed", "SDK WebSocket close failed", error);
1130
1148
  } finally {
1131
1149
  clearTimeout(timer);
1132
1150
  socket.removeEventListener("close", onClose);
@@ -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 {
@@ -7013,6 +7028,43 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
7013
7028
  let brokerRegistered = false;
7014
7029
  let brokerRegistrationInFlight: Promise<void> | undefined;
7015
7030
  let brokerRecoveryStopped = false;
7031
+ let brokerRecoveryRestartRequired = false;
7032
+ const brokerRecoveryBackoff = new RecoveryBackoffTracker();
7033
+ if (options.setRecoveryBackoffClockForTest) {
7034
+ options.setRecoveryBackoffClockForTest((clock: { now(): number }) =>
7035
+ brokerRecoveryBackoff.setClockForTest(clock),
7036
+ );
7037
+ }
7038
+ // Capture runtime image identity at startup to detect replacements
7039
+ let startupRuntimeImageIdentity: Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined;
7040
+ let captureStartupRuntimeImageForTest:
7041
+ | (() => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined)
7042
+ | undefined;
7043
+ let isRuntimeImageReplacedForTest: (() => Promise<boolean>) | undefined;
7044
+ if (options.setRuntimeImageIdentityForTest) {
7045
+ options.setRuntimeImageIdentityForTest(
7046
+ (
7047
+ captureFn: () => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined,
7048
+ isReplacedFn: () => Promise<boolean>,
7049
+ ) => {
7050
+ captureStartupRuntimeImageForTest = captureFn;
7051
+ isRuntimeImageReplacedForTest = isReplacedFn;
7052
+ },
7053
+ );
7054
+ }
7055
+ const captureStartupRuntimeImage = async (): Promise<void> => {
7056
+ if (options.setRuntimeImageIdentityForTest) {
7057
+ // Test mode: use injected identity
7058
+ startupRuntimeImageIdentity = captureStartupRuntimeImageForTest?.();
7059
+ } else {
7060
+ // Production mode: capture real runtime image
7061
+ const runtimeImage = sdkInternalRuntimeImage();
7062
+ if (runtimeImage) {
7063
+ startupRuntimeImageIdentity = await captureRuntimeImageIdentity(runtimeImage);
7064
+ }
7065
+ }
7066
+ };
7067
+ const startupImageCapture = captureStartupRuntimeImage();
7016
7068
  const registerBroker = async (): Promise<void> => {
7017
7069
  if (brokerRegistered) return;
7018
7070
  if (brokerRegistrationInFlight !== undefined) {
@@ -7143,17 +7195,58 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
7143
7195
  let brokerRecoveryTimer: NodeJS.Timeout | undefined;
7144
7196
  let brokerRecoveryInFlight: Promise<void> | undefined;
7145
7197
  const runBrokerRecovery = async (): Promise<void> => {
7146
- if (brokerRecoveryStopped || brokerRecoveryInFlight !== undefined) return;
7198
+ if (brokerRecoveryStopped || brokerRecoveryInFlight !== undefined || brokerRecoveryRestartRequired) return;
7199
+ // Checked synchronously so an interval tick cannot start an attempt the backoff forbids.
7200
+ if (!brokerRecoveryBackoff.canAttemptRecovery(options.agentDir)) return;
7201
+
7202
+ // The single-flight slot is claimed before the first await: the interval does not wait
7203
+ // for prior callbacks, so a slow runtime-image probe must not let a second tick through
7204
+ // to ensure the broker and record the same failure twice.
7147
7205
  const recovery = (async (): Promise<void> => {
7206
+ // A replaced runtime image cannot launch a broker; restarting the session is the only fix.
7207
+ const imageReplaced = await (isRuntimeImageReplacedForTest
7208
+ ? isRuntimeImageReplacedForTest()
7209
+ : isSdkInternalRuntimeImageReplaced(startupRuntimeImageIdentity).catch(() => false));
7210
+ if (imageReplaced) {
7211
+ brokerRecoveryRestartRequired = true;
7212
+ stopBrokerRecovery();
7213
+ logger.warn("sdk broker recovery requires session restart", {
7214
+ reason: "runtime_image_replaced",
7215
+ });
7216
+ return;
7217
+ }
7148
7218
  if (brokerRecoveryStopped) return;
7149
7219
  if (brokerRegistered) await (options.ensureBrokerImpl ?? ensureBroker)({ agentDir: options.agentDir });
7150
- else await registerBroker();
7220
+ else {
7221
+ // Optional registration logs and resolves on failure; recovery must still count it
7222
+ // as a failed attempt so the backoff holds instead of resetting every tick.
7223
+ await registerBroker();
7224
+ if (!brokerRegistered && !brokerRecoveryStopped)
7225
+ throw Object.assign(new Error("SDK broker registration did not complete."), {
7226
+ code: "registration_incomplete",
7227
+ });
7228
+ }
7151
7229
  })();
7152
7230
  brokerRecoveryInFlight = recovery;
7153
7231
  try {
7154
7232
  await recovery;
7233
+ if (brokerRecoveryRestartRequired || brokerRecoveryStopped) return;
7234
+ brokerRecoveryBackoff.recordSuccess(options.agentDir);
7155
7235
  } catch (error) {
7156
- logger.warn("sdk broker recovery unavailable", { code: brokerDiagnosticCode(error) });
7236
+ const shouldContinue = brokerRecoveryBackoff.recordFailure(options.agentDir);
7237
+ if (!shouldContinue) {
7238
+ // Max recovery attempts reached; mark that restart is required.
7239
+ brokerRecoveryRestartRequired = true;
7240
+ stopBrokerRecovery();
7241
+ logger.warn("sdk broker recovery max attempts exceeded", {
7242
+ code: brokerDiagnosticCode(error),
7243
+ });
7244
+ } else {
7245
+ logger.warn("sdk broker recovery unavailable", {
7246
+ code: brokerDiagnosticCode(error),
7247
+ backoffWaitMs: brokerRecoveryBackoff.getBackoffWaitMs(options.agentDir),
7248
+ });
7249
+ }
7157
7250
  } finally {
7158
7251
  if (brokerRecoveryInFlight === recovery) brokerRecoveryInFlight = undefined;
7159
7252
  }
@@ -7210,6 +7303,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
7210
7303
  try {
7211
7304
  publishedEndpointUrl = (await runtime.start()).url;
7212
7305
  await registerBroker();
7306
+ // Await the startup runtime image capture before arming recovery.
7307
+ // This ensures replacement detection works correctly on the first recovery check.
7308
+ await startupImageCapture;
7213
7309
  if (!brokerRecoveryStopped) startBrokerRecovery();
7214
7310
  } catch (error) {
7215
7311
  active = undefined;
@@ -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
 
@@ -5506,6 +5506,17 @@
5506
5506
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
5507
5507
  ]
5508
5508
  },
5509
+ {
5510
+ "sourceId": "agent_session:getAutoCompactionThresholdTokens",
5511
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
5512
+ "sourceKind": "agent_session",
5513
+ "decision": "exclude",
5514
+ "rationale": "internal compaction-threshold accessor feeding the /context breakdown; exposed through context.get, not an independent SDK operation",
5515
+ "exclusionMetadata": {
5516
+ "adapterMappings": "not_applicable",
5517
+ "testIds": "not_applicable"
5518
+ }
5519
+ },
5509
5520
  {
5510
5521
  "sourceId": "agent_session:getContextUsage",
5511
5522
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -58,23 +58,28 @@ import {
58
58
  CompactionCancelledError,
59
59
  type CompactionPreparation,
60
60
  type CompactionResult,
61
+ type CompactionSettings,
61
62
  calculateContextTokens,
62
63
  calculatePromptTokens,
63
64
  collectEntriesForBranchSummary,
64
65
  compact,
65
66
  type EmergencyCompactionSample,
67
+ effectiveReserveTokens,
66
68
  emergencyCompactionReason,
67
69
  estimateMessageTokensHeuristic,
68
70
  estimateTextTokensHeuristic,
69
71
  generateBranchSummary,
70
72
  generateHandoff,
71
73
  IMAGE_TOKEN_ESTIMATE,
74
+ isDefaultAutoThresholdCeilingApplied,
72
75
  prepareCompaction,
73
76
  type RemoteCompactionFallbackHealthEvent,
74
77
  type RemoteCompactionFallbackHealthHooks,
78
+ resolveThresholdTokens,
75
79
  type SummaryOptions,
76
80
  shouldCompact,
77
81
  } from "@gajae-code/agent-core/compaction";
82
+
78
83
  import {
79
84
  commitToolOutputPrune,
80
85
  createPrunedNotice,
@@ -19954,10 +19959,15 @@ export class AgentSession {
19954
19959
  const compactionSettings = this.settings.getGroup("compaction");
19955
19960
  const pathEntries = this.#withoutEphemeralCustomMessageEntries(this.sessionManager.getBranch());
19956
19961
 
19957
- const preparation = prepareCompaction(pathEntries, compactionSettings, {
19958
- contextWindow: this.model.contextWindow,
19959
- tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
19960
- });
19962
+ const preparation = prepareCompaction(
19963
+ pathEntries,
19964
+ this.#compactionSettingsForContextPromotion(compactionSettings),
19965
+ {
19966
+ contextWindow: this.model.contextWindow,
19967
+ tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
19968
+ },
19969
+ );
19970
+
19961
19971
  if (!preparation) {
19962
19972
  // Check why we can't compact
19963
19973
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -20974,12 +20984,12 @@ export class AgentSession {
20974
20984
  }
20975
20985
  }
20976
20986
 
20977
- #recordAdaptiveCompactionCall<T extends { adaptive?: { enabled: boolean; turnWindow: number } }>(
20987
+ #recordAdaptiveCompactionCall(
20978
20988
  contextTokens: number,
20979
- settings: T,
20989
+ settings: CompactionSettings,
20980
20990
  assistantMessage: AssistantMessage,
20981
- ): T {
20982
- if (!settings.adaptive?.enabled) return settings;
20991
+ ): CompactionSettings {
20992
+ if (!settings.adaptive?.enabled) return this.#compactionSettingsWithAdaptiveState(settings);
20983
20993
  this.#adaptiveCompactionTracker.setWindowMs(settings.adaptive.turnWindow * 60_000);
20984
20994
  const messageKey = this.#adaptiveCompactionMessageKey(assistantMessage);
20985
20995
  if (this.#adaptiveCompactionRecordedMessageKey === messageKey) {
@@ -20994,11 +21004,31 @@ export class AgentSession {
20994
21004
  return `${message.provider}\u0000${message.model}\u0000${message.timestamp}\u0000${message.usage?.totalTokens ?? ""}`;
20995
21005
  }
20996
21006
 
20997
- #compactionSettingsWithAdaptiveState<T extends { adaptive?: { enabled: boolean } }>(settings: T): T {
20998
- if (!settings.adaptive?.enabled) return settings;
21007
+ #compactionSettingsWithAdaptiveState(settings: CompactionSettings): CompactionSettings {
21008
+ const adaptiveSettings = settings.adaptive?.enabled
21009
+ ? { ...settings, adaptiveState: this.#adaptiveCompactionTracker.decisionState() }
21010
+ : settings;
21011
+ return this.#compactionSettingsForContextPromotion(adaptiveSettings);
21012
+ }
21013
+
21014
+ #compactionSettingsForContextPromotion(settings: CompactionSettings): CompactionSettings {
21015
+ const contextWindow = this.model?.contextWindow ?? 0;
21016
+ const isContextPromoted = this.#temporaryProviderSessionScopes.some(
21017
+ scope => scope.token.reason === "context-promotion",
21018
+ );
21019
+ if (
21020
+ !isContextPromoted ||
21021
+ !isDefaultAutoThresholdCeilingApplied(contextWindow, settings)
21022
+ ) {
21023
+ return settings;
21024
+ }
21025
+
21026
+ // Context promotion is opt-in and exists to use the larger model's headroom.
21027
+ // Keep the reserve-based default on the promoted model instead of immediately
21028
+ // compacting at the same 300K ceiling that triggered promotion.
20999
21029
  return {
21000
21030
  ...settings,
21001
- adaptiveState: this.#adaptiveCompactionTracker.decisionState(),
21031
+ thresholdTokens: contextWindow - effectiveReserveTokens(contextWindow, settings),
21002
21032
  };
21003
21033
  }
21004
21034
 
@@ -22304,10 +22334,15 @@ export class AgentSession {
22304
22334
  // correction only when it SHRINKS the keep window (ratio >= 1), never when
22305
22335
  // it would grow it, so recovery cannot re-overflow the provider window.
22306
22336
  const overflowRatio = this.#computeCompactionTokenCorrectionRatio();
22307
- const preparation = prepareCompaction(pathEntries, compactionSettings, {
22308
- contextWindow: this.model?.contextWindow,
22309
- tokenCorrectionRatio: overflowRatio !== undefined ? Math.max(1, overflowRatio) : undefined,
22310
- });
22337
+ const preparation = prepareCompaction(
22338
+ pathEntries,
22339
+ this.#compactionSettingsForContextPromotion(compactionSettings),
22340
+ {
22341
+ contextWindow: this.model?.contextWindow,
22342
+ tokenCorrectionRatio: overflowRatio !== undefined ? Math.max(1, overflowRatio) : undefined,
22343
+ },
22344
+ );
22345
+
22311
22346
  if (autoCompactionSignal.aborted) return await emitAborted();
22312
22347
 
22313
22348
  if (!preparation) {
@@ -24390,8 +24425,15 @@ export class AgentSession {
24390
24425
  if (!managedFallback && assistantMessageHasVisibleOrToolContent(message)) {
24391
24426
  return false;
24392
24427
  }
24428
+ const canReplayUnexpectedSocketClose =
24429
+ classification === "transient" &&
24430
+ isUnexpectedSocketCloseMessage(message.errorMessage ?? "") &&
24431
+ !hasBareDefaultRetryDisqualifyingFacts(message) &&
24432
+ this.#hasCleanRetryReplaySafety;
24393
24433
  // Bare defaults retain their narrow watchdog and provider capacity-overload
24394
- // admissions. A first-event timeout adds the typed, content-free,
24434
+ // admissions. A content-free unexpected socket close is replay-safe when
24435
+ // the attempt has no conflicting facts and the retry scope is clean. A
24436
+ // first-event timeout adds the typed, content-free,
24395
24437
  // current-clean-scope requirement above; other transient watchdogs preserve
24396
24438
  // legacy behavior. A provider overload is admitted only from that provider's
24397
24439
  // own typed overload code on a content-free attempt carrying no conflicting
@@ -24400,12 +24442,16 @@ export class AgentSession {
24400
24442
  if (!managedFallback && !legacyRetryConfigured && !canReplayRotatedCredential && !canReplayEmptyResponse) {
24401
24443
  if (
24402
24444
  (!canReplayProviderOverload &&
24445
+ !canReplayUnexpectedSocketClose &&
24403
24446
  !this.#isTypedFirstEventTimeout(message) &&
24404
24447
  !messageOnlyWatchdogTimeout &&
24405
24448
  (hasBareDefaultRetryDisqualifyingFacts(message) ||
24406
24449
  (classification !== "transient" && classification !== "first_event_timeout") ||
24407
24450
  !BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))) ||
24408
- (!firstEventTimeout && !messageOnlyWatchdogTimeout && !this.#hasCleanRetryReplaySafety)
24451
+ (!firstEventTimeout &&
24452
+ !messageOnlyWatchdogTimeout &&
24453
+ !canReplayUnexpectedSocketClose &&
24454
+ !this.#hasCleanRetryReplaySafety)
24409
24455
  ) {
24410
24456
  return false;
24411
24457
  }
@@ -24414,6 +24460,7 @@ export class AgentSession {
24414
24460
  !managedFallback &&
24415
24461
  classification === "transient" &&
24416
24462
  !canReplayProviderOverload &&
24463
+ !canReplayUnexpectedSocketClose &&
24417
24464
  !this.#isIdleStreamStallErrorMessage(message.errorMessage ?? "");
24418
24465
 
24419
24466
  const failedSelector = managedFallback ? controller.currentSelector() : undefined;
@@ -27184,6 +27231,21 @@ export class AgentSession {
27184
27231
  };
27185
27232
  }
27186
27233
 
27234
+ /** Current auto-compaction threshold, including active model-promotion policy. */
27235
+ getAutoCompactionThresholdTokens(contextTokens?: number): number {
27236
+ const model = this.model;
27237
+ if (!model) return 0;
27238
+ const contextWindow = model.contextWindow ?? 0;
27239
+ if (contextWindow <= 0) return 0;
27240
+ const settings = this.settings.getGroup("compaction") as CompactionSettings;
27241
+ return resolveThresholdTokens(
27242
+ contextWindow,
27243
+ this.#compactionSettingsWithAdaptiveState(settings),
27244
+ 0,
27245
+ contextTokens,
27246
+ );
27247
+ }
27248
+
27187
27249
  /**
27188
27250
  * Get current context usage statistics.
27189
27251
  * Uses the last assistant message's usage data when available,