diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 5ba4d4963..49ae37c02 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -15,6 +15,30 @@ Two standing sections outlive any single batch and must be read on every review: ## Review batches +## 2026-09-04 (targeted) — `beae2147a9487ec47ac992319f2216914b4cb62d..95103905f5`, lifecycle reliability only + +**Cursor deliberately not advanced.** This was a targeted review, not a full +batch. The developer asked, after the 2026-09-04 lost-message incident (Pylon +PRs #264–#267), whether upstream held fixes Pylon was missing on the same +paths: the Claude adapter's lifecycle emissions, synthetic turns, and +`ProviderRuntimeIngestion` admission gating. Only commits touching those paths +were assessed; the rest of the range stays undecided. + +Answer to the question that prompted it: **no**. Upstream's adapter maps +`system/status` to running and opens synthetic turns exactly as Pylon's did, and +upstream has no strict admission gate at all — the gate and the steer intent are +Pylon-only (`8d3fd193d7`, `3ba49ed5a2`). The incident was Pylon's, and so is the fix. +Three upstream commits on the same files are still worth having; one landed here. + +| Change set | Upstream | Decision | Pylon reference | Rationale or revisit condition | +| ---------- | ---------------------- | -------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| L1 | `560afffdea` / `#9135` | adopted | `upstream/2026-09-04-lifecycle-reliability` | Claude Agent SDK 0.3.170 → 0.3.260 plus failure classification for results the CLI abandons: `terminal_reason` values and 529 overloads now mark the turn failed with a user-facing reason instead of a silent success. Clean cherry-pick onto the adapter after #265; Pylon's handshake and between-turn guards sit above the changed code. 88 adapter tests pass. | +| L2 | `01f3e50eca` / `#9653` | pending | — | OpenCode approvals and stop, and the `turn.aborted` terminal mapping in ingestion. Not standalone: it sits on `#9005`, `#8778`, `#9282`, and `#9293` in `OpenCodeAdapter.ts`, none of which Pylon has. Adopt as that five-commit series in order, oldest first, in the next OpenCode batch — not as a lone cherry-pick. | +| L3 | `5b7d72aad1` / `#9167` | pending | — | Continue active threads across server self-updates. 26 files; 344 lines in `serverRuntimeStartup.ts`, which Pylon reworked for Prime turn adoption after restart (`c6881c331a`) and durable rollback (`937b3010c1`). Manual port with a Pylon-first reconciliation of the two restart models, not a cherry-pick. Depends on `#7719`, already adopted 2026-08-21. | + +Deferred register and watch list: read, unchanged. DEF-7 and DEF-8 conditions +were not re-evaluated in this targeted pass; the next full batch owes them a check. + ## 2026-09-02 — `9b2d04317c68233782e0630464ac86d77d0686f3..beae2147a9487ec47ac992319f2216914b4cb62d` The maintainer's standing instruction for this batch was to stop escalating diff --git a/apps/server/package.json b/apps/server/package.json index cb2ae4a31..c7a6f60fb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,7 +22,7 @@ "test": "vp test run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.170", + "@anthropic-ai/claude-agent-sdk": "^0.3.260", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2145342e1..a76948360 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -25,7 +25,9 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -2148,6 +2150,177 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("fails a turn when the result carries a give-up terminal_reason", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // The CLI stamps subtype success with an empty error list when it + // gives up after exhausting API retries; the terminal_reason is the + // only structured failure signal. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + result: "", + errors: [], + stop_reason: null, + terminal_reason: "api_error", + session_id: "sdk-session-api-error", + uuid: "result-api-error", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "runtime.error", + "turn.completed", + ], + ); + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "failed"); + assert.equal( + turnCompleted.payload.errorMessage, + "Claude gave up after repeated API errors.", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("fails a turn for every dead-turn terminal_reason", () => { + const reasons = [ + "blocking_limit", + "rapid_refill_breaker", + "prompt_too_long", + "image_error", + "model_error", + "malformed_tool_use_exhausted", + "budget_exhausted", + "structured_output_retry_exhausted", + "tool_deferred_unavailable", + "turn_setup_failed", + ]; + // One harness per reason: the fake query settles a single turn. + const runDeadTurn = (reason: string) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const completionFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + result: "", + errors: [], + stop_reason: null, + terminal_reason: reason, + session_id: "sdk-session-dead-turn", + uuid: `result-${reason}`, + } as unknown as SDKMessage); + const completed = yield* Fiber.join(completionFiber); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + assert.equal(completed.value.payload.state, "failed", reason); + assert.ok(completed.value.payload.errorMessage, `${reason} carries an error message`); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }; + return Effect.forEach(reasons, runDeadTurn, { discard: true }); + }); + + it.effect("fails a turn when a success result reports a 529 overload", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: true, + api_error_status: 529, + result: "", + errors: [], + stop_reason: null, + session_id: "sdk-session-overload", + uuid: "result-overload", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "failed"); + assert.equal( + turnCompleted.payload.errorMessage, + "Claude API is overloaded (529). Try again shortly.", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("interruptTurn settles live tasks and closes the provider session", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -3527,10 +3700,14 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil( + (event) => + event.type === "session.state.changed" && event.payload.reason === "api_retry:3/10", + ), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.startSession({ threadId: THREAD_ID, @@ -3576,12 +3753,40 @@ describe("ClaudeAdapterLive", () => { uuid: "tu", }, { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, - { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" }, { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" }, { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { + type: "system", + subtype: "control_request_progress", + request_id: "ctrl-1", + status: "started", + session_id: "session", + uuid: "crp", + }, + { + type: "system", + subtype: "worker_shutting_down", + reason: "host_exit", + session_id: "session", + uuid: "wsd", + }, + { + type: "system", + subtype: "informational", + content: "Loaded 3 skills", + level: "notice", + session_id: "session", + uuid: "info", + }, { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, + { + type: "conversation_reset", + new_conversation_id: "conv-2", + session_id: "session", + uuid: "cr", + }, { type: "system", subtype: "notification", @@ -3594,6 +3799,21 @@ describe("ClaudeAdapterLive", () => { ]) { harness.query.emit(message as unknown as SDKMessage); } + // Safety model-fallback notices DO surface as a warning row. + harness.query.emit({ + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + original_model: "claude-fable-5", + fallback_model: "claude-opus-4-8", + request_id: "req_test", + api_refusal_category: "cyber", + api_refusal_explanation: null, + content: "Safeguards flagged this message. Switched to Opus 4.8.", + session_id: "session", + uuid: "mrf", + } as unknown as SDKMessage); // High-priority notifications DO surface as a warning row. harness.query.emit({ type: "system", @@ -3604,6 +3824,27 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "notif-high", } as unknown as SDKMessage); + // Warning-level informational notes and refusals without a fallback + // model surface as warning rows too. + harness.query.emit({ + type: "system", + subtype: "informational", + content: "Stop hook prevented continuation", + level: "warning", + prevent_continuation: true, + session_id: "session", + uuid: "info-warn", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "model_refusal_no_fallback", + original_model: "claude-opus-5", + request_id: null, + api_refusal_explanation: "The request was declined by the API.", + content: "Model refused", + session_id: "session", + uuid: "mrnf", + } as unknown as SDKMessage); // session_state_changed maps to the matching session states. for (const [state, uuid] of [ ["running", "ssc-run"], @@ -3630,14 +3871,19 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "retry", } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); - // Exactly one warning: the high-priority notification. Nothing else. + // Exactly four warnings: the fallback notice, high-priority notification, + // warning-level informational note, and the refusal. Nothing else. assert.deepEqual( warnings.map((event) => event.payload.message), - ["context window nearly full"], + [ + "Safeguards flagged this message. Switched to Opus 4.8.", + "context window nearly full", + "Stop hook prevented continuation", + "The request was declined by the API.", + ], ); const sessionStates = runtimeEvents .filter((event) => event.type === "session.state.changed") @@ -3661,6 +3907,526 @@ describe("ClaudeAdapterLive", () => { event.payload.reason.startsWith("api_retry:"), ); assert.equal(heartbeat?.type, "session.state.changed"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + const observeUsageLimitEvents = (adapter: ClaudeAdapterShape, query: FakeClaudeQuery) => + Effect.gen(function* () { + const runtimeEvents: Array = []; + let receipt: Deferred.Deferred | undefined; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if ( + receipt && + event.type === "session.state.changed" && + event.payload.reason === "api_retry:1/1" + ) { + yield* Deferred.succeed(receipt, undefined); + } + }), + ).pipe(Effect.forkChild); + const drainSdkMessages = Effect.gen(function* () { + receipt = yield* Deferred.make(); + // The heartbeat follows queued SDK messages without adding a warning. + query.emit({ + type: "system", + subtype: "api_retry", + attempt: 1, + max_retries: 1, + retry_delay_ms: 0, + error_status: 429, + error: { type: "rate_limit_error" }, + session_id: "sdk-session-limit", + uuid: "usage-limit-drain", + } as unknown as SDKMessage); + yield* Deferred.await(receipt); + }); + return { runtimeEvents, runtimeEventsFiber, drainSdkMessages }; + }); + + it.effect("surfaces a rejected Claude usage limit once per turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + // resetsAt is epoch seconds, so the window reopens 4h 1m30s out. + const nowMs = yield* Clock.currentTimeMillis; + const rateLimitInfo = { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 4 * 60 * 60 + 90, + }; + const rejected = { + type: "rate_limit_event", + rate_limit_info: rateLimitInfo, + session_id: "sdk-session-limit", + uuid: "rate-limit-rejected", + }; + // Sibling fields drift while the window is parked, so the same rendered + // line can arrive more than once inside one turn. + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + // The repeat lands minutes later, so the remaining wait has visibly + // shrunk. Deduping on the rendered row would let that drift through. + yield* TestClock.adjust("5 minutes"); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + const usageLimitRows = () => + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")); + assert.equal(usageLimitRows().length, 1); + // A wait, not a wall clock: the server renders this row but clients read + // it from other timezones. Reading resetsAt as milliseconds would put the + // window minutes out instead of hours, so the hour also pins the scale. + assert.match( + usageLimitRows()[0] ?? "", + /^Claude usage limit reached\. This turn is paused until the 5-hour limit resets in 4h( \d{1,2}m)?\.$/, + ); + // The exact instant still rides along for clients that want to render it. + assert.deepEqual( + runtimeEvents.find((event) => event.type === "runtime.warning")?.payload.detail, + rateLimitInfo, + ); + // The raw telemetry event still flows for every copy. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + // Same window, drifting siblings: still the one pause. + harness.query.emit({ + ...rejected, + rate_limit_info: { ...rateLimitInfo, utilization: 0.99 }, + uuid: "rate-limit-rejected-drift", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.equal(usageLimitRows().length, 1); + + // Retrying inside the same window renders the identical line. Staying + // quiet there would put the new turn right back to a silent spin. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-limit", + uuid: "result-limit", + } as unknown as SDKMessage); + yield* drainSdkMessages; + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "retry", attachments: [] }); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(usageLimitRows().length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps allowed and malformed Claude rate-limit events out of the work log", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + // A turn is in flight, so silence here is the status filter doing its job + // rather than the between-turns guard. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitInfo of [ + { status: "allowed", rateLimitType: "five_hour", utilization: 0.4 }, + { status: "allowed_warning", rateLimitType: "five_hour", utilization: 0.9 }, + // Undeclared shape from an older/newer CLI must not take the session down. + undefined, + ]) { + harness.query.emit({ + type: "rate_limit_event", + ...(rateLimitInfo ? { rate_limit_info: rateLimitInfo } : {}), + session_id: "sdk-session-limit-ok", + uuid: `rate-limit-${rateLimitInfo?.status ?? "malformed"}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 3, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stays quiet when no turn is parked by the Claude limit", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The stream stays live between turns, so a reject can land with nothing + // to pause; claiming "this turn is paused" there would be a lie. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-idle", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + // Provisioned overage carries the request even though the base window + // rejected it, so the turn keeps running and needs no row. + for (const overage of [ + { overageStatus: "allowed" }, + { overageStatus: "allowed_warning" }, + { isUsingOverage: true }, + { overageInUse: true }, + ]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + utilization: 1, + ...overage, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-overage", + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + // Idle and overage-covered events still reach the account telemetry stream. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 5, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("still surfaces the pause when overage is exhausted too", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The overage-exhausted / out-of-credits shape: the base window and the + // overage it would have spent both reject, with neither isUsingOverage + // nor overageInUse set to say anything is still covered. Nothing is + // carrying the turn here, so staying quiet would be the silent spin + // this row exists to prevent. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + overageStatus: "rejected", + }, + session_id: "sdk-session-dual-reject", + uuid: "rate-limit-dual-reject", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 1); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps one row per window when two Claude limits interleave", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const nowSeconds = Math.floor(nowMs / 1000); + const rejection = (rateLimitType: string, resetsAt: number, uuid: string) => ({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-interleaved", + uuid, + }); + + // One turn can park on more than one window; each deserves its own row, + // and a later repeat of an earlier window deserves none. + for (const message of [ + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour"), + rejection("seven_day", nowSeconds + 48 * 60 * 60, "limit-seven-day"), + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour-repeat"), + ]) { + harness.query.emit(message as unknown as SDKMessage); + yield* drainSdkMessages; + } + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")) + .map((message) => message.replace(/ in \d+h( \d{1,2}m)?/, "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("re-announces a Claude limit for a synthetic turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const rejected = { + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt: Math.floor(nowMs / 1000) + 2 * 60 * 60, + }, + session_id: "sdk-session-synthetic", + uuid: "rate-limit-synthetic", + }; + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-synthetic", + uuid: "result-synthetic", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + // A background agent answering between prompts auto-starts a synthetic + // turn, which parks on the same window and needs its own row. + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-synthetic", + uuid: "assistant-synthetic", + parent_tool_use_id: null, + message: { + id: "assistant-message-synthetic", + content: [{ type: "text", text: "Following up" }], + }, + } as unknown as SDKMessage); + yield* drainSdkMessages; + harness.query.emit({ ...rejected, uuid: "rate-limit-synthetic-2" } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("drops an unusable Claude reset time, not the row or the session", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const [rateLimitType, resetsAt] of [ + ["five_hour", undefined], + // Implausibly far out once scaled to milliseconds: no credible wait. + ["seven_day", 1e20], + ] as const) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-limit-unusable", + uuid: `rate-limit-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + // A throw inside the telemetry handler would tear the session down. + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "session.exited" || event.type === "runtime.error") + .map((event) => event.type), + [], + ); + // Still live enough to take the next turn. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "still here", attachments: [] }); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("warns for unmapped Claude limits and uses a generic model bucket", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitType of ["seven_day_overage_included", "future_window"]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType }, + session_id: "sdk-session-unmapped-limit", + uuid: `rejected-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + const nowMs = yield* Clock.currentTimeMillis; + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "seven_day_overage_included", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 3600, + }, + session_id: "sdk-session-unmapped-limit", + uuid: "rejected-probed-bucket", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => event.payload.message), + [ + "Claude usage limit reached. This turn is paused until the 7-day model limit resets.", + "Claude usage limit reached. This turn is paused until the limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day model limit resets in 1h.", + ], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 3, + ); runtimeEventsFiber.interruptUnsafe(); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -4683,6 +5449,7 @@ describe("ClaudeAdapterLive", () => { { command: "pwd" }, { signal: new AbortController().signal, + requestId: "request-1", suggestions: [ { type: "setMode", @@ -4794,6 +5561,7 @@ describe("ClaudeAdapterLive", () => { { title: "hello" }, { signal: new AbortController().signal, + requestId: "request-2", suggestions: [], toolUseID: "tool-use-mcp-1", }, @@ -4820,6 +5588,7 @@ describe("ClaudeAdapterLive", () => { { command: "git status" }, { signal: new AbortController().signal, + requestId: "request-3", suggestions: [ { type: "addRules", @@ -4878,6 +5647,7 @@ describe("ClaudeAdapterLive", () => { {}, { signal: new AbortController().signal, + requestId: "request-4", toolUseID: "tool-agent-1", }, ); @@ -4902,6 +5672,7 @@ describe("ClaudeAdapterLive", () => { { pattern: "foo", path: "src" }, { signal: new AbortController().signal, + requestId: "request-5", toolUseID: "tool-grep-approval-1", }, ); @@ -5444,6 +6215,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: new AbortController().signal, + requestId: "request-6", toolUseID: "tool-exit-1", }, ); @@ -5566,7 +6338,7 @@ describe("ClaudeAdapterLive", () => { dialogKind: "resume_return", payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: "request-dialog" }, ); const requested = yield* Stream.runHead(adapter.streamEvents); @@ -5666,6 +6438,7 @@ describe("ClaudeAdapterLive", () => { const permissionPromise = canUseTool("AskUserQuestion", askInput, { signal: new AbortController().signal, + requestId: "request-7", toolUseID: "tool-ask-1", }); @@ -5792,6 +6565,7 @@ describe("ClaudeAdapterLive", () => { const permissionPromise = canUseTool("AskUserQuestion", askInput, { signal: new AbortController().signal, + requestId: "request-8", toolUseID: "tool-ask-2", }); @@ -5857,6 +6631,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: controller.signal, + requestId: "request-9", toolUseID: "tool-ask-abort", }, ); @@ -5932,6 +6707,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: controller.signal, + requestId: "request-10", toolUseID: "tool-ask-pre-aborted", }, ); @@ -5988,7 +6764,11 @@ describe("ClaudeAdapterLive", () => { }, ], }, - { signal: new AbortController().signal, toolUseID: "tool-ask-stop" }, + { + signal: new AbortController().signal, + requestId: "request-stop", + toolUseID: "tool-ask-stop", + }, ); const requestedEvent = yield* Stream.runHead(adapter.streamEvents); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 4178366d5..dea737903 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -15,6 +15,7 @@ import { type PermissionUpdate, type SDKMessage, type SDKControlGetContextUsageResponse, + type SDKRateLimitInfo, type SDKResultMessage, type SettingSource, type SDKUserMessage, @@ -335,6 +336,8 @@ interface ClaudeSessionContext { lastKnownTotalProcessedTokens: number | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + /** Limits already announced for the running turn, keyed `window:resetsAt`. */ + announcedUsageLimits: { turnId: string; keys: Set } | undefined; stopped: boolean; } @@ -444,10 +447,45 @@ function resultErrorsText(result: SDKResultMessage): string { * so they must never become the error banner. */ function resultUserFacingError(result: SDKResultMessage): string | undefined { - if (result.subtype === "success" || !Array.isArray(result.errors)) { - return undefined; + const listed = + result.subtype === "success" || !Array.isArray(result.errors) + ? undefined + : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); + if (listed) { + return listed; + } + // Structured failure markers for results whose error list is empty or + // diagnostic-only: an overloaded API (529) and the terminal reasons the + // CLI stamps when it gives up on a turn. + if (isOverloadedResult(result)) { + return "Claude API is overloaded (529). Try again shortly."; + } + switch (result.terminal_reason) { + case "api_error": + return "Claude gave up after repeated API errors."; + case "malformed_tool_use_exhausted": + return "Claude gave up after repeated malformed tool calls."; + case "budget_exhausted": + return "Claude stopped: the turn's token budget was exhausted."; + case "structured_output_retry_exhausted": + return "Claude could not produce the requested structured output."; + case "tool_deferred_unavailable": + return "Claude could not resume a deferred tool call: the tool is no longer available."; + case "turn_setup_failed": + return "Claude could not start the turn."; + case "blocking_limit": + return "Claude stopped: a usage limit blocked the request."; + case "rapid_refill_breaker": + return "Claude stopped: the context refilled too quickly after compaction."; + case "prompt_too_long": + return "Claude stopped: the prompt exceeds the model's context window."; + case "image_error": + return "Claude stopped: an image in the conversation could not be processed."; + case "model_error": + return "Claude stopped: the model returned an error."; + default: + return undefined; } - return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); } function isInterruptedResult(result: SDKResultMessage): boolean { @@ -475,6 +513,46 @@ function isInterruptedResult(result: SDKResultMessage): boolean { ); } +const CLAUDE_USAGE_LIMIT_WINDOWS = { + five_hour: "5-hour", + seven_day: "7-day", + seven_day_opus: "7-day Opus", + seven_day_sonnet: "7-day Sonnet", + seven_day_overage_included: "7-day model", + overage: "overage", +} satisfies Record, string>; + +/** Beyond this the reset time is not credible, so the row ships without a wait. */ +const CLAUDE_USAGE_LIMIT_MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * `resetsAt` is epoch seconds. The row states the remaining wait rather than a + * wall-clock time: this renders on the server, while the row is read on clients + * that may sit in another timezone and locale, and that carry their own + * timestamp preference. A wait reads the same everywhere. + */ +function describeClaudeUsageLimit(info: SDKRateLimitInfo, nowMs: number): string { + const label = info.rateLimitType ? CLAUDE_USAGE_LIMIT_WINDOWS[info.rateLimitType] : undefined; + const resetsAtMs = info.resetsAt === undefined ? undefined : info.resetsAt * 1000; + const waitMs = + resetsAtMs === undefined || !Number.isFinite(nowMs) ? undefined : resetsAtMs - nowMs; + const wait = + waitMs !== undefined && waitMs > 0 && waitMs <= CLAUDE_USAGE_LIMIT_MAX_WAIT_MS + ? formatClaudeUsageLimitWait(waitMs) + : undefined; + return `Claude usage limit reached. This turn is paused until the ${ + label ? `${label} ` : "" + }limit resets${wait ? ` in ${wait}` : ""}.`; +} + +function formatClaudeUsageLimitWait(waitMs: number): string { + const totalMinutes = Math.ceil(waitMs / 60_000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${totalMinutes}m`; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; +} + function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.make(value); } @@ -1417,7 +1495,43 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( return buildUserMessage({ sdkContent }); }); +/** + * terminal_reason values the CLI classifies as dead turns: the turn died + * rather than finished, even when the result subtype is success and the + * error list is empty. Kept in sync with the messages in + * resultUserFacingError. + */ +const FAILED_TERMINAL_REASONS: ReadonlySet> = + new Set([ + "api_error", + "malformed_tool_use_exhausted", + "budget_exhausted", + "structured_output_retry_exhausted", + "tool_deferred_unavailable", + "turn_setup_failed", + "blocking_limit", + "rapid_refill_breaker", + "prompt_too_long", + "image_error", + "model_error", + ]); + +/** + * The CLI reports repeated 529 overload failures as a success-subtype result + * with api_error_status 529 and an empty error list; the status code is the + * only structured failure signal. + */ +function isOverloadedResult(result: SDKResultMessage): boolean { + return result.subtype === "success" && result.api_error_status === 529; +} + function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { + if ( + isOverloadedResult(result) || + (result.terminal_reason !== undefined && FAILED_TERMINAL_REASONS.has(result.terminal_reason)) + ) { + return "failed"; + } if (result.subtype === "success") { return "completed"; } @@ -3274,15 +3388,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Undeclared-but-real subtypes (absent from the SDK's union, so they can't // be switch cases): consumed intentionally without emitting, otherwise // they fall through to the unknown-subtype warning and surface as spurious - // error rows in client work logs. `background_tasks_changed` is a roster - // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the - // authoritative per-agent data and the typed background_tasks control - // request is the reconciliation source. `vcs_state_changed` + // error rows in client work logs. `vcs_state_changed` // ({kind: commit|push|rebase}) and `code_change_published` // ({provider, url, repo}) are informational CLI notices; the work log // already shows the underlying git/gh tool calls. switch (message.subtype as string) { - case "background_tasks_changed": case "vcs_state_changed": case "code_change_published": return; @@ -3608,14 +3718,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* emitRuntimeWarning(context, message.text, message); } return; + case "model_refusal_fallback": + // A safety fallback switched the model mid-session (e.g. Fable 5 + // retried on Opus 4.8 after a flagged request). The CLI ships the + // user-facing notice in `content`; surface it like high-priority + // notifications so the rest of the session isn't silently served + // by a different model. + yield* emitRuntimeWarning(context, message.content, message); + return; // Inner protocol/UX details with no T3 surface today — consumed // deliberately so they don't masquerade as unknown-subtype warnings. - case "model_refusal_fallback": + // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the + // task_* lifecycle events carry the authoritative per-agent data and + // the typed background_tasks control request is the reconciliation + // source. `control_request_progress` is a liveness heartbeat for an + // in-flight control request. `worker_shutting_down` is a Remote + // Control worker notice; the session close path reports the outcome. case "local_command_output": case "plugin_install": case "commands_changed": case "memory_recall": case "elicitation_complete": + case "background_tasks_changed": + case "control_request_progress": + case "worker_shutting_down": + return; + case "informational": + // Transcript-level CLI notes. Only warnings (e.g. a Stop hook that + // refused continuation) warrant a work-log row; info/notice/ + // suggestion levels are CLI chrome. + if (message.level === "warning") { + yield* emitRuntimeWarning(context, message.content, message); + } + return; + case "model_refusal_no_fallback": + // The API refused the request and no fallback model was available. + // The terminal result reports the failed turn; this row carries the + // refusal explanation the result's error list lacks. + yield* emitRuntimeWarning( + context, + message.api_refusal_explanation?.trim() || message.content, + message, + ); return; case "permission_denied": yield* offerRuntimeEvent(context.sessionIncarnationId, { @@ -3641,7 +3785,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // handled above, so `message` narrows to never here — a new SDK // release adding a subtype fails this typecheck instead of silently // warning at runtime. The runtime fallback still catches undeclared - // wire-only subtypes (like background_tasks_changed used to be). + // wire-only subtypes (like vcs_state_changed). message satisfies never; const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( @@ -3728,6 +3872,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rateLimits: message, }, }); + const rateLimitInfo = message.rate_limit_info; + if (!rateLimitInfo) return; + // A rejected window parks the turn inside the SDK: no further messages + // arrive and no result lands, so without a row the thread just spins. + // Warnings (allowed_warning) still have headroom and stay quiet, an + // account spending provisioned overage keeps running despite the reject, + // and between turns there is no turn to report as paused. + if ( + rateLimitInfo.status === "rejected" && + rateLimitInfo.overageStatus !== "allowed" && + rateLimitInfo.overageStatus !== "allowed_warning" && + rateLimitInfo.isUsingOverage !== true && + rateLimitInfo.overageInUse !== true && + context.turnState !== undefined + ) { + // Tracked per turn as a set of limit identities, not as the rendered + // row: a parked window re-fires while the remaining wait shrinks, and a + // turn can park on more than one window, so a single slot would let an + // interleaved repeat through. A new turn — including a synthetic one — + // starts a fresh set and announces its pause again. + const turnId = context.turnState.turnId; + if (context.announcedUsageLimits?.turnId !== turnId) { + context.announcedUsageLimits = { turnId, keys: new Set() }; + } + const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`; + if (!context.announcedUsageLimits.keys.has(limitKey)) { + context.announcedUsageLimits.keys.add(limitKey); + const notice = describeClaudeUsageLimit( + rateLimitInfo, + DateTime.toEpochMillis(DateTime.makeUnsafe(stamp.createdAt)), + ); + yield* emitRuntimeWarning(context, notice, rateLimitInfo); + } + } return; } }); @@ -3767,7 +3945,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* handleSdkTelemetryMessage(context, message); return; // Composer prompt suggestions have no T3 surface; consumed deliberately. + // `conversation_reset` announces a CLI-side conversation id swap + // (e.g. /clear); T3 keeps its own thread identity and resume cursor. case "prompt_suggestion": + case "conversation_reset": return; default: { // Exhaustiveness guard (see handleSystemMessage): new SDK top-level @@ -4625,6 +4806,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTotalProcessedTokens: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + announcedUsageLimits: undefined, stopped: false, }; yield* Ref.set(contextRef, context); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 5bd3214a3..a3cb248da 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -46,6 +46,14 @@ offers to compact the conversation before you continue. You can also select **Co from the context meter. On every client, you can enter `/compact` in the message composer, and Claude can show its own resume prompt when you continue an old session. +## Usage limits + +If your Claude subscription runs out of usage mid-turn, the thread shows which +limit was reached and the remaining wait when Claude provides a reset time. +Claude Code holds the turn until that window reopens, so it can keep showing as +working. Wait for the reset, or stop the turn and continue later. The warning's +timestamp shows when the displayed wait started. + ## Where Claude Skills Are Loaded Pylon looks for Claude skills in the Claude config directory's `skills` folder and diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d39d02989..fcc20d778 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -474,8 +474,8 @@ importers: apps/server: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.170 - version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.260 + version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) @@ -1030,8 +1030,8 @@ packages: '@alchemy.run/node-utils@0.0.5': resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} - '@anthropic-ai/claude-agent-sdk@0.3.170': - resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} + '@anthropic-ai/claude-agent-sdk@0.3.260': + resolution: {integrity: sha512-PmABtP4Rwd6l95itQrqzguv6rS9uACqikPB9g8BPeWRKZOpy3xpEOjJLYauof3BFk2wNZnfhr0Ttx8ttcZzq0w==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -10905,7 +10905,7 @@ snapshots: '@alchemy.run/node-utils@0.0.5': {} - '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 21124c243..83fac53e8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -87,6 +87,7 @@ minimumReleaseAgeExclude: - expo-updates@57.0.19 - expo@57.0.18 - expo-widgets@57.0.15 + - "@anthropic-ai/claude-agent-sdk@0.3.260" overrides: # Fresh release installs must resolve the versions our native patches target.