@skaile/workspaces 3.7.3 → 3.8.1

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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/app-sdk/index.js +1 -1
  3. package/dist/{asset-feeds-6XDLVHQJ.js → asset-feeds-6A2KVBQZ.js} +4 -4
  4. package/dist/{asset-feeds-6XDLVHQJ.js.map → asset-feeds-6A2KVBQZ.js.map} +1 -1
  5. package/dist/asset-manager/index.js +1 -1
  6. package/dist/bridge/drivers/claude-sdk.js +351 -30
  7. package/dist/bridge/drivers/claude-sdk.js.map +1 -1
  8. package/dist/bridge/drivers/codex.js +1 -1
  9. package/dist/bridge/drivers/continue.js +2 -2
  10. package/dist/bridge/drivers/echo.js +1 -1
  11. package/dist/bridge/drivers/gemini.js +3 -3
  12. package/dist/bridge/drivers/goose.js +2 -2
  13. package/dist/bridge/drivers/omp.js +1 -1
  14. package/dist/bridge/drivers/opencode.js +2 -2
  15. package/dist/bridge/drivers/qwen.js +3 -3
  16. package/dist/bridge/index.js +2 -2
  17. package/dist/bridge/src/drivers/claude-sdk.d.ts +148 -2
  18. package/dist/bridge/src/drivers/claude-sdk.d.ts.map +1 -1
  19. package/dist/bridge/src/types.d.ts +103 -0
  20. package/dist/bridge/src/types.d.ts.map +1 -1
  21. package/dist/{chunk-YICH6VQB.js → chunk-3ZL3GHON.js} +2 -2
  22. package/dist/{chunk-YICH6VQB.js.map → chunk-3ZL3GHON.js.map} +1 -1
  23. package/dist/{chunk-5Z4JLURL.js → chunk-4P2P5KA5.js} +3 -3
  24. package/dist/{chunk-5Z4JLURL.js.map → chunk-4P2P5KA5.js.map} +1 -1
  25. package/dist/{chunk-V7CKJGT4.js → chunk-5MIXT2MV.js} +4 -4
  26. package/dist/{chunk-V7CKJGT4.js.map → chunk-5MIXT2MV.js.map} +1 -1
  27. package/dist/{chunk-ML6OHA3S.js → chunk-5YIGODSZ.js} +2 -2
  28. package/dist/chunk-5YIGODSZ.js.map +1 -0
  29. package/dist/{chunk-6GTPLCMO.js → chunk-7SOLXVES.js} +142 -13
  30. package/dist/chunk-7SOLXVES.js.map +1 -0
  31. package/dist/{chunk-OOUQWDD5.js → chunk-D5X2IXY7.js} +6 -6
  32. package/dist/{chunk-OOUQWDD5.js.map → chunk-D5X2IXY7.js.map} +1 -1
  33. package/dist/{chunk-26L2B6P6.js → chunk-FUCYTGAE.js} +4 -4
  34. package/dist/chunk-FUCYTGAE.js.map +1 -0
  35. package/dist/{chunk-MGPHEYW2.js → chunk-HCGVNO7F.js} +2 -2
  36. package/dist/{chunk-MGPHEYW2.js.map → chunk-HCGVNO7F.js.map} +1 -1
  37. package/dist/{chunk-FSWYUSCP.js → chunk-IWHPJFQ2.js} +3 -3
  38. package/dist/{chunk-FSWYUSCP.js.map → chunk-IWHPJFQ2.js.map} +1 -1
  39. package/dist/{chunk-UGBN7MQZ.js → chunk-KLATRNFG.js} +3 -3
  40. package/dist/{chunk-UGBN7MQZ.js.map → chunk-KLATRNFG.js.map} +1 -1
  41. package/dist/{chunk-BCXI27BH.js → chunk-NVYEK4FI.js} +3 -3
  42. package/dist/{chunk-BCXI27BH.js.map → chunk-NVYEK4FI.js.map} +1 -1
  43. package/dist/{chunk-D4VHBAJC.js → chunk-O3LEV6KB.js} +4 -4
  44. package/dist/{chunk-D4VHBAJC.js.map → chunk-O3LEV6KB.js.map} +1 -1
  45. package/dist/{chunk-WHOM6NF3.js → chunk-OI5VPP4P.js} +3 -3
  46. package/dist/{chunk-WHOM6NF3.js.map → chunk-OI5VPP4P.js.map} +1 -1
  47. package/dist/{chunk-BSQGQMGA.js → chunk-OIOJMSW2.js} +3 -3
  48. package/dist/{chunk-BSQGQMGA.js.map → chunk-OIOJMSW2.js.map} +1 -1
  49. package/dist/{chunk-DGWJAPRC.js → chunk-Q57ZGD4V.js} +447 -95
  50. package/dist/chunk-Q57ZGD4V.js.map +1 -0
  51. package/dist/{chunk-ZMCBWZ77.js → chunk-SO3BKY2Y.js} +6 -6
  52. package/dist/{chunk-ZMCBWZ77.js.map → chunk-SO3BKY2Y.js.map} +1 -1
  53. package/dist/{chunk-765ZGKVC.js → chunk-XNAY6FBK.js} +3 -3
  54. package/dist/{chunk-765ZGKVC.js.map → chunk-XNAY6FBK.js.map} +1 -1
  55. package/dist/cli/index.js +30 -30
  56. package/dist/connectors/index.js +3 -3
  57. package/dist/connectors/src/connector-manager.d.ts +34 -2
  58. package/dist/connectors/src/connector-manager.d.ts.map +1 -1
  59. package/dist/connectors/src/connector-prompt.d.ts.map +1 -1
  60. package/dist/connectors/src/connector-types.d.ts +25 -0
  61. package/dist/connectors/src/connector-types.d.ts.map +1 -1
  62. package/dist/connectors/src/credential-helper-script.d.ts +48 -0
  63. package/dist/connectors/src/credential-helper-script.d.ts.map +1 -1
  64. package/dist/connectors/src/index.d.ts +3 -3
  65. package/dist/connectors/src/index.d.ts.map +1 -1
  66. package/dist/connectors/src/managed-gitconfig.d.ts +80 -0
  67. package/dist/connectors/src/managed-gitconfig.d.ts.map +1 -1
  68. package/dist/{connectors-DY7UBBV5.js → connectors-2OHRZDJZ.js} +3 -3
  69. package/dist/{connectors-DY7UBBV5.js.map → connectors-2OHRZDJZ.js.map} +1 -1
  70. package/dist/{ensure-sources-TSHLAVNU.js → ensure-sources-7DUTA24P.js} +4 -4
  71. package/dist/{ensure-sources-TSHLAVNU.js.map → ensure-sources-7DUTA24P.js.map} +1 -1
  72. package/dist/factory-assets/connectors/box.js +3 -3
  73. package/dist/factory-assets/connectors/deploy.js +3 -3
  74. package/dist/factory-assets/connectors/devserver.js +3 -3
  75. package/dist/factory-assets/connectors/flow/adapter.js +3 -3
  76. package/dist/factory-assets/connectors/flow/run-flow.js +4 -4
  77. package/dist/factory-assets/connectors/flow.js +3 -3
  78. package/dist/factory-assets/connectors/git/driver.d.ts +28 -0
  79. package/dist/factory-assets/connectors/git/driver.d.ts.map +1 -1
  80. package/dist/factory-assets/connectors/git.js +3 -3
  81. package/dist/factory-assets/connectors/gmail.js +3 -3
  82. package/dist/factory-assets/connectors/googledrive.js +3 -3
  83. package/dist/factory-assets/connectors/local.js +3 -3
  84. package/dist/factory-assets/connectors/mattermost.js +3 -3
  85. package/dist/factory-assets/connectors/memory.js +3 -3
  86. package/dist/factory-assets/connectors/minio.js +3 -3
  87. package/dist/factory-assets/connectors/postgres.js +3 -3
  88. package/dist/factory-assets/connectors/s3.js +3 -3
  89. package/dist/factory-assets/connectors/sharepoint.js +3 -3
  90. package/dist/factory-assets/connectors/sqlite.js +3 -3
  91. package/dist/factory-assets/connectors/static-server.js +3 -3
  92. package/dist/factory-assets/connectors/tunnel.js +3 -3
  93. package/dist/factory-assets/connectors/webdav.js +3 -3
  94. package/dist/factory-assets/connectors/xstate-store.js +3 -3
  95. package/dist/factory-assets/connectors/xstate.js +3 -3
  96. package/dist/{helpers-PVM4MQV2.js → helpers-IA562IEW.js} +4 -4
  97. package/dist/{helpers-PVM4MQV2.js.map → helpers-IA562IEW.js.map} +1 -1
  98. package/dist/{library-publish-P7ZVEY6I.js → library-publish-RQUQXZW6.js} +6 -6
  99. package/dist/{library-publish-P7ZVEY6I.js.map → library-publish-RQUQXZW6.js.map} +1 -1
  100. package/dist/{open-library-QG4LHFQQ.js → open-library-74EQADJ4.js} +5 -5
  101. package/dist/{open-library-QG4LHFQQ.js.map → open-library-74EQADJ4.js.map} +1 -1
  102. package/dist/{project-YE6GKQTZ.js → project-OKXPPH2C.js} +5 -5
  103. package/dist/{project-YE6GKQTZ.js.map → project-OKXPPH2C.js.map} +1 -1
  104. package/dist/runner/index.js +7 -7
  105. package/dist/runner/src/ai-provider-switch.d.ts +95 -0
  106. package/dist/runner/src/ai-provider-switch.d.ts.map +1 -0
  107. package/dist/runner/src/credential-rotated.d.ts +23 -4
  108. package/dist/runner/src/credential-rotated.d.ts.map +1 -1
  109. package/dist/runner/src/serve.d.ts.map +1 -1
  110. package/dist/runner/src/session-builder.d.ts +19 -0
  111. package/dist/runner/src/session-builder.d.ts.map +1 -1
  112. package/dist/sdk/asset-manager.js +1 -1
  113. package/dist/sdk/bridge.js +2 -2
  114. package/dist/sdk/index.js +7 -7
  115. package/dist/sdk/runner.js +7 -7
  116. package/dist/sdk/session.js +2 -2
  117. package/dist/sdk/types.js +1 -1
  118. package/dist/session/index.js +2 -2
  119. package/dist/{setup-UYLZFES6.js → setup-Q6ANIFZE.js} +4 -4
  120. package/dist/{setup-UYLZFES6.js.map → setup-Q6ANIFZE.js.map} +1 -1
  121. package/dist/{source-5GTHOFT6.js → source-P27ND6I4.js} +6 -6
  122. package/dist/{source-5GTHOFT6.js.map → source-P27ND6I4.js.map} +1 -1
  123. package/dist/{source-manifest-publish-DK3CPBSI.js → source-manifest-publish-PKYHNVGG.js} +7 -7
  124. package/dist/{source-manifest-publish-DK3CPBSI.js.map → source-manifest-publish-PKYHNVGG.js.map} +1 -1
  125. package/dist/tui/index.js +7 -7
  126. package/dist/types/index.js +1 -1
  127. package/dist/types/src/events.d.ts +34 -1
  128. package/dist/types/src/events.d.ts.map +1 -1
  129. package/dist/types/src/index.d.ts +1 -1
  130. package/dist/types/src/index.d.ts.map +1 -1
  131. package/dist/types/src/lifecycle.d.ts +13 -0
  132. package/dist/types/src/lifecycle.d.ts.map +1 -1
  133. package/dist/types/src/version.d.ts +22 -1
  134. package/dist/types/src/version.d.ts.map +1 -1
  135. package/dist/workspace-plugin/adapters/mcp.js +2 -2
  136. package/dist/workspace-plugin/adapters/mcp.js.map +1 -1
  137. package/dist/workspace-plugin/adapters/omp.js +8 -8
  138. package/dist/workspace-plugin/adapters/omp.js.map +1 -1
  139. package/dist/workspace-plugin/index.js +1 -1
  140. package/dist/workspace-plugin/src/adapters/mcp.d.ts.map +1 -1
  141. package/dist/workspace-plugin/src/adapters/omp.d.ts.map +1 -1
  142. package/dist/workspace-plugin/src/tools/connectors.d.ts +7 -3
  143. package/dist/workspace-plugin/src/tools/connectors.d.ts.map +1 -1
  144. package/package.json +1 -1
  145. package/dist/chunk-26L2B6P6.js.map +0 -1
  146. package/dist/chunk-6GTPLCMO.js.map +0 -1
  147. package/dist/chunk-DGWJAPRC.js.map +0 -1
  148. package/dist/chunk-ML6OHA3S.js.map +0 -1
@@ -2,7 +2,7 @@ import { scrubPoisonedTranscript, resolveClaudeConfigDir, SUPPORTED_CLOUDS, buil
2
2
  import { dispatchCapability } from '../../chunk-RRVQAE5D.js';
3
3
  import { fetchProviderModels } from '../../chunk-V34ZEXSE.js';
4
4
  import { AuthError, classifyClaudeSdkError, reclassifyAuthAsAccountUnavailable, AccountUnavailableError } from '../../chunk-GAH4YRCI.js';
5
- import { BUILTIN_DRIVER_CATALOG, AgentDriver, getBridgeLogger } from '../../chunk-ML6OHA3S.js';
5
+ import { BUILTIN_DRIVER_CATALOG, AgentDriver, getBridgeLogger } from '../../chunk-5YIGODSZ.js';
6
6
  import '../../chunk-6E6PKKAD.js';
7
7
  import '../../chunk-AZPXCHDK.js';
8
8
  import '../../chunk-F3HNW354.js';
@@ -159,7 +159,12 @@ if (typeof globalThis.require === "undefined") {
159
159
  }
160
160
  }
161
161
  function freshRetryBudget() {
162
- return { staleResume: false, poison: false, auth: false, queryEnded: false };
162
+ return { staleResume: false, poison: false, auth: false, queryEnded: false, limit: false };
163
+ }
164
+ function isSeatCapacityFailure(detail, subtype) {
165
+ if (detail.category !== "rate_limit") return false;
166
+ if (subtype === "error_max_budget_usd") return false;
167
+ return detail.statusCode !== 529;
163
168
  }
164
169
  var QueryEndedBeforeTurnError = class extends Error {
165
170
  constructor(message) {
@@ -178,6 +183,23 @@ function firstMessageStallMs() {
178
183
  const value = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_FIRST_MESSAGE_STALL_MS;
179
184
  return Math.min(value, turnStallMs());
180
185
  }
186
+ var CLI_AUTH_RENDER_MARKERS = [
187
+ "failed to authenticate",
188
+ "api error:",
189
+ "authentication_error",
190
+ "not logged in",
191
+ "please run /login",
192
+ "oauth authentication is currently not supported"
193
+ ];
194
+ function isCliAuthRenderText(text) {
195
+ const lower = text.toLowerCase();
196
+ return CLI_AUTH_RENDER_MARKERS.some((marker) => lower.includes(marker));
197
+ }
198
+ var DEFAULT_ERROR_RESULT_GRACE_MS = 5e3;
199
+ function errorResultGraceMs() {
200
+ const raw = Number(process.env.SKAILE_ERROR_RESULT_GRACE_MS);
201
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_ERROR_RESULT_GRACE_MS;
202
+ }
181
203
  var RECYCLE_TURNS_FRACTION = 0.8;
182
204
  var MIN_RECYCLE_HEADROOM = 5;
183
205
  var PushableInputStream = class {
@@ -241,6 +263,16 @@ var ClaudeSdkDriver = class extends AgentDriver {
241
263
  emittedSanitizedLen = 0;
242
264
  /** Set once the streaming text of the current message classifies as a self-healable auth render. */
243
265
  suppressingAuthStream = false;
266
+ /**
267
+ * The auth render this attempt dropped on the promise that a self-heal would
268
+ * retry — the evidence {@link handleResultErrorPayload} otherwise lacks when
269
+ * the SDK reports the same 401 as an `is_error` result with an empty
270
+ * `errors[]`. Written only through {@link stashSuppressedAuthRender}, which
271
+ * applies a stricter test than suppression does; see its docblock. Cleared at
272
+ * both ends of {@link runPrompt}, so it can only ever describe the turn that
273
+ * is failing.
274
+ */
275
+ suppressedAuthRenderText = null;
244
276
  /** Active query — stays alive between turns (streaming input mode). */
245
277
  query = null;
246
278
  /**
@@ -282,6 +314,12 @@ var ClaudeSdkDriver = class extends AgentDriver {
282
314
  lastSdkMessageAt = 0;
283
315
  /** Armed while a turn is in flight; trips when the SDK goes silent for too long. */
284
316
  turnStallTimer = null;
317
+ /**
318
+ * Armed only while an `is_error` result waits for its follow-up iterator
319
+ * throw; fails the turn if that throw never lands. Cleared at every turn
320
+ * start and settle site so it can never fire into a later turn.
321
+ */
322
+ errorResultGraceTimer = null;
285
323
  /**
286
324
  * Mirror of the in-flight {@link prompt} call's auth retry budget: `false`
287
325
  * while the `onAuthError` self-heal is still available, `true` once it has
@@ -292,6 +330,52 @@ var ClaudeSdkDriver = class extends AgentDriver {
292
330
  * reset elsewhere — the next call's set is the only legitimate transition.
293
331
  */
294
332
  authSelfHealUsed = false;
333
+ /**
334
+ * Mirror of the in-flight turn's seat-failover budget — same contract as
335
+ * {@link authSelfHealUsed}, for the `onLimitBlocked` path.
336
+ */
337
+ limitFailoverUsed = false;
338
+ /**
339
+ * Set when THIS attempt saw an upstream usage-limit rejection: either the
340
+ * SDK's `rate_limit_event` telemetry with `status: "rejected"`, or an error
341
+ * result the classifier calls `rate_limit`.
342
+ *
343
+ * The telemetry is the load-bearing half. A subscription ceiling arrives as
344
+ * `{"status":"rejected","type":"five_hour"}` followed by an error result
345
+ * whose text ("You've hit your session limit · resets 9:50am") matches none
346
+ * of the classifier's rate-limit keywords — so without this flag the
347
+ * rejection classifies as `unknown` and no failover is reachable at all.
348
+ *
349
+ * Reset at the top of every {@link runPrompt} attempt, so the replay after a
350
+ * failover observes its own outcome rather than inheriting the first one's.
351
+ */
352
+ limitRejectionSeen = false;
353
+ /**
354
+ * True when {@link handleResultErrorPayload} rejected this turn ONLY so the
355
+ * seat-failover branch could run — a turn that, before this existed, would
356
+ * have emitted its error and resolved. If the failover then declines, the
357
+ * rejection is undone so a limit block with no peer available looks exactly
358
+ * as it always has downstream (one error event, one `agent_end`, a resolved
359
+ * promise) rather than also drawing the runner's fatal "prompt failed".
360
+ */
361
+ limitTurnRejectedForFailover = false;
362
+ /**
363
+ * Whether this attempt already reported a provider rejection. One upstream
364
+ * failure routinely reaches the driver twice — `rejected` telemetry followed
365
+ * by the error result, or an `is_error` result followed by the iterator throw
366
+ * carrying the real text — and these observations feed counters, so counting
367
+ * one rejection twice would overstate the seat's pressure.
368
+ */
369
+ providerResponseReported = false;
370
+ /** Limit-window metadata from this attempt's rejection telemetry, if any. */
371
+ lastLimitInfo = null;
372
+ /**
373
+ * The `agent-event: error` {@link failTurn} withheld because a recovery
374
+ * (auth self-heal or seat failover) was about to run. The recovery emits it
375
+ * if it declines, and drops it if it retries — so a recovered turn shows the
376
+ * user nothing and a failed one shows exactly one error.
377
+ */
378
+ deferredTurnError = null;
295
379
  /** Tracks whether a session has been started (for continue: true). */
296
380
  hasSession = false;
297
381
  /** Session ID from the SDK — used for streamInput messages. */
@@ -422,8 +506,14 @@ var ClaudeSdkDriver = class extends AgentDriver {
422
506
  async runPrompt(message, _budget) {
423
507
  if (!this.sdk) await this.start();
424
508
  this.authSelfHealUsed = _budget.auth;
509
+ this.limitFailoverUsed = _budget.limit;
510
+ this.limitRejectionSeen = false;
511
+ this.limitTurnRejectedForFailover = false;
512
+ this.providerResponseReported = false;
513
+ this.lastLimitInfo = null;
514
+ this.deferredTurnError = null;
425
515
  let retrying = false;
426
- const isFirstAttempt = !_budget.staleResume && !_budget.poison && !_budget.auth && !_budget.queryEnded;
516
+ const isFirstAttempt = !_budget.staleResume && !_budget.poison && !_budget.auth && !_budget.queryEnded && !_budget.limit;
427
517
  if (isFirstAttempt) {
428
518
  this._lastTokens = null;
429
519
  this._lastContextTokens = null;
@@ -432,7 +522,9 @@ var ClaudeSdkDriver = class extends AgentDriver {
432
522
  this.prevText = "";
433
523
  this.emittedSanitizedLen = 0;
434
524
  this.suppressingAuthStream = false;
525
+ this.suppressedAuthRenderText = null;
435
526
  this.turnCompleted = false;
527
+ this.disarmErrorResultGrace();
436
528
  if (this.query && (this.inputStream == null || this.inputStream.ended || this.shouldRecycleQuery())) {
437
529
  this.recycleQueryForNextTurn();
438
530
  }
@@ -447,6 +539,7 @@ var ClaudeSdkDriver = class extends AgentDriver {
447
539
  await this.startQuery(message);
448
540
  }
449
541
  } catch (err) {
542
+ this.disarmErrorResultGrace();
450
543
  this.turnResolve = null;
451
544
  this.turnReject = null;
452
545
  this.running = false;
@@ -476,20 +569,40 @@ var ClaudeSdkDriver = class extends AgentDriver {
476
569
  return this.runPrompt(message, { ..._budget, poison: true });
477
570
  }
478
571
  }
479
- const isAuthError = err instanceof AuthError && !_budget.auth;
480
- if (isAuthError && this.config.onAuthError) {
572
+ const authOwns = err instanceof AuthError && !_budget.auth && this.config.onAuthError != null;
573
+ if (authOwns) {
481
574
  if (await this.attemptAuthSelfHeal(err)) {
482
575
  retrying = true;
483
576
  return this.runPrompt(message, { ..._budget, auth: true });
484
577
  }
578
+ } else if (!_budget.limit && this.limitRejectionSeen && this.config.onLimitBlocked) {
579
+ if (await this.attemptLimitFailover(err)) {
580
+ if (this.abortController?.signal.aborted) {
581
+ this.log.info("aborted during the seat failover; not replaying the prompt");
582
+ this.emitAgentEnd();
583
+ return;
584
+ }
585
+ retrying = true;
586
+ return this.runPrompt(message, { ..._budget, limit: true });
587
+ }
588
+ if (this.limitTurnRejectedForFailover) {
589
+ this.emitAgentEnd();
590
+ return;
591
+ }
485
592
  }
486
593
  throw err;
487
594
  } finally {
488
595
  if (!retrying) {
489
596
  this.disarmTurnStallWatchdog();
597
+ this.disarmErrorResultGrace();
490
598
  this.turnResolve = null;
491
599
  this.turnReject = null;
492
600
  this.running = false;
601
+ this.limitRejectionSeen = false;
602
+ this.limitTurnRejectedForFailover = false;
603
+ this.lastLimitInfo = null;
604
+ this.deferredTurnError = null;
605
+ this.suppressedAuthRenderText = null;
493
606
  }
494
607
  }
495
608
  }
@@ -536,6 +649,42 @@ var ClaudeSdkDriver = class extends AgentDriver {
536
649
  this.turnStallTimer = null;
537
650
  }
538
651
  }
652
+ /**
653
+ * Bound the wait for the follow-up throw an `is_error` result implies. The
654
+ * throw carries the real, richly-classified error, so the deferral stays —
655
+ * this only caps it, turning a permanently pending turn into a bounded
656
+ * failure. Never arms onto an already-settled turn: an auth / account / limit
657
+ * payload has run `failTurn` by the time this is reached, and the same guard
658
+ * is re-checked inside the callback because the throw normally lands in
659
+ * between. Note 2026-06-08-claude-sdk-error-result-handling.
660
+ */
661
+ armErrorResultGrace(detail, context) {
662
+ this.disarmErrorResultGrace();
663
+ if (this.turnCompleted || this.turnReject == null) return;
664
+ this.errorResultGraceTimer = setTimeout(() => {
665
+ this.errorResultGraceTimer = null;
666
+ if (this.turnCompleted || this.turnReject == null) return;
667
+ this.log.error("is_error result never produced its follow-up throw; failing the turn", {
668
+ subtype: context.subtype,
669
+ terminalReason: context.terminalReason,
670
+ category: detail.category
671
+ });
672
+ const message = "The model reported an error but sent no details. The conversation is intact \u2014 send your message again.";
673
+ this.failTurn(new Error(message), { ...detail, message });
674
+ }, errorResultGraceMs());
675
+ this.errorResultGraceTimer.unref?.();
676
+ }
677
+ /**
678
+ * The retry helpers that null `turnResolve`/`turnReject` before recursing
679
+ * need no call: they run only from `runPrompt`'s catch, which is reached only
680
+ * because `failTurn` or `rejectTurnSilently` rejected — and both disarm first.
681
+ */
682
+ disarmErrorResultGrace() {
683
+ if (this.errorResultGraceTimer) {
684
+ clearTimeout(this.errorResultGraceTimer);
685
+ this.errorResultGraceTimer = null;
686
+ }
687
+ }
539
688
  /**
540
689
  * The SDK went silent mid-turn. Tear the query down so the *next* prompt
541
690
  * starts a fresh one (a live-but-deaf query would otherwise swallow every
@@ -803,18 +952,60 @@ var ClaudeSdkDriver = class extends AgentDriver {
803
952
  this.running = false;
804
953
  return true;
805
954
  }
955
+ this.emitDeferredTurnError({ type: "error", error: err.message, detail: err.detail });
956
+ return false;
957
+ }
958
+ /**
959
+ * Side-effects of the seat-failover path: ask the host to re-resolve the AI
960
+ * provider config behind this session. On `switched: true` drop the query and
961
+ * return `true` (the caller recurses onto a fresh CLI); otherwise emit the
962
+ * deferred error and return `false`. Mirrors {@link attemptAuthSelfHeal} —
963
+ * this performs the state mutation, the caller owns the control flow.
964
+ *
965
+ * Dropping `this.query` is the whole point of the retry, not bookkeeping: a
966
+ * resident CLI caches "blocked until <reset>" and short-circuits the next
967
+ * prompt locally with no API attempt at all (skaile-ai/workspaces#566), so a
968
+ * rewritten credentials file alone would change nothing. Only a fresh spawn
969
+ * clears it. `sessionId` / `hasSession` are preserved so the new query
970
+ * resumes the same conversation.
971
+ */
972
+ async attemptLimitFailover(err) {
973
+ this.log.warn("turn blocked by an upstream usage limit; invoking onLimitBlocked", {
974
+ configId: this.config.aiProviderConfigId,
975
+ limitType: this.lastLimitInfo?.limitType,
976
+ resetsAt: this.lastLimitInfo?.resetsAt
977
+ });
978
+ let outcome;
806
979
  try {
807
- this.emit("agent-event", {
808
- type: "error",
809
- error: err.message,
810
- detail: err.detail
980
+ outcome = await this.config.onLimitBlocked({
981
+ configId: this.config.aiProviderConfigId ?? "",
982
+ ...this.lastLimitInfo ?? {}
983
+ });
984
+ } catch (failoverErr) {
985
+ this.log.warn("onLimitBlocked callback threw; surfacing the original limit error", {
986
+ error: failoverErr instanceof Error ? failoverErr.message : String(failoverErr)
811
987
  });
812
- } catch (emitErr) {
813
- this.log.warn(
814
- "agent-event listener threw during deferred auth-error surfacing; suppressing",
815
- { error: emitErr instanceof Error ? emitErr.message : String(emitErr) }
816
- );
817
988
  }
989
+ if (outcome?.switched === true) {
990
+ this.log.info("switched AI provider config; respawning the CLI and replaying the prompt", {
991
+ configId: outcome.configId
992
+ });
993
+ this.query = null;
994
+ this.endInputStream();
995
+ this.turnResolve = null;
996
+ this.turnReject = null;
997
+ this.running = false;
998
+ return true;
999
+ }
1000
+ this.log.info("no alternative AI provider config available; surfacing the limit error", {
1001
+ reason: outcome?.reason
1002
+ });
1003
+ const detail = err?.detail;
1004
+ this.emitDeferredTurnError({
1005
+ type: "error",
1006
+ error: err instanceof Error ? err.message : String(err),
1007
+ ...detail ? { detail } : {}
1008
+ });
818
1009
  return false;
819
1010
  }
820
1011
  /**
@@ -1261,6 +1452,12 @@ var ClaudeSdkDriver = class extends AgentDriver {
1261
1452
  category: detail.category,
1262
1453
  retryable: detail.retryable
1263
1454
  });
1455
+ if (detail.category === "auth") {
1456
+ this.reportProviderResponse({ status: 401 });
1457
+ } else if (isSeatCapacityFailure(detail)) {
1458
+ this.limitRejectionSeen = true;
1459
+ this.reportProviderResponse({ status: 429, ...this.lastLimitInfo ?? {} });
1460
+ }
1264
1461
  const surfaced = detail.category === "auth" ? new AuthError(detail) : detail.category === "account" ? new AccountUnavailableError(detail) : error;
1265
1462
  this.failTurn(surfaced, detail);
1266
1463
  }
@@ -1276,15 +1473,20 @@ var ClaudeSdkDriver = class extends AgentDriver {
1276
1473
  if (this.turnCompleted) return;
1277
1474
  this.turnCompleted = true;
1278
1475
  this.disarmTurnStallWatchdog();
1476
+ this.disarmErrorResultGrace();
1279
1477
  if (this.turnResolve) {
1280
1478
  this.turnResolve();
1281
1479
  this.turnResolve = null;
1282
1480
  this.turnReject = null;
1283
1481
  }
1482
+ this.emitAgentEnd();
1483
+ }
1484
+ /** Emit the turn-end signal, swallowing a throwing listener. */
1485
+ emitAgentEnd() {
1284
1486
  try {
1285
1487
  this.emit("agent-event", { type: "agent_end" });
1286
1488
  } catch (emitErr) {
1287
- this.log.warn("agent-event listener threw during completeTurn; suppressing", {
1489
+ this.log.warn("agent-event listener threw while ending the turn; suppressing", {
1288
1490
  error: emitErr instanceof Error ? emitErr.message : String(emitErr)
1289
1491
  });
1290
1492
  }
@@ -1302,6 +1504,7 @@ var ClaudeSdkDriver = class extends AgentDriver {
1302
1504
  */
1303
1505
  rejectTurnSilently(err) {
1304
1506
  this.disarmTurnStallWatchdog();
1507
+ this.disarmErrorResultGrace();
1305
1508
  this.turnCompleted = true;
1306
1509
  if (this.turnReject) {
1307
1510
  const reject = this.turnReject;
@@ -1344,29 +1547,63 @@ var ClaudeSdkDriver = class extends AgentDriver {
1344
1547
  */
1345
1548
  failTurn(err, detail) {
1346
1549
  this.disarmTurnStallWatchdog();
1550
+ this.disarmErrorResultGrace();
1551
+ const hadPendingTurn = this.turnReject != null;
1347
1552
  if (this.turnReject) {
1348
1553
  this.turnReject(err);
1349
1554
  this.turnResolve = null;
1350
1555
  this.turnReject = null;
1351
1556
  }
1352
1557
  this.turnCompleted = true;
1558
+ const event = {
1559
+ type: "error",
1560
+ error: err.message,
1561
+ ...detail ? { detail } : {}
1562
+ };
1353
1563
  const willSelfHeal = err instanceof AuthError && !this.authSelfHealUsed && this.config.onAuthError != null;
1354
- if (willSelfHeal) {
1564
+ if (hadPendingTurn && willSelfHeal) {
1355
1565
  this.log.info("deferring auth-error event; self-heal will attempt refresh");
1566
+ this.deferredTurnError = event;
1567
+ return;
1568
+ }
1569
+ if (hadPendingTurn && this.willFailoverOnLimit()) {
1570
+ this.log.info("deferring limit error event; seat failover will be attempted");
1571
+ this.deferredTurnError = event;
1356
1572
  return;
1357
1573
  }
1574
+ this.emitTurnErrorEvent(event);
1575
+ }
1576
+ /**
1577
+ * Whether a usage-limit block on the current attempt still has a seat
1578
+ * failover available. Evaluated identically by {@link failTurn} (to decide
1579
+ * whether to withhold the error event) and by {@link handleResultErrorPayload}
1580
+ * (to decide whether to reject the turn at all) — one predicate so the two
1581
+ * can never disagree and strand a deferred event.
1582
+ */
1583
+ willFailoverOnLimit() {
1584
+ return this.limitRejectionSeen && !this.limitFailoverUsed && this.config.onLimitBlocked != null;
1585
+ }
1586
+ /** Emit one `agent-event: error`, swallowing a throwing listener. */
1587
+ emitTurnErrorEvent(event) {
1358
1588
  try {
1359
- this.emit("agent-event", {
1360
- type: "error",
1361
- error: err.message,
1362
- ...detail ? { detail } : {}
1363
- });
1589
+ this.emit("agent-event", event);
1364
1590
  } catch (emitErr) {
1365
- this.log.warn("agent-event listener threw during failTurn; suppressing", {
1591
+ this.log.warn("agent-event listener threw while surfacing a turn error; suppressing", {
1366
1592
  error: emitErr instanceof Error ? emitErr.message : String(emitErr)
1367
1593
  });
1368
1594
  }
1369
1595
  }
1596
+ /**
1597
+ * Emit the error {@link failTurn} withheld, exactly once. Called by a
1598
+ * recovery path that decided NOT to retry, so every failure produces one
1599
+ * downstream error event and a recovered one produces none. `fallback` covers
1600
+ * the paths that reject without going through `failTurn`.
1601
+ */
1602
+ emitDeferredTurnError(fallback) {
1603
+ const event = this.deferredTurnError ?? fallback;
1604
+ this.deferredTurnError = null;
1605
+ this.emitTurnErrorEvent(event);
1606
+ }
1370
1607
  // ---------------------------------------------------------------------------
1371
1608
  // Message handling
1372
1609
  // ---------------------------------------------------------------------------
@@ -1383,9 +1620,17 @@ var ClaudeSdkDriver = class extends AgentDriver {
1383
1620
  }
1384
1621
  }
1385
1622
  /**
1386
- * Surface subscription rate-limit telemetry as a structured warning log.
1623
+ * Surface subscription rate-limit telemetry as a structured warning log, and
1624
+ * — on `status: "rejected"` — arm the seat failover and report the rejection
1625
+ * to the host for per-seat health attribution.
1626
+ *
1387
1627
  * Returns `true` when the message was a rate-limit event (and fully handled),
1388
1628
  * so the caller can stop dispatching.
1629
+ *
1630
+ * This used to only log. That is what left a session pinned to a parked seat:
1631
+ * the rejection was fully handled here and nothing propagated, so the
1632
+ * platform's re-resolution — which was ready and working — was reachable only
1633
+ * through a token refresh that happened to be due (skaile-ai/platform#2976).
1389
1634
  */
1390
1635
  handleRateLimitEvent(msg) {
1391
1636
  const maybeRateLimit = msg;
@@ -1397,15 +1642,45 @@ var ClaudeSdkDriver = class extends AgentDriver {
1397
1642
  utilization: info.utilization,
1398
1643
  resetsAt: info.resetsAt
1399
1644
  });
1645
+ if (info.status === "rejected") {
1646
+ this.limitRejectionSeen = true;
1647
+ this.lastLimitInfo = {
1648
+ ...info.rateLimitType ? { limitType: info.rateLimitType } : {},
1649
+ ...typeof info.resetsAt === "number" ? { resetsAt: info.resetsAt } : {},
1650
+ ...typeof info.utilization === "number" ? { utilization: info.utilization } : {}
1651
+ };
1652
+ this.reportProviderResponse({ status: 429, ...this.lastLimitInfo });
1653
+ }
1400
1654
  return true;
1401
1655
  }
1402
1656
  return false;
1403
1657
  }
1658
+ /**
1659
+ * Report an observed 401 / 429 to the host so the owning AI provider config's
1660
+ * live health counters can be bumped (skaile-ai/workspaces#368). Strictly
1661
+ * fire-and-forget: a throwing host callback must never fail the turn that
1662
+ * produced the observation.
1663
+ */
1664
+ reportProviderResponse(observation) {
1665
+ if (!this.config.onProviderResponse || this.providerResponseReported) return;
1666
+ this.providerResponseReported = true;
1667
+ try {
1668
+ this.config.onProviderResponse({
1669
+ ...observation,
1670
+ ...this.config.aiProviderConfigId ? { configId: this.config.aiProviderConfigId } : {}
1671
+ });
1672
+ } catch (err) {
1673
+ this.log.warn("onProviderResponse threw; suppressing", {
1674
+ error: err instanceof Error ? err.message : String(err)
1675
+ });
1676
+ }
1677
+ }
1404
1678
  handleAssistantMessage(msg) {
1405
1679
  this.captureAssistantContext(msg);
1406
1680
  const agentMsg = this.mapAssistantMessage(msg);
1407
1681
  if (this.isSuppressibleAuthRender(agentMsg)) {
1408
1682
  this.log.info("suppressing SDK auth-error assistant text; self-heal will retry");
1683
+ this.stashSuppressedAuthRender(this.assistantTextOf(agentMsg));
1409
1684
  this.prevText = "";
1410
1685
  this.emittedSanitizedLen = 0;
1411
1686
  this.suppressingAuthStream = false;
@@ -1425,14 +1700,37 @@ var ClaudeSdkDriver = class extends AgentDriver {
1425
1700
  this.prevText = "";
1426
1701
  this.emittedSanitizedLen = 0;
1427
1702
  this.suppressingAuthStream = false;
1703
+ this.suppressedAuthRenderText = null;
1428
1704
  this.emit("agent-event", { type: "message_end", message: agentMsg });
1429
1705
  }
1430
1706
  // True when this assistant text is the SDK's render of a 401 about to be self-healed.
1431
1707
  isSuppressibleAuthRender(msg) {
1432
- if (this.authSelfHealUsed || this.config.onAuthError == null) return false;
1708
+ if (!this.authSelfHealAvailable()) return false;
1433
1709
  if (msg.toolCalls && msg.toolCalls.length > 0) return false;
1434
- const text = typeof msg.content === "string" ? msg.content : msg.content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
1435
- return this.textIsAuthRender(text);
1710
+ return this.textIsAuthRender(this.assistantTextOf(msg));
1711
+ }
1712
+ /** Whether a 401 on this attempt can still be self-healed and retried. */
1713
+ authSelfHealAvailable() {
1714
+ return !this.authSelfHealUsed && this.config.onAuthError != null;
1715
+ }
1716
+ /**
1717
+ * Stash a dropped auth render as evidence for {@link handleResultErrorPayload}.
1718
+ *
1719
+ * Deliberately stricter than the suppression predicate. Suppression only hides
1720
+ * a message, but this text can reach the result path and construct an
1721
+ * `AuthError` — which refreshes a credential, bumps that seat's health
1722
+ * counters, and replays the turn. `classifyClaudeSdkError` buckets a bare
1723
+ * "401" or "unauthorized" as auth, so ordinary prose about authentication
1724
+ * qualifies; requiring a marker only the CLI itself emits keeps that prose out.
1725
+ */
1726
+ stashSuppressedAuthRender(text) {
1727
+ if (!this.authSelfHealAvailable()) return;
1728
+ if (!isCliAuthRenderText(text)) return;
1729
+ this.suppressedAuthRenderText = text;
1730
+ }
1731
+ /** Flatten an assistant message's text blocks; non-text blocks contribute nothing. */
1732
+ assistantTextOf(msg) {
1733
+ return typeof msg.content === "string" ? msg.content : msg.content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
1436
1734
  }
1437
1735
  // Cheap substring pre-filter keeps the full classifier off the hot path of
1438
1736
  // every assistant message / stream delta; only auth-shaped text pays for it.
@@ -1453,7 +1751,10 @@ var ClaudeSdkDriver = class extends AgentDriver {
1453
1751
  this.suppressingAuthStream = true;
1454
1752
  this.log.info("suppressing SDK auth-error stream; self-heal will retry");
1455
1753
  }
1456
- if (this.suppressingAuthStream) return;
1754
+ if (this.suppressingAuthStream) {
1755
+ this.stashSuppressedAuthRender(this.prevText);
1756
+ return;
1757
+ }
1457
1758
  const safe = streamingSafeSanitize(this.prevText);
1458
1759
  const sanitizedDelta = safe.slice(this.emittedSanitizedLen);
1459
1760
  this.emittedSanitizedLen = safe.length;
@@ -1577,11 +1878,16 @@ var ClaudeSdkDriver = class extends AgentDriver {
1577
1878
  /**
1578
1879
  * Classify + route an `is_error` result payload: auth-shaped errors throw
1579
1880
  * via `failTurn`; populated non-auth errors surface a normal `error` event;
1580
- * empty errors defer to the consumer-error catch.
1881
+ * empty errors defer to the consumer-error catch under a bounded grace timer.
1882
+ *
1883
+ * An empty `errors[]` falls back to the auth render this attempt suppressed:
1884
+ * handed nothing, the classifier can only answer `unknown`, stranding a 401
1885
+ * the render path already recognised on the same text.
1581
1886
  */
1582
1887
  handleResultErrorPayload(msg, errors) {
1888
+ const effectiveErrors = errors.length === 0 && this.suppressedAuthRenderText != null ? [this.suppressedAuthRenderText] : errors;
1583
1889
  const rawDetail = classifyClaudeSdkError({
1584
- errors,
1890
+ errors: effectiveErrors,
1585
1891
  terminalReason: msg.terminal_reason,
1586
1892
  subtype: msg.subtype,
1587
1893
  oauthCredential: this.usingOauthCredential
@@ -1591,20 +1897,35 @@ var ClaudeSdkDriver = class extends AgentDriver {
1591
1897
  subtype: msg.subtype,
1592
1898
  terminalReason: msg.terminal_reason,
1593
1899
  errorsCount: errors.length,
1900
+ fromSuppressedAuthRender: effectiveErrors !== errors,
1594
1901
  category: detail.category,
1595
1902
  retryable: detail.retryable
1596
1903
  });
1597
- const errorText = errors.join("; ") || `Agent stopped: ${msg.subtype}`;
1904
+ if (detail.category === "auth") {
1905
+ this.reportProviderResponse({ status: 401 });
1906
+ } else if (isSeatCapacityFailure(detail, msg.subtype)) {
1907
+ this.limitRejectionSeen = true;
1908
+ this.reportProviderResponse({ status: 429, ...this.lastLimitInfo ?? {} });
1909
+ }
1910
+ const errorText = effectiveErrors.join("; ") || `Agent stopped: ${msg.subtype}`;
1598
1911
  if (detail.category === "auth") {
1599
1912
  this.failTurn(new AuthError({ ...detail, message: errorText }), detail);
1600
1913
  } else if (detail.category === "account") {
1601
1914
  this.failTurn(new AccountUnavailableError({ ...detail, message: errorText }), detail);
1915
+ } else if (this.willFailoverOnLimit()) {
1916
+ this.limitTurnRejectedForFailover = true;
1917
+ this.failTurn(new Error(errorText), detail);
1602
1918
  } else if (errors.length > 0) {
1603
1919
  this.emit("agent-event", {
1604
1920
  type: "error",
1605
1921
  error: errorText,
1606
1922
  detail
1607
1923
  });
1924
+ } else {
1925
+ this.armErrorResultGrace(detail, {
1926
+ subtype: msg.subtype,
1927
+ terminalReason: msg.terminal_reason
1928
+ });
1608
1929
  }
1609
1930
  }
1610
1931
  // ---------------------------------------------------------------------------