condukt 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/runtimes/claude/bridge-cache.d.ts +58 -0
  2. package/dist/runtimes/claude/bridge-cache.d.ts.map +1 -0
  3. package/dist/runtimes/claude/bridge-cache.js +257 -0
  4. package/dist/runtimes/claude/bridge-cache.js.map +1 -0
  5. package/dist/runtimes/claude/claude-runtime.d.ts +30 -0
  6. package/dist/runtimes/claude/claude-runtime.d.ts.map +1 -0
  7. package/dist/runtimes/claude/claude-runtime.js +88 -0
  8. package/dist/runtimes/claude/claude-runtime.js.map +1 -0
  9. package/dist/runtimes/claude/claude-session.d.ts +154 -0
  10. package/dist/runtimes/claude/claude-session.d.ts.map +1 -0
  11. package/dist/runtimes/claude/claude-session.js +851 -0
  12. package/dist/runtimes/claude/claude-session.js.map +1 -0
  13. package/dist/runtimes/claude/config-mapper.d.ts +69 -0
  14. package/dist/runtimes/claude/config-mapper.d.ts.map +1 -0
  15. package/dist/runtimes/claude/config-mapper.js +458 -0
  16. package/dist/runtimes/claude/config-mapper.js.map +1 -0
  17. package/dist/runtimes/claude/epoch-store.d.ts +28 -0
  18. package/dist/runtimes/claude/epoch-store.d.ts.map +1 -0
  19. package/dist/runtimes/claude/epoch-store.js +199 -0
  20. package/dist/runtimes/claude/epoch-store.js.map +1 -0
  21. package/dist/runtimes/claude/fallback-runtime.d.ts +14 -0
  22. package/dist/runtimes/claude/fallback-runtime.d.ts.map +1 -0
  23. package/dist/runtimes/claude/fallback-runtime.js +64 -0
  24. package/dist/runtimes/claude/fallback-runtime.js.map +1 -0
  25. package/dist/runtimes/claude/index.d.ts +24 -0
  26. package/dist/runtimes/claude/index.d.ts.map +1 -0
  27. package/dist/runtimes/claude/index.js +61 -0
  28. package/dist/runtimes/claude/index.js.map +1 -0
  29. package/dist/runtimes/claude/message-mapper.d.ts +135 -0
  30. package/dist/runtimes/claude/message-mapper.d.ts.map +1 -0
  31. package/dist/runtimes/claude/message-mapper.js +504 -0
  32. package/dist/runtimes/claude/message-mapper.js.map +1 -0
  33. package/dist/runtimes/claude/one-shot.d.ts +48 -0
  34. package/dist/runtimes/claude/one-shot.d.ts.map +1 -0
  35. package/dist/runtimes/claude/one-shot.js +186 -0
  36. package/dist/runtimes/claude/one-shot.js.map +1 -0
  37. package/dist/runtimes/claude/protocol-bridge.d.ts +138 -0
  38. package/dist/runtimes/claude/protocol-bridge.d.ts.map +1 -0
  39. package/dist/runtimes/claude/protocol-bridge.js +1170 -0
  40. package/dist/runtimes/claude/protocol-bridge.js.map +1 -0
  41. package/dist/runtimes/claude/protocol-router.d.ts +113 -0
  42. package/dist/runtimes/claude/protocol-router.d.ts.map +1 -0
  43. package/dist/runtimes/claude/protocol-router.js +514 -0
  44. package/dist/runtimes/claude/protocol-router.js.map +1 -0
  45. package/dist/runtimes/claude/session-epoch.d.ts +156 -0
  46. package/dist/runtimes/claude/session-epoch.d.ts.map +1 -0
  47. package/dist/runtimes/claude/session-epoch.js +365 -0
  48. package/dist/runtimes/claude/session-epoch.js.map +1 -0
  49. package/dist/runtimes/claude/session-recovery.d.ts +78 -0
  50. package/dist/runtimes/claude/session-recovery.d.ts.map +1 -0
  51. package/dist/runtimes/claude/session-recovery.js +259 -0
  52. package/dist/runtimes/claude/session-recovery.js.map +1 -0
  53. package/dist/runtimes/claude/sse.d.ts +16 -0
  54. package/dist/runtimes/claude/sse.d.ts.map +1 -0
  55. package/dist/runtimes/claude/sse.js +46 -0
  56. package/dist/runtimes/claude/sse.js.map +1 -0
  57. package/dist/runtimes/claude/translate-chat.d.ts +106 -0
  58. package/dist/runtimes/claude/translate-chat.d.ts.map +1 -0
  59. package/dist/runtimes/claude/translate-chat.js +857 -0
  60. package/dist/runtimes/claude/translate-chat.js.map +1 -0
  61. package/dist/runtimes/claude/translate-responses.d.ts +76 -0
  62. package/dist/runtimes/claude/translate-responses.d.ts.map +1 -0
  63. package/dist/runtimes/claude/translate-responses.js +625 -0
  64. package/dist/runtimes/claude/translate-responses.js.map +1 -0
  65. package/dist/runtimes/claude/usage-ledger.d.ts +108 -0
  66. package/dist/runtimes/claude/usage-ledger.d.ts.map +1 -0
  67. package/dist/runtimes/claude/usage-ledger.js +189 -0
  68. package/dist/runtimes/claude/usage-ledger.js.map +1 -0
  69. package/dist/runtimes/copilot/copilot-backend.d.ts +8 -0
  70. package/dist/runtimes/copilot/copilot-backend.d.ts.map +1 -1
  71. package/dist/runtimes/copilot/sdk-backend.d.ts.map +1 -1
  72. package/dist/runtimes/copilot/sdk-backend.js +314 -135
  73. package/dist/runtimes/copilot/sdk-backend.js.map +1 -1
  74. package/dist/runtimes/mcp-env.d.ts +24 -0
  75. package/dist/runtimes/mcp-env.d.ts.map +1 -0
  76. package/dist/runtimes/mcp-env.js +81 -0
  77. package/dist/runtimes/mcp-env.js.map +1 -0
  78. package/dist/runtimes/mock/mock-runtime.d.ts +2 -2
  79. package/dist/runtimes/mock/mock-runtime.d.ts.map +1 -1
  80. package/dist/runtimes/mock/mock-runtime.js +38 -1
  81. package/dist/runtimes/mock/mock-runtime.js.map +1 -1
  82. package/dist/src/agent.d.ts.map +1 -1
  83. package/dist/src/agent.js +171 -3
  84. package/dist/src/agent.js.map +1 -1
  85. package/dist/src/index.d.ts +1 -1
  86. package/dist/src/index.d.ts.map +1 -1
  87. package/dist/src/index.js.map +1 -1
  88. package/dist/src/session-recovery-policy.d.ts +19 -0
  89. package/dist/src/session-recovery-policy.d.ts.map +1 -0
  90. package/dist/src/session-recovery-policy.js +43 -0
  91. package/dist/src/session-recovery-policy.js.map +1 -0
  92. package/dist/src/types.d.ts +118 -1
  93. package/dist/src/types.d.ts.map +1 -1
  94. package/dist/src/types.js +11 -1
  95. package/dist/src/types.js.map +1 -1
  96. package/package.json +20 -4
@@ -51,6 +51,8 @@ const fs = __importStar(require("fs"));
51
51
  const path = __importStar(require("path"));
52
52
  const node_crypto_1 = require("node:crypto");
53
53
  const types_1 = require("../../src/types");
54
+ const session_recovery_policy_1 = require("../../src/session-recovery-policy");
55
+ const mcp_env_1 = require("../mcp-env");
54
56
  const lifecycle_events_1 = require("./lifecycle-events");
55
57
  const subagents_1 = require("./subagents");
56
58
  const SEVERITY_RANK = {
@@ -81,35 +83,32 @@ function writeDiagnosticStderr(severity, message, configuredLevel) {
81
83
  /* closed stream */
82
84
  }
83
85
  }
84
- const MAX_SESSION_RECOVERY_CONTINUATIONS = 23;
85
- function positiveIntegerOr(value, fallback) {
86
- return value !== undefined && Number.isFinite(value) && value > 0
87
- ? Math.trunc(value)
88
- : fallback;
89
- }
90
- function nonNegativeOr(value, fallback) {
91
- return value !== undefined && Number.isFinite(value) && value >= 0
92
- ? value
93
- : fallback;
94
- }
95
- function effectiveSessionRecoveryPolicy(policy) {
96
- if (policy === false)
97
- return undefined;
98
- const configured = policy ?? {};
99
- return {
100
- maxContinuations: Math.min(MAX_SESSION_RECOVERY_CONTINUATIONS, positiveIntegerOr(configured.maxContinuations, types_1.DEFAULT_SESSION_RECOVERY_POLICY.maxContinuations)),
101
- budgetMs: nonNegativeOr(configured.budgetMs, types_1.DEFAULT_SESSION_RECOVERY_POLICY.budgetMs),
102
- backoffBaseMs: positiveIntegerOr(configured.backoffBaseMs, types_1.DEFAULT_SESSION_RECOVERY_POLICY.backoffBaseMs),
103
- backoffMaxMs: positiveIntegerOr(configured.backoffMaxMs, types_1.DEFAULT_SESSION_RECOVERY_POLICY.backoffMaxMs),
104
- jitter: configured.jitter ?? types_1.DEFAULT_SESSION_RECOVERY_POLICY.jitter,
105
- nativeRetryGraceMs: nonNegativeOr(configured.nativeRetryGraceMs, types_1.DEFAULT_SESSION_RECOVERY_POLICY.nativeRetryGraceMs),
106
- };
86
+ /**
87
+ * A failed reconnect step, tagged with the stage that threw so an `exhausted`
88
+ * event can name it.
89
+ *
90
+ * `fatal` marks the two steps whose failure cannot change its answer on a
91
+ * retry: a missing client factory, and a resumed session whose id is not the one
92
+ * we asked for. Every other step is retried until the continuation count or the
93
+ * recovery budget runs out.
94
+ */
95
+ class SessionResumeAttemptError extends Error {
96
+ stage;
97
+ fatal;
98
+ constructor(stage, message, fatal, options) {
99
+ super(message, options);
100
+ this.stage = stage;
101
+ this.fatal = fatal;
102
+ this.name = 'SessionResumeAttemptError';
103
+ }
107
104
  }
108
- function recoveryDelayMs(policy, failedContinuation) {
109
- const exponent = Math.min(52, Math.max(0, failedContinuation));
110
- const capped = Math.min(policy.backoffMaxMs, policy.backoffBaseMs * (2 ** exponent));
111
- const delayed = policy.jitter ? Math.random() * capped : capped;
112
- return Math.max(100, Number.isFinite(delayed) ? delayed : 100);
105
+ function toResumeAttemptError(value, stage) {
106
+ if (value instanceof SessionResumeAttemptError)
107
+ return value;
108
+ const message = value instanceof Error ? value.message : String(value);
109
+ return new SessionResumeAttemptError(stage, message, false, {
110
+ cause: value instanceof Error ? value : undefined,
111
+ });
113
112
  }
114
113
  function numericStatusCode(value) {
115
114
  if (typeof value === 'number' && Number.isFinite(value))
@@ -119,6 +118,48 @@ function numericStatusCode(value) {
119
118
  const parsed = Number(value);
120
119
  return Number.isFinite(parsed) ? parsed : undefined;
121
120
  }
121
+ /**
122
+ * The CLI and the SDK describe a failed model call differently.
123
+ *
124
+ * The SDK surfaces `model.call_failure` in its typed shape: `source`,
125
+ * `failureKind`, `transport`, `errorMessage` and friends, flat and camelCase,
126
+ * with `source` always set. The CLI emits `model.model_call_failure` (routed
127
+ * here by the type-substring branch in `dispatchClassEvent`) in its own
128
+ * envelope: the details nested under `modelCall` in snake_case, and no `source`
129
+ * anywhere.
130
+ *
131
+ * Both land in `handleModelCallFailure`, so classification reads one view of
132
+ * either. Without this the recovery predicate, which tests `source ===
133
+ * 'top_level'` first, rejected every transport failure that arrived in the CLI
134
+ * shape, and the run restarted the node from a cold session instead of
135
+ * resuming it.
136
+ *
137
+ * The original envelope is kept alongside the unwrapped fields: the failure log
138
+ * still shows exactly what arrived, and the added keys are the ones the
139
+ * classifier reads.
140
+ */
141
+ function normalizeModelCallFailure(data) {
142
+ if (!data)
143
+ return data;
144
+ const nested = isRecord(data.modelCall) ? data.modelCall : undefined;
145
+ // Already the flat SDK shape, or nothing to unwrap: leave it alone.
146
+ if (!nested)
147
+ return data;
148
+ return {
149
+ ...data,
150
+ failureKind: data.failureKind ?? nested.failure_kind ?? nested.failureKind,
151
+ transport: data.transport ?? nested.transport,
152
+ errorMessage: data.errorMessage ?? nested.error ?? nested.errorMessage,
153
+ errorCode: data.errorCode ?? nested.error_code ?? nested.errorCode,
154
+ apiEndpoint: data.apiEndpoint ?? nested.api_endpoint,
155
+ model: data.model ?? nested.model,
156
+ durationMs: data.durationMs ?? data.modelCallDurationMs ?? nested.durationMs,
157
+ // The CLI envelope carries no `source`, and an agent-scoped failure never
158
+ // reaches here: `handleModelCallFailure` returns on `logAgentScopedFailure`
159
+ // first. An absent source on this shape is the root session's own call.
160
+ source: typeof data.source === 'string' ? data.source : 'top_level',
161
+ };
162
+ }
122
163
  function isRecoverableModelCallFailure(data) {
123
164
  if (!data)
124
165
  return false;
@@ -906,72 +947,6 @@ function parseMcpConfig(configPath, mcpServerWorkingDirectory, terminalLogLevel)
906
947
  return null;
907
948
  }
908
949
  }
909
- const ENV_REFERENCE = /^\$\{([A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*)\}$/;
910
- /**
911
- * Resolves ${NAME} and ${NAME|FALLBACK|...}, taking the first variable that is set.
912
- *
913
- * The fallback chain exists because there is no single conventional name for a GitHub
914
- * token: the Copilot CLI itself reads COPILOT_GITHUB_TOKEN, then GH_TOKEN, then
915
- * GITHUB_TOKEN. A default that insisted on one spelling would silently ship an
916
- * unauthenticated server in any environment that chose a different one.
917
- */
918
- function resolveMcpEnvironmentValue(value) {
919
- const match = ENV_REFERENCE.exec(value);
920
- if (!match)
921
- return value;
922
- for (const name of match[1].split('|')) {
923
- const resolved = process.env[name];
924
- if (resolved !== undefined && resolved !== '')
925
- return resolved;
926
- }
927
- return undefined;
928
- }
929
- function resolveMcpEnvironmentRecord(values) {
930
- if (values === undefined)
931
- return undefined;
932
- const resolved = {};
933
- for (const [key, value] of Object.entries(values)) {
934
- const environmentValue = resolveMcpEnvironmentValue(value);
935
- if (environmentValue !== undefined)
936
- resolved[key] = environmentValue;
937
- }
938
- return Object.keys(resolved).length > 0 ? resolved : undefined;
939
- }
940
- // Embedded references, so `Bearer ${A|B}` resolves rather than only a bare `${A|B}`. The
941
- // fallback alternatives must be part of the pattern: a reference matcher without them finds
942
- // nothing in `${A|B}`, leaves the value untouched, and emits the placeholder as a literal
943
- // header — an unauthenticated server that reports no error and simply returns no tools.
944
- const HEADER_ENV_REFERENCE = /\$\{([A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*)\}/g;
945
- function resolveMcpHeaders(headers, terminalLogLevel) {
946
- if (headers === undefined)
947
- return undefined;
948
- const resolved = {};
949
- for (const [key, value] of Object.entries(headers)) {
950
- const references = [...value.matchAll(HEADER_ENV_REFERENCE)];
951
- let resolvedValue = value;
952
- let complete = true;
953
- for (const reference of references) {
954
- const names = reference[1].split('|');
955
- const environmentValue = names
956
- .map((name) => process.env[name])
957
- .find((candidate) => candidate !== undefined && candidate !== '');
958
- if (environmentValue === undefined) {
959
- // Dropping the header is deliberate — a missing credential must not abort a run — but
960
- // it is invisible at the server, which just answers with nothing.
961
- try {
962
- writeDiagnosticStderr('warning', `[SdkBackend] MCP header ${key} dropped: none of ${names.join(', ')} is set\n`, terminalLogLevel);
963
- }
964
- catch { /* diagnostics must not break configuration */ }
965
- complete = false;
966
- break;
967
- }
968
- resolvedValue = resolvedValue.replace(reference[0], environmentValue);
969
- }
970
- if (complete)
971
- resolved[key] = resolvedValue;
972
- }
973
- return Object.keys(resolved).length > 0 ? resolved : undefined;
974
- }
975
950
  function toSdkMcpServers(servers, terminalLogLevel) {
976
951
  if (servers === undefined || servers === false)
977
952
  return servers;
@@ -981,14 +956,19 @@ function toSdkMcpServers(servers, terminalLogLevel) {
981
956
  if (!parsed)
982
957
  continue;
983
958
  if ('url' in parsed) {
984
- const headers = resolveMcpHeaders(parsed.headers, terminalLogLevel);
959
+ const headers = (0, mcp_env_1.resolveMcpHeaders)(parsed.headers, ({ header, names }) => {
960
+ try {
961
+ writeDiagnosticStderr('warning', `[SdkBackend] MCP header ${header} dropped: none of ${names.join(', ')} is set\n`, terminalLogLevel);
962
+ }
963
+ catch { /* diagnostics must not break configuration */ }
964
+ });
985
965
  converted[name] = {
986
966
  ...parsed,
987
967
  ...(headers !== undefined ? { headers } : { headers: undefined }),
988
968
  };
989
969
  }
990
970
  else {
991
- const env = resolveMcpEnvironmentRecord(parsed.env);
971
+ const env = (0, mcp_env_1.resolveMcpEnvironmentRecord)(parsed.env);
992
972
  converted[name] = {
993
973
  ...parsed,
994
974
  ...(env !== undefined ? { env } : { env: undefined }),
@@ -1115,6 +1095,13 @@ class SdkSession {
1115
1095
  deferredIdle = false;
1116
1096
  parentUsage;
1117
1097
  turnSettled = false;
1098
+ /**
1099
+ * Resolvers parked by `continueSession`, settled by the *next* idle/error.
1100
+ * `continueSession` owns the wait for its own turn so `agent()` does not have
1101
+ * to re-register idle/error handlers it cannot remove (the session interface
1102
+ * has no `off`).
1103
+ */
1104
+ settleWaiters = [];
1118
1105
  aborted = false;
1119
1106
  sessionId;
1120
1107
  originalPrompt;
@@ -1227,6 +1214,9 @@ class SdkSession {
1227
1214
  this.emitError(new Error('Session is aborted'));
1228
1215
  return;
1229
1216
  }
1217
+ // A parked `continueSession` belongs to the previous turn. This is a fresh
1218
+ // lifecycle; leaving it parked would hang its caller forever.
1219
+ this.rejectContinuation(new Error('Session was restarted before the continuation settled'));
1230
1220
  this.originalPrompt = prompt;
1231
1221
  this.sessionId = undefined;
1232
1222
  this.sdkSessionConfigFactory = undefined;
@@ -1262,6 +1252,67 @@ class SdkSession {
1262
1252
  this.fail(err instanceof Error ? err : new Error(String(err)), 'session.run');
1263
1253
  });
1264
1254
  }
1255
+ /**
1256
+ * Append one more user turn to the live session and resolve when it settles.
1257
+ *
1258
+ * This is the warm counterpart to `send()`: it never creates or resumes a
1259
+ * session, so whatever the first turn built stays intact. `agent()` uses it to
1260
+ * ask for a declared output the first turn forgot.
1261
+ *
1262
+ * Rejects when the session is aborted, the handle is gone, a compaction owns
1263
+ * the session, the transport refuses the turn, or the turn itself fails. Every
1264
+ * rejection means "no continuation happened"; callers report the miss rather
1265
+ * than falling back to a cold restart.
1266
+ */
1267
+ async continueSession(prompt) {
1268
+ if (this.aborted)
1269
+ throw new Error('Session is aborted');
1270
+ // A recovery in flight is already resuming this session; let it finish so we
1271
+ // send the continuation over whatever handle it installs.
1272
+ if (this.recoveryInProgress)
1273
+ await this.recoveryInProgress;
1274
+ if (this.aborted)
1275
+ throw new Error('Session is aborted');
1276
+ if (this.activeCompactionEventId !== undefined) {
1277
+ throw new Error('Session is compacting; cannot continue the turn');
1278
+ }
1279
+ const handle = this._sdkSession;
1280
+ if (!handle)
1281
+ throw new Error('Session handle is gone; cannot continue the turn');
1282
+ // Reopen the turn so idle, the heartbeat and `fail()` treat the
1283
+ // continuation as live rather than as an already-settled turn.
1284
+ this.turnSettled = false;
1285
+ this.deferredIdle = false;
1286
+ this._turnText.clear();
1287
+ this.lastForwardProgressAt = Date.now();
1288
+ this.lastForwardProgressType = 'continuation-prompt';
1289
+ const waiter = { resolve: () => undefined, reject: () => undefined };
1290
+ const settled = new Promise((resolve, reject) => {
1291
+ waiter.resolve = resolve;
1292
+ waiter.reject = reject;
1293
+ });
1294
+ this.settleWaiters.push(waiter);
1295
+ // The attempt deadline is absolute, so this re-arms the remainder of it
1296
+ // rather than granting the continuation a fresh budget.
1297
+ this.rearmHardTimeout(handle);
1298
+ this.resetHeartbeat();
1299
+ try {
1300
+ await handle.rpc.sendMessages({
1301
+ messages: [{ prompt, displayPrompt: prompt }],
1302
+ wait: false,
1303
+ });
1304
+ }
1305
+ catch (error) {
1306
+ this.removeContinuationWaiter(waiter);
1307
+ throw error instanceof Error ? error : new Error(String(error));
1308
+ }
1309
+ await settled;
1310
+ }
1311
+ removeContinuationWaiter(waiter) {
1312
+ const index = this.settleWaiters.indexOf(waiter);
1313
+ if (index >= 0)
1314
+ this.settleWaiters.splice(index, 1);
1315
+ }
1265
1316
  /**
1266
1317
  * Internal async entrypoint that creates the client, session, wires events,
1267
1318
  * sends the prompt, and handles lifecycle.
@@ -1896,7 +1947,7 @@ class SdkSession {
1896
1947
  });
1897
1948
  }
1898
1949
  scheduleRecovery(failedHandle, error, data, eventId, options = {}) {
1899
- const policy = effectiveSessionRecoveryPolicy(this.config.sessionRecovery);
1950
+ const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
1900
1951
  if (!policy) {
1901
1952
  const eventType = data?.failureKind === 'session-progress-timeout'
1902
1953
  ? 'session.progress_timeout'
@@ -1928,7 +1979,7 @@ class SdkSession {
1928
1979
  }
1929
1980
  this.recoveryStartedAt ??= Date.now();
1930
1981
  this.recoveryDeadlineMs ??= Math.min(this.sessionDeadlineMs ?? Number.POSITIVE_INFINITY, this.recoveryStartedAt + policy.budgetMs);
1931
- const delayMs = recoveryDelayMs(policy, this.recoveryContinuation);
1982
+ const delayMs = (0, session_recovery_policy_1.recoveryDelayMs)(policy, this.recoveryContinuation);
1932
1983
  this.emitRecovery('scheduled', policy, this.recoveryContinuation + 1, sessionId, {
1933
1984
  delayMs,
1934
1985
  ...this.recoveryMetadata(data),
@@ -1972,49 +2023,126 @@ class SdkSession {
1972
2023
  this.exhaustRecovery(policy, sessionId, originalError, 'balanced root history has no terminal completion evidence', data);
1973
2024
  return;
1974
2025
  }
1975
- this.recoveryContinuation += 1;
1976
- this.emitRecovery('resuming', policy, this.recoveryContinuation, sessionId, {
1977
- ...this.recoveryMetadata(data),
1978
- });
1979
- // Fence the old handle synchronously before any asynchronous cleanup.
2026
+ // A handshake that fails reconnects and nudges again rather than ending
2027
+ // the turn. Every resume passes `continuePendingWork: false` and every
2028
+ // continuation sends an empty message list, so no attempt replays a turn
2029
+ // or re-issues a tool call: the session is left exactly where the failed
2030
+ // turn stopped, and the next attempt resumes that same state. The
2031
+ // continuation count and the recovery budget are what bound the loop.
2032
+ for (;;) {
2033
+ this.recoveryContinuation += 1;
2034
+ this.emitRecovery('resuming', policy, this.recoveryContinuation, sessionId, {
2035
+ ...this.recoveryMetadata(data),
2036
+ });
2037
+ try {
2038
+ await this.attemptSameSessionResume(failedHandle, policy, before, data);
2039
+ return;
2040
+ }
2041
+ catch (attemptError) {
2042
+ if (this.aborted || this.turnSettled)
2043
+ return;
2044
+ const failed = toResumeAttemptError(attemptError, 'fence');
2045
+ if (failed.fatal
2046
+ || this.recoveryContinuation >= policy.maxContinuations
2047
+ || (this.recoveryDeadlineMs !== undefined && Date.now() >= this.recoveryDeadlineMs)) {
2048
+ this.exhaustRecovery(policy, sessionId, originalError, failed.message, data, failed.stage);
2049
+ return;
2050
+ }
2051
+ const retryDelayMs = (0, session_recovery_policy_1.recoveryDelayMs)(policy, this.recoveryContinuation);
2052
+ this.emitRecovery('scheduled', policy, this.recoveryContinuation + 1, sessionId, {
2053
+ delayMs: retryDelayMs,
2054
+ ...this.recoveryMetadata(data),
2055
+ });
2056
+ try {
2057
+ await this.waitWithinRecoveryDeadline(retryDelayMs);
2058
+ }
2059
+ catch {
2060
+ // The budget, not the handshake, is what ended recovery, so the
2061
+ // reason worth reporting is the handshake's own failure.
2062
+ this.exhaustRecovery(policy, sessionId, originalError, failed.message, data, failed.stage);
2063
+ return;
2064
+ }
2065
+ }
2066
+ }
2067
+ }
2068
+ catch (recoveryError) {
2069
+ if (this.aborted || this.turnSettled)
2070
+ return;
2071
+ // Only the pre-flight reaches here: the initial recovery wait and the
2072
+ // history audit that precedes the first handshake. A failing handshake is
2073
+ // handled inside the loop, which retries it within the same bounds.
2074
+ const normalized = recoveryError instanceof Error
2075
+ ? recoveryError
2076
+ : new Error(String(recoveryError));
2077
+ this.exhaustRecovery(policy, sessionId, originalError, normalized.message, data);
2078
+ }
2079
+ }
2080
+ /**
2081
+ * One reconnect handshake: fence the failed handle, stand up a fresh client,
2082
+ * resume the same session without replaying any turn, restore the resumed
2083
+ * session's configuration, and nudge it with an empty message.
2084
+ *
2085
+ * Repeating this is safe by construction. `continuePendingWork: false` plus an
2086
+ * empty-message send appends nothing to the transcript, so a handshake that
2087
+ * fails at any step leaves the session where the failed turn stopped and the
2088
+ * next attempt resumes that same state.
2089
+ *
2090
+ * Returns once the continuation was sent, or once the abandoned turn turns out
2091
+ * to have completed. Throws otherwise, after tearing down whatever it built,
2092
+ * so the caller can retry from a clean fence.
2093
+ */
2094
+ async attemptSameSessionResume(failedHandle, policy, before, data) {
2095
+ const sessionId = this.sessionId;
2096
+ if (!sessionId)
2097
+ return;
2098
+ let stage = 'fence';
2099
+ try {
2100
+ // Fence synchronously before any asynchronous cleanup. Target whatever
2101
+ // handle is live: after a resume that succeeded through the handshake and
2102
+ // then failed on the nudge, that is the resumed handle, not the original.
2103
+ const handleToFence = this._sdkSession ?? failedHandle;
1980
2104
  this._sdkSession = null;
1981
2105
  this.handleGeneration += 1;
1982
2106
  this.activeCompactionEventId = undefined;
1983
2107
  this.suspendHeartbeat();
1984
- await failedHandle.disconnect().catch(() => undefined);
2108
+ await handleToFence.disconnect().catch(() => undefined);
1985
2109
  const oldClient = this._client;
1986
2110
  this._client = null;
1987
2111
  if (oldClient) {
1988
2112
  await boundedCleanup(oldClient.stop()).catch(() => undefined);
1989
2113
  await oldClient.forceStop().catch(() => undefined);
1990
2114
  }
2115
+ stage = 'client';
1991
2116
  const clientFactory = this.clientFactory;
1992
2117
  const configFactory = this.sdkSessionConfigFactory;
1993
2118
  if (!clientFactory || !configFactory) {
1994
- throw new Error('SDK recovery configuration is unavailable');
2119
+ throw new SessionResumeAttemptError(stage, 'SDK recovery configuration is unavailable', true);
1995
2120
  }
1996
2121
  const client = clientFactory();
1997
2122
  this._client = client;
1998
2123
  await client.start();
1999
2124
  const { sessionId: _createSessionId, ...resumeConfig } = configFactory(client);
2125
+ stage = 'resume';
2000
2126
  const resumed = await client.resumeSession(sessionId, {
2001
2127
  ...resumeConfig,
2002
2128
  continuePendingWork: false,
2003
2129
  });
2004
2130
  if (resumed.sessionId !== sessionId) {
2005
2131
  await resumed.disconnect().catch(() => undefined);
2006
- throw new Error(`SDK resumed unexpected session '${resumed.sessionId}' instead of '${sessionId}'`);
2132
+ throw new SessionResumeAttemptError(stage, `SDK resumed unexpected session '${resumed.sessionId}' instead of '${sessionId}'`, true);
2007
2133
  }
2008
2134
  if (this.aborted || this.turnSettled) {
2009
2135
  await resumed.disconnect().catch(() => undefined);
2010
2136
  return;
2011
2137
  }
2138
+ stage = 'configuration';
2012
2139
  this._sdkSession = resumed;
2013
2140
  this.handleGeneration += 1;
2014
2141
  this.resetStateAfterResume();
2015
2142
  this.rearmHardTimeout(resumed);
2016
2143
  await this.applyResumedSessionConfiguration(resumed);
2017
2144
  this._wireEvents(resumed);
2145
+ stage = 'audit';
2018
2146
  const afterResume = await this.auditRecoveryHistory(resumed, sessionId);
2019
2147
  if (afterResume.unmatchedRootTurns === 0) {
2020
2148
  const taskCompletedDuringHandoff = afterResume.latestRootTurnHasTerminalResponse
@@ -2029,6 +2157,7 @@ class SdkSession {
2029
2157
  // Disconnect can close the abandoned turn without completing the task.
2030
2158
  // Keep the continuation path active rather than synthesizing false success.
2031
2159
  }
2160
+ stage = 'continuation';
2032
2161
  this.lastForwardProgressAt = Date.now();
2033
2162
  this.lastForwardProgressType = 'continuation-sent';
2034
2163
  await resumed.rpc.sendMessages({ messages: [], wait: false });
@@ -2037,20 +2166,29 @@ class SdkSession {
2037
2166
  ...this.recoveryMetadata(data),
2038
2167
  });
2039
2168
  }
2040
- catch (recoveryError) {
2041
- if (this.aborted || this.turnSettled)
2042
- return;
2043
- const normalized = recoveryError instanceof Error
2044
- ? recoveryError
2045
- : new Error(String(recoveryError));
2046
- if (this.recoveryContinuation >= policy.maxContinuations
2047
- || (this.recoveryDeadlineMs !== undefined && Date.now() >= this.recoveryDeadlineMs)) {
2048
- this.exhaustRecovery(policy, sessionId, originalError, normalized.message, data);
2049
- return;
2050
- }
2051
- // Resume failures are unsafe to replay blindly. They exhaust this logical
2052
- // turn even when numerical continuations remain.
2053
- this.exhaustRecovery(policy, sessionId, originalError, normalized.message, data);
2169
+ catch (error) {
2170
+ await this.tearDownFailedResume();
2171
+ throw toResumeAttemptError(error, stage);
2172
+ }
2173
+ }
2174
+ /**
2175
+ * Undo a failed handshake so the next attempt fences from the state it would
2176
+ * have started from. The attempt may already have reached the server, so both
2177
+ * the resumed handle and the client holding it come down: a lingering client
2178
+ * could make the SDK refuse the next resume as already active.
2179
+ */
2180
+ async tearDownFailedResume() {
2181
+ const handle = this._sdkSession;
2182
+ this._sdkSession = null;
2183
+ this.handleGeneration += 1;
2184
+ this.suspendHeartbeat();
2185
+ if (handle)
2186
+ await handle.disconnect().catch(() => undefined);
2187
+ const client = this._client;
2188
+ this._client = null;
2189
+ if (client) {
2190
+ await boundedCleanup(client.stop()).catch(() => undefined);
2191
+ await client.forceStop().catch(() => undefined);
2054
2192
  }
2055
2193
  }
2056
2194
  async auditRecoveryHistory(handle, sessionId) {
@@ -2236,9 +2374,15 @@ class SdkSession {
2236
2374
  ...fields,
2237
2375
  });
2238
2376
  }
2239
- exhaustRecovery(policy, sessionId, cause, reason, data) {
2377
+ /**
2378
+ * `stage` names the reconnect step whose failure ended recovery, when the
2379
+ * end came from a handshake rather than from the budget alone. The `reason`
2380
+ * alone ("Connection is disposed.") does not say which RPC produced it.
2381
+ */
2382
+ exhaustRecovery(policy, sessionId, cause, reason, data, stage) {
2240
2383
  this.emitRecovery('exhausted', policy, this.recoveryContinuation, sessionId, {
2241
2384
  reason,
2385
+ ...(stage !== undefined ? { stage } : {}),
2242
2386
  ...this.recoveryMetadata(data),
2243
2387
  });
2244
2388
  this.fail(new types_1.SessionRecoveryExhaustedError(`Session recovery exhausted after ${this.recoveryContinuation} continuation(s): ${reason}`, { cause }), 'session.recovery_exhausted', data);
@@ -2258,6 +2402,7 @@ class SdkSession {
2258
2402
  return;
2259
2403
  const error = new Error('Session aborted');
2260
2404
  this.clearTimers();
2405
+ this.rejectContinuation(error);
2261
2406
  this.emitError(error);
2262
2407
  this.aborted = true;
2263
2408
  await this._cleanup();
@@ -2312,22 +2457,48 @@ class SdkSession {
2312
2457
  settleIdle() {
2313
2458
  if (this.aborted || this.turnSettled)
2314
2459
  return;
2315
- const policy = effectiveSessionRecoveryPolicy(this.config.sessionRecovery);
2316
- if (policy && this.recoveryContinuation > 0 && this.sessionId) {
2460
+ // A parked waiter means this idle closes a declared-output continuation
2461
+ // rather than the attempt's first turn, so the recovery bookkeeping (and
2462
+ // its `recovered` event) does not apply to it.
2463
+ const isContinuation = this.settleWaiters.length > 0;
2464
+ const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
2465
+ if (!isContinuation && policy && this.recoveryContinuation > 0 && this.sessionId) {
2317
2466
  this.emitRecovery('recovered', policy, this.recoveryContinuation, this.sessionId);
2318
2467
  }
2319
2468
  this.turnSettled = true;
2320
2469
  this._turnText.clear();
2321
2470
  this.clearTimers();
2322
2471
  this.emit('idle');
2323
- void this._cleanup();
2472
+ this.settleContinuation();
2473
+ // The handle is deliberately left alive: `agent()`'s declared-output guard
2474
+ // continues the same session when the turn ended without the artifact, and
2475
+ // it cannot do that against a torn-down client. Teardown is owned by
2476
+ // `abort()`, which the agent node always calls in its `finally`.
2477
+ }
2478
+ settleContinuation() {
2479
+ const waiters = this.settleWaiters;
2480
+ if (waiters.length === 0)
2481
+ return;
2482
+ this.settleWaiters = [];
2483
+ for (const waiter of waiters)
2484
+ waiter.resolve();
2485
+ }
2486
+ rejectContinuation(error) {
2487
+ const waiters = this.settleWaiters;
2488
+ if (waiters.length === 0)
2489
+ return;
2490
+ this.settleWaiters = [];
2491
+ for (const waiter of waiters)
2492
+ waiter.reject(error);
2324
2493
  }
2325
2494
  handleModelCallFailure(sdkSession, e) {
2326
2495
  if (this._sdkSession !== sdkSession || this.aborted)
2327
2496
  return;
2328
2497
  if (this.logAgentScopedFailure(e))
2329
2498
  return;
2330
- const data = e.data;
2499
+ // The payload may be the flat SDK shape or the CLI's nested envelope; see
2500
+ // `normalizeModelCallFailure`. Everything below reads the normalized view.
2501
+ const data = normalizeModelCallFailure(e.data);
2331
2502
  const detail = typeof data?.errorMessage === 'string'
2332
2503
  ? data.errorMessage
2333
2504
  : typeof data?.errorCode === 'string'
@@ -2377,6 +2548,7 @@ class SdkSession {
2377
2548
  this.activeCompactionEventId = undefined;
2378
2549
  this.turnSettled = true;
2379
2550
  this.clearTimers();
2551
+ this.rejectContinuation(err);
2380
2552
  try {
2381
2553
  this.writeStderr('error', `[SdkBackend] ${eventType}: ${this.serializeEventDataCompact(eventType, data)}\n`);
2382
2554
  }
@@ -2446,7 +2618,7 @@ class SdkSession {
2446
2618
  if (!e.agentId && this.nativeRetryGraceTimer) {
2447
2619
  clearTimeout(this.nativeRetryGraceTimer);
2448
2620
  this.nativeRetryGraceTimer = null;
2449
- const policy = effectiveSessionRecoveryPolicy(this.config.sessionRecovery);
2621
+ const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
2450
2622
  if (policy && this.sessionId) {
2451
2623
  this.emitRecovery('native-retry', policy, this.recoveryContinuation, this.sessionId);
2452
2624
  }
@@ -2687,16 +2859,23 @@ class SdkSession {
2687
2859
  inactivityMs,
2688
2860
  lastProgressType: this.lastForwardProgressType,
2689
2861
  };
2690
- if (this.activeToolCalls.size > 0 || this.pendingExternalRequests.size > 0) {
2691
- this.fail(error, 'session.progress_timeout.pending_work', {
2692
- ...data,
2693
- activeToolCalls: this.activeToolCalls.size,
2694
- pendingExternalRequests: this.pendingExternalRequests.size,
2695
- });
2696
- return;
2697
- }
2698
- const policy = effectiveSessionRecoveryPolicy(this.config.sessionRecovery);
2862
+ // Resolve the policy before the in-flight test: a node that opted into
2863
+ // recovery prefers the warm continuation over failing outright, even with
2864
+ // a tool call outstanding. The resume does not replay that call, so a
2865
+ // genuinely running subagent read is abandoned and re-issued by the model.
2866
+ // For the read-only work recovery is opted into for, that is the right
2867
+ // trade against failing the node; a mutating node opts out of recovery.
2868
+ const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
2869
+ const hasPendingWork = this.activeToolCalls.size > 0 || this.pendingExternalRequests.size > 0;
2699
2870
  if (!policy) {
2871
+ if (hasPendingWork) {
2872
+ this.fail(error, 'session.progress_timeout.pending_work', {
2873
+ ...data,
2874
+ activeToolCalls: this.activeToolCalls.size,
2875
+ pendingExternalRequests: this.pendingExternalRequests.size,
2876
+ });
2877
+ return;
2878
+ }
2700
2879
  this.fail(error, 'session.progress_timeout', data);
2701
2880
  return;
2702
2881
  }