diff --git a/.github/workflows/code-release.yml b/.github/workflows/code-release.yml index 6f0f044803..d4221b83ba 100644 --- a/.github/workflows/code-release.yml +++ b/.github/workflows/code-release.yml @@ -12,12 +12,18 @@ concurrency: jobs: publish-macos: runs-on: macos-latest + strategy: + fail-fast: false + matrix: + arch: [arm64, x64] permissions: id-token: write contents: write env: NODE_OPTIONS: "--max-old-space-size=8192" NODE_ENV: production + npm_config_arch: ${{ matrix.arch }} + npm_config_platform: darwin VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }} VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }} POSTHOG_SOURCEMAP_API_KEY: ${{ secrets.POSTHOG_SOURCEMAP_API_KEY }} @@ -128,7 +134,7 @@ jobs: env: APP_VERSION: ${{ steps.version.outputs.version }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - run: pnpm --filter code run publish + run: pnpm --filter code run publish -- --arch=${{ matrix.arch }} publish-windows: runs-on: windows-latest diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts index 2441b4852a..9c457b66f7 100644 --- a/apps/code/forge.config.ts +++ b/apps/code/forge.config.ts @@ -225,8 +225,16 @@ const config: ForgeConfig = { prePackage: async () => { if (process.platform !== "darwin") return; - // Build native modules for DMG maker on Node.js 22 + // Build native modules for DMG maker on Node.js 22. These run on the + // build host (DMG creation is host-side), so we force npm to target the + // host arch even when the rest of the build is cross-targeting (e.g. + // building darwin-x64 on an arm64 runner). const modules = ["macos-alias", "fs-xattr"]; + const hostBuildEnv = { + ...process.env, + npm_config_arch: process.arch, + npm_config_platform: process.platform, + }; for (const mod of modules) { const candidates = [ @@ -237,7 +245,11 @@ const config: ForgeConfig = { if (modulePath) { console.log(`Building native module: ${mod} (${modulePath})`); - execSync("npm install", { cwd: modulePath, stdio: "inherit" }); + execSync("npm install", { + cwd: modulePath, + stdio: "inherit", + env: hostBuildEnv, + }); } } }, @@ -245,24 +257,32 @@ const config: ForgeConfig = { electronChild = child; }, packageAfterCopy: async (_forgeConfig, buildPath) => { + // Resolve the target arch (cross-builds set npm_config_arch); fall back + // to the host so non-cross builds keep their existing behavior. + const targetArch = process.env.npm_config_arch ?? process.arch; + copyNativeDependency("node-pty", buildPath); copyNativeDependency("node-addon-api", buildPath); copyNativeDependency("@parcel/watcher", buildPath); // Platform-specific native dependencies if (process.platform === "darwin") { - copyNativeDependency("@parcel/watcher-darwin-arm64", buildPath); + const watcherPkg = + targetArch === "x64" + ? "@parcel/watcher-darwin-x64" + : "@parcel/watcher-darwin-arm64"; + copyNativeDependency(watcherPkg, buildPath); copyNativeDependency("file-icon", buildPath); copyNativeDependency("p-map", buildPath); } else if (process.platform === "win32") { const watcherPkg = - process.arch === "arm64" + targetArch === "arm64" ? "@parcel/watcher-win32-arm64" : "@parcel/watcher-win32-x64"; copyNativeDependency(watcherPkg, buildPath); } else if (process.platform === "linux") { const watcherPkg = - process.arch === "arm64" + targetArch === "arm64" ? "@parcel/watcher-linux-arm64-glibc" : "@parcel/watcher-linux-x64-glibc"; copyNativeDependency(watcherPkg, buildPath); diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 8507cc6075..fe726d9943 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -21,9 +21,7 @@ const mockClientSideConnection = vi.hoisted(() => this.initialize = vi.fn().mockResolvedValue({}); this.newSession = mockNewSession; this.loadSession = vi.fn().mockResolvedValue({ configOptions: [] }); - this.unstable_resumeSession = vi - .fn() - .mockResolvedValue({ configOptions: [] }); + this.resumeSession = vi.fn().mockResolvedValue({ configOptions: [] }); }), ); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 1596f9ff5b..d60b09b6cc 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -336,7 +336,10 @@ export class AgentService extends TypedEventEmitter { } private getClaudeCliPath(): string { - return this.bundledResources.resolve(".vite/build/claude-cli/cli.js"); + // Keep in sync with the destDir in apps/code/vite.main.config.mts + // (copyClaudeExecutable plugin). + const binary = process.platform === "win32" ? "claude.exe" : "claude"; + return this.bundledResources.resolve(`.vite/build/claude-cli/${binary}`); } private getCodexBinaryPath(): string { @@ -747,7 +750,7 @@ When creating pull requests, add the following footer at the end of the PR descr // Claude-specific: hydrate session JSONL from PostHog before resuming. // If hydration finds no conversation to restore, skip the resume and // fall through to creating a new session. This avoids a doomed - // unstable_resumeSession that would fail with "Resource not found" + // resumeSession that would fail with "Resource not found" if (isReconnect && config.sessionId) { const existingSessionId = config.sessionId; @@ -777,10 +780,10 @@ When creating pull requests, add the following footer at the end of the PR descr if (isReconnect && config.sessionId) { const existingSessionId = config.sessionId; - // Both adapters implement unstable_resumeSession: + // Both adapters implement resumeSession: // - Claude: delegates to SDK's resumeSession with JSONL hydration // - Codex: delegates to codex-acp's loadSession internally - const resumeResponse = await connection.unstable_resumeSession({ + const resumeResponse = await connection.resumeSession({ sessionId: existingSessionId, cwd: repoPath, mcpServers, diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index f7617e9fed..c0903429bd 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -764,7 +764,7 @@ export class SessionService { * The main process already cleaned up the agent, so we only need to * unsubscribe from the channel and mark the session as errored. * Preserves events, logUrl, configOptions and adapter so that Retry - * can reconnect with full context via unstable_resumeSession. + * can reconnect with full context via resumeSession. */ private handleIdleKill(taskRunId: string): void { this.unsubscribeFromChannel(taskRunId); diff --git a/apps/code/vite.main.config.mts b/apps/code/vite.main.config.mts index fd1c8ae943..4e0f4b0368 100644 --- a/apps/code/vite.main.config.mts +++ b/apps/code/vite.main.config.mts @@ -1,11 +1,14 @@ import { execFile, execSync } from "node:child_process"; import { + closeSync, copyFileSync, cpSync, existsSync, mkdirSync, + openSync, readdirSync, readFileSync, + readSync, statSync, } from "node:fs"; import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises"; @@ -16,6 +19,15 @@ import { promisify } from "node:util"; import { unzipSync } from "fflate"; import { defineConfig, loadEnv, type Plugin } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +// @ts-expect-error - plain ESM helper shared with packages/agent/tsup.config.ts +import { + CLAUDE_CLI_SUPPORT_DIRS, + CLAUDE_CLI_SUPPORT_FILES, + claudeBinName, + claudeExecutableCandidates as sdkClaudeExecutableCandidates, + targetArch, + targetPlatform, +} from "../../packages/agent/build/native-binary.mjs"; import { createForceDevModeDefine, createPosthogPlugin, @@ -58,14 +70,111 @@ function fixFilenameCircularRef(): Plugin { let claudeCliCopied = false; +function verifyBinaryArch(destPath: string): void { + // Best-effort: parse the binary's magic bytes and confirm the embedded arch + // matches what we believe we're packaging for. `file(1)` is more portable + // but adds a subprocess; reading 20 bytes is enough for Mach-O / ELF / PE. + let header: Buffer; + try { + const fd = openSync(destPath, "r"); + try { + header = Buffer.alloc(20); + readSync(fd, header, 0, 20, 0); + } finally { + closeSync(fd); + } + } catch (err) { + console.warn("[copy-claude-executable] Could not inspect binary:", err); + return; + } + + const arch = targetArch(); + const platform = targetPlatform(); + const actual = detectBinaryArch(header, platform); + if (actual && actual !== arch) { + throw new Error( + `[copy-claude-executable] Architecture mismatch: copied binary is ${actual} but target is ${arch} (platform=${platform}). ` + + `Reinstall @anthropic-ai/claude-agent-sdk optional deps for the target arch, or set npm_config_arch=${arch} before building.`, + ); + } +} + +function detectBinaryArch( + header: Buffer, + platform: string, +): "arm64" | "x64" | "ia32" | null { + // Mach-O 64-bit LE: magic 0xFEEDFACF, then cputype at offset 4 (LE). + if (platform === "darwin" && header.readUInt32LE(0) === 0xfeedfacf) { + const cpuType = header.readUInt32LE(4); + if (cpuType === 0x0100000c) return "arm64"; + if (cpuType === 0x01000007) return "x64"; + } + // ELF: \x7FELF, then e_machine at offset 18 (LE). + if ( + platform === "linux" && + header[0] === 0x7f && + header[1] === 0x45 && + header[2] === 0x4c && + header[3] === 0x46 + ) { + const eMachine = header.readUInt16LE(18); + if (eMachine === 0x3e) return "x64"; + if (eMachine === 0xb7) return "arm64"; + if (eMachine === 0x03) return "ia32"; + } + // PE: MZ at 0, PE header offset at 0x3C — too long to inline; skip. + return null; +} + +function signClaudeBinary(destPath: string): void { + if (targetPlatform() !== "darwin") return; + if (process.platform !== "darwin") { + // Can't ad-hoc sign a darwin binary from a non-darwin build host; the + // resulting app won't launch on Apple Silicon. Fail loud rather than + // shipping an unrunnable bundle. + throw new Error( + "[copy-claude-executable] Cannot ad-hoc sign darwin binary from non-darwin host. Build on macOS.", + ); + } + try { + execSync(`xattr -cr "${destPath}"`, { stdio: "inherit" }); + execSync(`codesign --force --sign - "${destPath}"`, { stdio: "inherit" }); + } catch (err) { + console.warn( + "[copy-claude-executable] FAILED to ad-hoc sign binary; macOS will reject the bundled app:", + err, + ); + } +} + +function copyClaudeSupportAssets(sourcePath: string, destDir: string): void { + const sourceDir = dirname(sourcePath); + + for (const file of CLAUDE_CLI_SUPPORT_FILES) { + const source = join(sourceDir, file); + if (existsSync(source)) { + copyFileSync(source, join(destDir, file)); + } + } + + for (const dir of CLAUDE_CLI_SUPPORT_DIRS) { + const source = join(sourceDir, dir); + if (existsSync(source)) { + cpSync(source, join(destDir, dir), { recursive: true }); + } + } +} + function copyClaudeExecutable(): Plugin { return { name: "copy-claude-executable", writeBundle() { + const binName = claudeBinName(); const destDir = join(__dirname, ".vite/build/claude-cli"); + const destBinary = join(destDir, binName); // Skip re-copying on subsequent HMR rebuilds - if (claudeCliCopied && existsSync(join(destDir, "cli.js"))) { + if (claudeCliCopied && existsSync(destBinary)) { return; } @@ -73,72 +182,35 @@ function copyClaudeExecutable(): Plugin { mkdirSync(destDir, { recursive: true }); } - const candidates = [ - { - path: join(__dirname, "node_modules/@posthog/agent/dist/claude-cli"), - type: "package", - }, - { - path: join( - __dirname, - "../../node_modules/@posthog/agent/dist/claude-cli", - ), - type: "package", - }, - { - path: join(__dirname, "../../packages/agent/dist/claude-cli"), - type: "package", - }, + const packageCandidates = [ + join(__dirname, "node_modules/@posthog/agent/dist/claude-cli", binName), + join( + __dirname, + "../../node_modules/@posthog/agent/dist/claude-cli", + binName, + ), + join(__dirname, "../../packages/agent/dist/claude-cli", binName), + ...sdkClaudeExecutableCandidates(join(__dirname, "node_modules")), + ...sdkClaudeExecutableCandidates(join(__dirname, "../../node_modules")), ]; - for (const candidate of candidates) { - if ( - existsSync(join(candidate.path, "cli.js")) && - existsSync(join(candidate.path, "yoga.wasm")) - ) { - const files = ["cli.js", "package.json", "yoga.wasm"]; - for (const file of files) { - copyFileSync(join(candidate.path, file), join(destDir, file)); - } - const vendorDir = join(candidate.path, "vendor"); - if (existsSync(vendorDir)) { - cpSync(vendorDir, join(destDir, "vendor"), { recursive: true }); - } - claudeCliCopied = true; - return; - } - } - - const rootNodeModules = join(__dirname, "../../node_modules"); - const sdkDir = join(rootNodeModules, "@anthropic-ai/claude-agent-sdk"); - const yogaDir = join(rootNodeModules, "yoga-wasm-web/dist"); - - if ( - existsSync(join(sdkDir, "cli.js")) && - existsSync(join(yogaDir, "yoga.wasm")) - ) { - copyFileSync(join(sdkDir, "cli.js"), join(destDir, "cli.js")); - copyFileSync( - join(sdkDir, "package.json"), - join(destDir, "package.json"), - ); - copyFileSync(join(yogaDir, "yoga.wasm"), join(destDir, "yoga.wasm")); - const vendorDir = join(sdkDir, "vendor"); - if (existsSync(vendorDir)) { - cpSync(vendorDir, join(destDir, "vendor"), { recursive: true }); - } - console.log( - "Assembled Claude CLI from workspace sources in claude-cli/ subdirectory", + const source = packageCandidates.find((p: string) => existsSync(p)); + if (!source) { + console.warn( + `[copy-claude-executable] FAILED to find native Claude binary for ${targetPlatform()}-${targetArch()}. Agent execution may fail.`, ); - claudeCliCopied = true; + console.warn(`Checked paths:\n ${packageCandidates.join("\n ")}`); return; } - console.warn( - "[copy-claude-executable] FAILED to find Claude CLI artifacts. Agent execution may fail.", - ); - console.warn("Checked paths:", candidates.map((c) => c.path).join(", ")); - console.warn("Checked workspace sources:", sdkDir); + copyFileSync(source, destBinary); + if (targetPlatform() !== "win32") { + execSync(`chmod +x "${destBinary}"`); + } + copyClaudeSupportAssets(source, destDir); + verifyBinaryArch(destBinary); + signClaudeBinary(destBinary); + claudeCliCopied = true; }, }; } diff --git a/packages/agent/build/native-binary.mjs b/packages/agent/build/native-binary.mjs new file mode 100644 index 0000000000..b60e06afdc --- /dev/null +++ b/packages/agent/build/native-binary.mjs @@ -0,0 +1,86 @@ +import { join } from "node:path"; + +/** + * Shared build-time helpers for resolving the Claude native binary that ships + * via `@anthropic-ai/claude-agent-sdk-${platform}-${arch}` optional deps. + * + * Used by both `packages/agent/tsup.config.ts` (bundles the binary into the + * agent package's `dist/claude-cli/`) and `apps/code/vite.main.config.mts` + * (copies it into the Electron app's `.vite/build/claude-cli/`). + * + * The runtime equivalent of this lives upstream in `acp-agent.ts` as + * `claudeCliPath()` + `isMuslLibc()`. Keep behavior in sync if the upstream + * resolution logic changes. + */ + +/** Cross-compile aware platform — electron-forge sets npm_config_platform when packaging for a target. */ +export function targetPlatform() { + return process.env.npm_config_platform ?? process.platform; +} + +/** Cross-compile aware arch — same story as targetPlatform. */ +export function targetArch() { + return process.env.npm_config_arch ?? process.arch; +} + +export function claudeBinName(platform = targetPlatform()) { + return platform === "win32" ? "claude.exe" : "claude"; +} + +export const CLAUDE_CLI_SUPPORT_FILES = [ + "package.json", + "manifest.json", + "manifest.zst.json", + "yoga.wasm", +]; + +export const CLAUDE_CLI_SUPPORT_DIRS = ["vendor"]; + +/** + * Detect whether the *current* Node was built against musl libc (not glibc). + * Only meaningful when targetPlatform() === "linux" and we're running on + * linux — cross-host packaging defaults to glibc ordering since we have no + * way to know the target's libc. + */ +export function isMuslLibc() { + if (process.platform !== "linux") return false; + const report = process.report?.getReport(); + const header = report?.header; + return !header?.glibcVersionRuntime; +} + +/** + * Ordered list of candidate paths to a Claude native binary inside a given + * node_modules root. First entry that exists should be preferred. + */ +export function nativeBinaryCandidates(rootNodeModules) { + const platform = targetPlatform(); + const arch = targetArch(); + const binary = claudeBinName(platform); + const slugs = + platform === "linux" + ? isMuslLibc() + ? [`linux-${arch}-musl`, `linux-${arch}`] + : [`linux-${arch}`, `linux-${arch}-musl`] + : [`${platform}-${arch}`]; + return slugs.map((slug) => + join(rootNodeModules, `@anthropic-ai/claude-agent-sdk-${slug}`, binary), + ); +} + +/** + * SDK 0.3.x is in the middle of transitioning from a monolithic `cli.js` + * package layout to platform-specific native executables. Keep the legacy + * entrypoint as a fallback until the optional native packages are universally + * available across our build environments. + */ +export function legacyCliCandidates(rootNodeModules) { + return [join(rootNodeModules, "@anthropic-ai/claude-agent-sdk", "cli.js")]; +} + +export function claudeExecutableCandidates(rootNodeModules) { + return [ + ...nativeBinaryCandidates(rootNodeModules), + ...legacyCliCandidates(rootNodeModules), + ]; +} diff --git a/packages/agent/package.json b/packages/agent/package.json index d836afe266..0230e1d742 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -120,9 +120,9 @@ "vitest": "^2.1.8" }, "dependencies": { - "@agentclientprotocol/sdk": "0.19.0", - "@anthropic-ai/claude-agent-sdk": "0.2.112", - "@anthropic-ai/sdk": "0.89.0", + "@agentclientprotocol/sdk": "0.22.1", + "@anthropic-ai/claude-agent-sdk": "0.3.154", + "@anthropic-ai/sdk": "0.100.0", "@hono/node-server": "^1.19.9", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index e4337fab11..8d83fb5a8d 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -5,8 +5,8 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Fork Point - **Forked**: v0.10.9, commit `5411e0f4`, Dec 2 2025 -- **Last sync**: v0.30.0, commit `e9dd452`, April 20 2026 -- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.2.112 (0.2.114 breaks session init, see agentclientprotocol/claude-agent-acp#575), `@agentclientprotocol/sdk` 0.19.0 +- **Last sync**: v0.38.0 + #716, commit `61ebda2`, May 28 2026 +- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.154, `@agentclientprotocol/sdk` 0.22.1, `@anthropic-ai/sdk` 0.100.0 ## File Mapping @@ -67,6 +67,83 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth - **Effort level sync** (v0.25.x): `xhigh` level added, `applyFlagSettings` on effort change - **Auto permission mode** (v0.25.0): Added to `CODE_EXECUTION_MODES`, available modes, ExitPlanMode options +## Changes Ported in v0.38.0 Sync + +- **SDK bumps**: claude-agent-sdk 0.3.144 -> 0.3.154, anthropic SDK 0.96.0 -> 0.100.0 (ACP SDK + unchanged at 0.22.1). +- **Compaction state-flag fix** (#716, a172885): SDK 0.3.154 emits the terminal `status` carrying + `compact_result` twice for failed compactions. Added a per-turn `compactionInProgress` flag in + `prompt()` so the user sees a single `Compacting completed.` / `Compacting failed: ` + chunk. Manual `/compact` outcomes now surface here rather than via `compact_boundary` (which only + fires when there's content to compact). +- **System-role guard on user/assistant handler** (#716, a172885): Added an early return in + `handleUserAssistantMessage` for `message.message.role === "system"`, covering both upstream's + `` strip branch guard and the broader assistant-handler guard. Avoids + rendering SDK-injected system reminders as user-visible chunks. +- **New no-op content block types** (#716, a172885): Added `advisor_tool_result` and + `mid_conv_system` cases to `processContentChunk` so unknown content blocks don't trip the + `unreachable` default. +- **Opus 4.8 model entries** (#718, 98b54a0): Added `claude-opus-4-8` to gateway model maps with + 1M context, effort and xhigh-effort support. MCP injection auto-included (Haiku exclusion only). + +## Skipped in v0.38.0 Sync + +- **Remove hide Claude auth flag** (#707, 7ed1daf): Our fork already returns `authMethods: []` + unconditionally; no flag to remove. +- **`thinking_tokens` status case** (#716, a172885): Our `handleSystemMessage` switch on + `status === "compacting"` is non-exhaustive (no default `unreachable`), so unknown status values + already no-op harmlessly. +- **Empty CI-retry commit** (#718, 98b54a0): No code change in the commit itself; the model entries + it carried are ported above. +- **`MessageDisplay` hook + `SessionStart` reloadSkills/sessionTitle** (SDK 0.3.152): Available in + the bumped SDK but not wired into our fork; upstream doesn't consume them in #716 either. Defer + to a focused PR if we want the capability. + +## Changes Ported in v0.37.0 Sync + +- **SDK bumps**: claude-agent-sdk 0.2.114 -> 0.3.144, ACP SDK 0.19.0 -> 0.22.1, anthropic SDK 0.89.0 -> 0.96.0 +- **TodoWrite -> Task tools migration** (SDK 0.3.142): Replaced TodoWrite snapshot tool with incremental + TaskCreate/TaskUpdate/TaskGet/TaskList. Added `conversion/task-state.ts` and `createTaskHook` to mirror the + SDK `TaskCreated`/`TaskCompleted` hook events into a per-session task map; plan entries are derived from + Map insertion order (preserves upstream ordering semantics). +- **MCP_CONNECTION_NONBLOCKING=0** (SDK 0.3.142): SDK changed MCP servers to background-connect by default; + set env to restore blocking-connect behavior so MCP tools are available on first prompt. +- **ACP SDK 0.22 breaking changes**: Renamed `unstable_resumeSession` -> `resumeSession`; new + `McpSdkServerConfig` variant (`type: "sdk"`) in the `McpServerConfig` union. Our + `parseMcpServers` only accepts `http`/`sse`/stdio entries, so `sdk` falls through and is + implicitly dropped (no explicit filter needed). +- **Skills option** (SDK 0.2.133): `'Skill'` in `allowedTools` deprecated; replaced with `skills` option. +- **Memory recall tool calls** (#703, a0bfb98): Emit a `tool_call` for SDK `memory_recall` events so the + UI shows what memories were surfaced; addresses phantom MEMORY.md read attempts. +- **Write diff fix** (#618, 8d7e220): `toolUpdateFromEditToolResponse` now also processes `Write` tool + responses so overwrites show real diffs instead of optimistic "creation" diffs. +- **Local-command-stdout render** (#649, 3b9b7d5): Strip marker tags from `` content + and render remaining prose so custom slash commands and skill expansions reach the UI. +- **Cancelled vs end_turn** (#694, 2414a6f): `session_state_changed: idle` handler now reports + `stopReason: "cancelled"` when the session was interrupted. +- **Recover prompt stream** (#706, 2711f50): After a failed turn, drain the trailing + `session_state_changed: idle` so the next prompt's first `query.next()` doesn't short-circuit. +- **additionalDirectories field** (#684, f37e9a0): Accept the official ACP field on session lifecycle + requests; advertise via `sessionCapabilities.additionalDirectories`. Legacy `_meta.additionalRoots` still + honored as fallback. +- **availableModels allowlist** (#637, 867a3a0): `ClaudeCodeSettings.availableModels` array merged-and-deduped + across settings sources, then applied to gateway model options via `applyAvailableModelsAllowlist`. +- **Model alias version match** (#702, e1e1c69): Refuse cross-version alias matches in `resolveModelPreference` + so `claude-opus-4-6` doesn't get copied onto the `opus` alias when it resolves to 4.7. +- **Hide /clear** (#705, cfce130): `/clear` removed from advertised commands; clients should use + `session/new` for the same effect. +- **No-op ping events** (#698, 694221a): `streamEventToAcpNotifications` no-ops `ping` keep-alive events + instead of falling through to `unreachable` and spamming stderr. + +## Skipped in v0.37.0 Sync + +- **Avoid redundant initial model sync** (#704, b275f6f): Our flow already guards `setModel` behind + `!isResume && resolvedSdkModel !== DEFAULT_MODEL`, so the upstream optimization is redundant. +- **Default effort option** (#701, 9e259d1): Our effort options are model-class-based rather than + SDK-supplied; the implicit no-override path already covers the "let SDK decide" case. +- **Gate auto mode on model support** (#604, ec47d34): Our `auto` mode is gated behind `ALLOW_BYPASS`, + not per-model `supportsAutoMode`. Per-model gating would be a larger refactor. + ## Skipped in v0.30.0 Sync - **Separate auth methods** (v0.25.0): PostHog returns empty authMethods @@ -75,7 +152,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Next Sync -1. Check upstream changelog since v0.30.0 +1. Check upstream changelog since v0.37.0 2. Diff upstream source against PostHog Code using the file mapping above 3. Port in phases: bug fixes first, then features 4. After each phase: `pnpm --filter agent typecheck && pnpm --filter agent build && pnpm lint` diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index ccccd2f10d..7c960d4f7f 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -84,6 +84,11 @@ import { handleSystemMessage, handleUserAssistantMessage, } from "./conversion/sdk-to-acp"; +import { + rehydrateTaskState, + type TaskState, + taskStateToPlanEntries, +} from "./conversion/task-state"; import type { EnrichedReadCache } from "./hooks"; import { createLocalToolsMcpServer } from "./mcp/local-tools"; import { @@ -95,6 +100,10 @@ import { import { canUseTool } from "./permissions/permission-handlers"; import { getAvailableSlashCommands } from "./session/commands"; import { parseMcpServers } from "./session/mcp-config"; +import { + applyAvailableModelsAllowlist, + resolveInitialModelId, +} from "./session/model-config"; import { DEFAULT_MODEL, getEffortOptions, @@ -237,6 +246,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, loadSession: true, sessionCapabilities: { + additionalDirectories: {}, list: {}, fork: {}, resume: {}, @@ -269,11 +279,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent { throw RequestError.authRequired(); } - const response = await this.createSession(params, { - // Revisit these meta values once we support resume - resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options - ?.resume as string | undefined, - }); + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, + _meta: params._meta, + }, + { + // Revisit these meta values once we support resume + resume: (params._meta as NewSessionMeta | undefined)?.claudeCode + ?.options?.resume as string | undefined, + }, + ); return response; } @@ -285,13 +303,14 @@ export class ClaudeAcpAgent extends BaseAcpAgent { { cwd: params.cwd, mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, _meta: params._meta, }, { resume: params.sessionId, forkSession: true }, ); } - async unstable_resumeSession( + async resumeSession( params: ResumeSessionRequest, ): Promise { // Reuse existing session if it matches @@ -302,6 +321,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { { cwd: params.cwd, mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, _meta: params._meta, }, { @@ -309,6 +329,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, ); + await this.rehydrateTaskStateFromJsonl(params.sessionId); + return response; } @@ -321,6 +343,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { { cwd: params.cwd, mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, _meta: params._meta, }, { resume: params.sessionId, skipBackgroundFetches: true }, @@ -421,6 +444,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.session.promptRunning = true; let handedOff = false; + let errored = false; let lastAssistantTotalUsage: number | null = null; let lastStreamUsage = { input_tokens: 0, @@ -428,6 +452,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }; + // Tracks whether we're inside a compaction. The SDK emits the terminal + // `status` (compact_result success/failed) twice for a single failed + // compaction, and the two messages are indistinguishable, so we report the + // outcome only while a compaction is in progress, then clear this. A fresh + // `compacting` status sets it again, so every distinct compaction (e.g. + // repeated auto-compactions in a long turn) is still shown. + let compactionInProgress = false; if (this.session.lastContextWindowSize == null) { this.session.lastContextWindowSize = this.getContextWindowForModel( this.session.modelId ?? "", @@ -503,6 +534,54 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (message.subtype === "local_command_output") { promptReplayed = true; } + if (message.subtype === "status") { + // The SDK signals manual `/compact` completion with a status + // message carrying `compact_result`, not the `compact_boundary` + // message (which only fires when there's content to compact). + // Gate the user-facing outcome on `compactionInProgress` to + // dedupe the duplicate terminal status the SDK emits for failed + // compactions. + if (message.status === "compacting") { + compactionInProgress = true; + // Fall through to handleSystemMessage so the COMPACTING + // extNotification still fires. + } else if ( + message.compact_result === "success" && + compactionInProgress + ) { + compactionInProgress = false; + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "\n\nCompacting completed.", + }, + }, + }); + break; + } else if ( + message.compact_result === "failed" && + compactionInProgress + ) { + compactionInProgress = false; + const reason = message.compact_error + ? `: ${message.compact_error}` + : "."; + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `\n\nCompacting failed${reason}`, + }, + }, + }); + break; + } + } if ( message.subtype === "session_state_changed" && (message as Record).state === "idle" @@ -564,7 +643,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, }); - return { stopReason: "end_turn" }; + return { + stopReason: this.session.cancelled ? "cancelled" : "end_turn", + }; } await handleSystemMessage(message, context); break; @@ -838,6 +919,37 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } throw new Error("Session did not end in result"); } catch (error) { + errored = true; + // A failed turn typically leaves a trailing `session_state_changed: idle` + // (and possibly more) in the query iterator. If we don't drain it here, + // the next prompt's first `query.next()` consumes that stale idle and + // short-circuits to end_turn with zero usage. + try { + await this.session.query.interrupt(); + const MAX_DRAIN = 100; + for (let i = 0; i < MAX_DRAIN; i++) { + const { value: m, done } = await this.session.query.next(); + if (done || !m) break; + if ( + m.type === "system" && + m.subtype === "session_state_changed" && + (m as Record).state === "idle" + ) { + break; + } + if (i === MAX_DRAIN - 1) { + this.logger.error( + `Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`, + ); + } + } + } catch (drainErr) { + this.logger.error( + `Session ${params.sessionId}: failed to drain query after prompt error`, + { error: drainErr }, + ); + } + if (error instanceof RequestError || !(error instanceof Error)) { throw error; } @@ -868,10 +980,25 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.toolUseStreamCache.clear(); if (!handedOff) { this.session.promptRunning = false; - // Resolve all remaining pending prompts so no callers get stuck. - for (const [key, pending] of this.session.pendingMessages) { - pending.resolve(true); - this.session.pendingMessages.delete(key); + if (errored) { + // The query stream was just drained — handing pending prompts off + // onto it would let them race with the recovery. Cancel them so + // each waiting prompt() returns stopReason "cancelled" and the + // client can decide whether to retry. + for (const pending of this.session.pendingMessages.values()) { + pending.resolve(true); + } + this.session.pendingMessages.clear(); + } else if (this.session.pendingMessages.size > 0) { + // Clean exit with queued prompts: hand off the lowest-order one + // so it can proceed. The rest stay queued for their own turn. + const next = [...this.session.pendingMessages.entries()].sort( + (a, b) => a[1].order - b[1].order, + )[0]; + if (next) { + next[1].resolve(false); + this.session.pendingMessages.delete(next[0]); + } } } } @@ -933,6 +1060,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const mcpServers = parseMcpServers( params as Pick, + this.logger, ); await this.refreshSession(mcpServers); return { refreshed: true }; @@ -1166,6 +1294,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { params: { cwd: string; mcpServers: NewSessionRequest["mcpServers"]; + additionalDirectories?: NewSessionRequest["additionalDirectories"]; _meta?: unknown; }, creationOpts: { @@ -1205,7 +1334,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const earlyModelId = settingsManager.getSettings().model || meta?.model || ""; const mcpServers = supportsMcpInjection(earlyModelId) - ? parseMcpServers(params) + ? parseMcpServers(params, this.logger) : {}; // Register the in-process general local-tools MCP server. Tools self-gate @@ -1250,6 +1379,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ? (meta.permissionMode as CodeExecutionMode) : "default"; + const taskState: TaskState = new Map(); const options = buildSessionOptions({ cwd, mcpServers, @@ -1263,7 +1393,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { forkSession, additionalDirectories: [ ...(meta?.claudeCode?.options?.additionalDirectories ?? []), - ...(meta?.additionalRoots ?? []), + // Prefer the official ACP `additionalDirectories` field. Fall back + // to the legacy `_meta.additionalRoots` extension for clients that + // haven't been updated yet. + ...(params.additionalDirectories ?? meta?.additionalRoots ?? []), ], disableBuiltInTools: meta?.disableBuiltInTools, outputFormat, @@ -1275,6 +1408,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent { enrichmentDeps: this.enrichment?.deps, enrichedReadCache: this.enrichedReadCache, cloudMode: cloudRun, + taskState, + onTaskStateChange: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(taskState), + }, + }); + }, }); // Use the same abort controller that buildSessionOptions gave to the query @@ -1307,6 +1450,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { systemPrompt: estimateSystemPrompt(systemPrompt), rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)), }, + taskState, // Custom properties cwd, @@ -1359,7 +1503,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ? withTimeout(q.initializationResult(), SESSION_VALIDATION_TIMEOUT_MS) : undefined; - const [modelOptions] = await Promise.all([ + const [rawModelOptions] = await Promise.all([ this.getModelConfigOptions( settingsManager.getSettings().model || meta?.model || undefined, ), @@ -1374,6 +1518,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent { : []), ]); + // Restrict the model list to the user's `availableModels` allowlist + // from settings.json so config UI and downstream resolution stay + // consistent with what the user configured. The Default option is + // always preserved per the Claude Code docs. + const settingsAvailableModels = + settingsManager.getSettings().availableModels; + const modelOptions = Array.isArray(settingsAvailableModels) + ? applyAvailableModelsAllowlist(rawModelOptions, settingsAvailableModels) + : rawModelOptions; + if (initPromise) { try { const initResult = await initPromise; @@ -1398,10 +1552,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } } - const settingsModel = settingsManager.getSettings().model; - const metaModel = meta?.model; - const resolvedModelId = - settingsModel || metaModel || modelOptions.currentModelId; + const resolvedModelId = resolveInitialModelId(modelOptions, [ + settingsManager.getSettings().model, + meta?.model, + ]); session.modelId = resolvedModelId; session.lastContextWindowSize = this.getContextWindowForModel(resolvedModelId); @@ -1665,6 +1819,35 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } + /** + * Rebuild the in-memory taskState from JSONL and push a plan update so the + * client's plan panel reflects pre-resume tasks. `loadSession` already covers + * this via the full `replaySessionHistory` notification stream; resume + * deliberately stays quiet (the client keeps its own message history) so we + * walk the transcript here for state only. + */ + private async rehydrateTaskStateFromJsonl(sessionId: string): Promise { + try { + const messages = await getSessionMessages(sessionId, { + dir: this.session.cwd, + }); + rehydrateTaskState(messages, this.session.taskState); + if (this.session.taskState.size === 0) return; + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(this.session.taskState), + }, + }); + } catch (err) { + this.logger.warn("Failed to rehydrate task state", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + private async replaySessionHistory(sessionId: string): Promise { try { const messages = await getSessionMessages(sessionId, { diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index 43defc3c2d..633aa20ae9 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -12,6 +12,10 @@ import type { SDKResultMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import type { + TaskCreateInput, + TaskUpdateInput, +} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; import type { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import type { BetaContentBlock, @@ -31,8 +35,13 @@ import type { ToolUseStreamCache, } from "../types"; import { - type ClaudePlanEntry, - planEntries, + applyTaskCreate, + applyTaskUpdate, + parseTaskCreateOutput, + type TaskState, + taskStateToPlanEntries, +} from "./task-state"; +import { toolInfoFromToolUse, toolUpdateFromEditToolResponse, toolUpdateFromToolResult, @@ -49,7 +58,6 @@ interface AnthropicMessageWithContent { type: Role; message: { content: AnthropicMessageContent; - role?: Role; model?: string; }; } @@ -67,6 +75,8 @@ type ChunkHandlerContext = { cwd?: string; /** Raw MCP tool result from SDKUserMessage.tool_use_result (contains content, structuredContent, _meta) */ mcpToolUseResult?: Record; + /** Per-session task list (populated by createTaskHook + tool_result handler) */ + taskState?: TaskState; }; export interface MessageHandlerContext { @@ -168,14 +178,14 @@ function handleToolUseChunk( const alreadyCached = chunk.id in ctx.toolUseCache; ctx.toolUseCache[chunk.id] = chunk; - if (chunk.name === "TodoWrite") { - const input = chunk.input as { todos?: unknown[] }; - if (Array.isArray(input.todos)) { - return { - sessionUpdate: "plan", - entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }), - }; - } + // Suppress Task* tool_calls — plan updates are emitted from the matching + // tool_result handler instead, after taskState has been mutated. + if ( + chunk.name === "TaskCreate" || + chunk.name === "TaskUpdate" || + chunk.name === "TaskList" || + chunk.name === "TaskGet" + ) { return null; } @@ -185,7 +195,7 @@ function handleToolUseChunk( const toolUse = ctx.toolUseCache[toolUseId]; if (toolUse) { const editUpdate = - toolUse.name === "Edit" + toolUse.name === "Edit" || toolUse.name === "Write" ? toolUpdateFromEditToolResponse(toolResponse) : null; @@ -334,7 +344,33 @@ function handleToolResultChunk( return []; } - if (toolUse.name === "TodoWrite") { + if ( + toolUse.name === "TaskCreate" || + toolUse.name === "TaskUpdate" || + toolUse.name === "TaskList" || + toolUse.name === "TaskGet" + ) { + if (chunk.is_error || !ctx.taskState) return []; + if (toolUse.name === "TaskCreate") { + applyTaskCreate( + ctx.taskState, + toolUse.input as TaskCreateInput | undefined, + parseTaskCreateOutput(chunk.content), + ); + } else if (toolUse.name === "TaskUpdate") { + applyTaskUpdate( + ctx.taskState, + toolUse.input as TaskUpdateInput | undefined, + ); + } + if (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate") { + return [ + { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(ctx.taskState), + }, + ]; + } return []; } @@ -460,6 +496,8 @@ function processContentChunk( case "container_upload": case "compaction": case "compaction_delta": + case "advisor_tool_result": + case "mid_conv_system": return []; default: @@ -486,6 +524,7 @@ function toAcpNotifications( cwd?: string, mcpToolUseResult?: Record, enrichedReadCache?: EnrichedReadCache, + taskState?: TaskState, ): SessionNotification[] { if (typeof content === "string") { const update: SessionUpdate = { @@ -514,6 +553,7 @@ function toAcpNotifications( supportsTerminalOutput, cwd, mcpToolUseResult, + taskState, }; const output: SessionNotification[] = []; @@ -539,6 +579,7 @@ function streamEventToAcpNotifications( supportsTerminalOutput?: boolean, cwd?: string, enrichedReadCache?: EnrichedReadCache, + taskState?: TaskState, ): SessionNotification[] { const event = message.event; switch (event.type) { @@ -564,6 +605,7 @@ function streamEventToAcpNotifications( cwd, undefined, enrichedReadCache, + taskState, ); } case "content_block_delta": { @@ -589,11 +631,17 @@ function streamEventToAcpNotifications( cwd, undefined, enrichedReadCache, + taskState, ); } case "content_block_stop": toolUseStreamCache.delete(event.index); return []; + // `ping` is a Messages-API keep-alive event that the SDK's + // `BetaRawMessageStreamEvent` union doesn't include even though the + // wire format emits it; the `as never` cast lets us no-op it here + // instead of falling through to `unreachable`. + case "ping" as never: case "message_start": case "message_delta": case "message_stop": @@ -678,6 +726,52 @@ export async function handleSystemMessage( }); break; } + case "memory_recall": { + const isSynthesis = message.mode === "synthesize"; + // Skip empty recalls — they're the dominant source of UI clutter on + // memory-heavy turns and carry no signal (no paths, no content). + if (!isSynthesis && message.memories.length === 0) break; + const locations = isSynthesis + ? [] + : message.memories.map((m) => ({ path: m.path })); + const content = isSynthesis + ? message.memories + .filter( + ( + m, + ): m is (typeof message.memories)[number] & { + content: string; + } => typeof m.content === "string", + ) + .map((m) => ({ + type: "content" as const, + content: { type: "text" as const, text: m.content }, + })) + : []; + const count = message.memories.length; + const title = isSynthesis + ? "Recalled synthesized memory" + : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`; + await client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "tool_call", + toolCallId: message.uuid, + title, + kind: "read", + status: "completed", + ...(locations.length > 0 && { locations }), + ...(content.length > 0 && { content }), + _meta: { + claudeCode: { + toolName: "memory_recall", + toolResponse: { mode: message.mode }, + }, + } satisfies ToolUpdateMeta, + }, + }); + break; + } default: break; } @@ -819,6 +913,7 @@ export async function handleStreamEvent( context.supportsTerminalOutput, context.session.cwd, context.enrichedReadCache, + context.session.taskState, )) { await client.sessionUpdate(notification); context.session.notificationHistory.push(notification); @@ -837,6 +932,41 @@ function hasLocalCommandStderr(content: AnthropicMessageContent): boolean { ); } +// SDK-persisted slash command invocations always lead with ``. +// Requiring that anchor keeps user-typed prompts that happen to contain a +// literal `` tag from being scrubbed on session reload. +function isSdkLocalCommandMessage(content: AnthropicMessageContent): boolean { + return ( + typeof content === "string" && + content.includes("") && + (content.includes("") || + content.includes("")) + ); +} + +// The Claude SDK persists local slash command invocations (e.g. `/model`) and +// their output as user messages wrapping the payload in these XML-like markers +// that the CLI uses for its own display. The live prompt loop must strip them +// so they don't leak into the UI, while preserving any real prose mixed in +// alongside. +const LOCAL_COMMAND_TAG_PATTERN = + /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g; + +function stripMarkerTags(text: string): string { + return text.replace(LOCAL_COMMAND_TAG_PATTERN, ""); +} + +/** + * Returns the string with local-command marker tags removed, or `null` if + * nothing renderable remains. Used to surface custom slash commands and + * skill expansions whose bodies arrive wrapped in marker tags, while + * still no-op'ing for pure-marker payloads like /compact. + */ +function stripLocalCommandMetadata(content: string): string | null { + const stripped = stripMarkerTags(content); + return stripped.trim() === "" ? null : stripped; +} + function isLoginRequiredMessage(message: AnthropicMessageWithContent): boolean { return ( message.type === "assistant" && @@ -863,8 +993,7 @@ function shouldSkipUserAssistantMessage( message: AnthropicMessageWithContent, ): boolean { return ( - hasLocalCommandStdout(message.message.content) || - hasLocalCommandStderr(message.message.content) || + isSdkLocalCommandMessage(message.message.content) || isLoginRequiredMessage(message) ); } @@ -900,12 +1029,48 @@ export async function handleUserAssistantMessage( const { session, sessionId, client, toolUseCache, fileContentCache, logger } = context; + // System-role payloads (e.g. SDK-injected reminders) reach the user/assistant + // switch but are never user-visible content; skip rendering them entirely. + if (message.message.role === "system") { + return {}; + } + if (shouldSkipUserAssistantMessage(message)) { logSpecialMessages(message, logger); if (isLoginRequiredMessage(message)) { return { shouldStop: true, error: RequestError.authRequired() }; } + + // Strip local-command marker tags and render whatever real prose remains + // so that custom slash commands and skill expansions (whose bodies arrive + // wrapped in / markers) reach the UI. + // Pure-marker payloads (e.g. /compact) still no-op via the `null` branch. + const rawContent = message.message.content; + if (typeof rawContent === "string") { + const stripped = stripLocalCommandMetadata(rawContent); + if (stripped !== null) { + for (const notification of toAcpNotifications( + stripped, + message.message.role as Role, + sessionId, + toolUseCache, + fileContentCache, + client, + logger, + undefined, + context.registerHooks, + context.supportsTerminalOutput, + session.cwd, + undefined, + context.enrichedReadCache, + session.taskState, + )) { + await client.sessionUpdate(notification); + session.notificationHistory.push(notification); + } + } + } return {}; } @@ -931,7 +1096,7 @@ export async function handleUserAssistantMessage( for (const notification of toAcpNotifications( contentToProcess as typeof content, - message.message.role, + message.message.role as Role, sessionId, toolUseCache, fileContentCache, @@ -943,6 +1108,7 @@ export async function handleUserAssistantMessage( session.cwd, mcpToolUseResult, context.enrichedReadCache, + session.taskState, )) { await client.sessionUpdate(notification); session.notificationHistory.push(notification); diff --git a/packages/agent/src/adapters/claude/conversion/task-state.test.ts b/packages/agent/src/adapters/claude/conversion/task-state.test.ts new file mode 100644 index 0000000000..d440b99ea9 --- /dev/null +++ b/packages/agent/src/adapters/claude/conversion/task-state.test.ts @@ -0,0 +1,338 @@ +import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import { describe, expect, it } from "vitest"; +import { + applyTaskCreate, + applyTaskUpdate, + parseTaskCreateOutput, + rehydrateTaskState, + type TaskState, + taskStateToPlanEntries, +} from "./task-state"; + +function assistantMsg(blocks: unknown[]): SessionMessage { + return { + type: "assistant", + uuid: "u", + session_id: "s", + parent_tool_use_id: null, + message: { role: "assistant", content: blocks }, + } as SessionMessage; +} + +function userMsg(blocks: unknown[]): SessionMessage { + return { + type: "user", + uuid: "u", + session_id: "s", + parent_tool_use_id: null, + message: { role: "user", content: blocks }, + } as SessionMessage; +} + +describe("parseTaskCreateOutput", () => { + it("parses a JSON string with task.id", () => { + const out = parseTaskCreateOutput('{"task":{"id":"t1"}}'); + expect(out?.task?.id).toBe("t1"); + }); + + it("returns undefined for invalid JSON", () => { + expect(parseTaskCreateOutput("not json")).toBeUndefined(); + }); + + it("returns undefined when task.id is missing", () => { + expect(parseTaskCreateOutput("{}")).toBeUndefined(); + expect(parseTaskCreateOutput('{"task":{}}')).toBeUndefined(); + }); + + it("walks array of text blocks and returns the first parseable one", () => { + const out = parseTaskCreateOutput([ + { type: "text", text: "garbage" }, + { type: "text", text: '{"task":{"id":"t2"}}' }, + ]); + expect(out?.task?.id).toBe("t2"); + }); + + it("ignores non-text blocks", () => { + const out = parseTaskCreateOutput([ + { type: "image", text: '{"task":{"id":"t3"}}' }, + ]); + expect(out).toBeUndefined(); + }); + + it("returns undefined for null/undefined/non-string content", () => { + expect(parseTaskCreateOutput(null)).toBeUndefined(); + expect(parseTaskCreateOutput(undefined)).toBeUndefined(); + expect(parseTaskCreateOutput(42)).toBeUndefined(); + }); +}); + +describe("applyTaskCreate", () => { + it("inserts a new entry keyed by output task id", () => { + const state: TaskState = new Map(); + applyTaskCreate( + state, + { + subject: "Fix bug", + description: "details", + activeForm: "Fixing bug", + }, + { task: { id: "t1", subject: "Fix bug" } }, + ); + expect(state.get("t1")).toEqual({ + subject: "Fix bug", + status: "pending", + activeForm: "Fixing bug", + description: "details", + }); + }); + + it("is a no-op when output has no task id", () => { + const state: TaskState = new Map(); + applyTaskCreate(state, { subject: "x", description: "y" }, undefined); + expect(state.size).toBe(0); + }); + + it("is a no-op when input is undefined", () => { + const state: TaskState = new Map(); + applyTaskCreate(state, undefined, { task: { id: "t1", subject: "x" } }); + expect(state.size).toBe(0); + }); +}); + +describe("applyTaskUpdate", () => { + it("removes the entry when status is deleted", () => { + const state: TaskState = new Map([ + ["t1", { subject: "x", status: "pending" as const }], + ]); + applyTaskUpdate(state, { taskId: "t1", status: "deleted" }); + expect(state.has("t1")).toBe(false); + }); + + it("merges partial fields, preserving existing values", () => { + const state: TaskState = new Map([ + [ + "t1", + { + subject: "Existing subject", + status: "pending" as const, + activeForm: "Doing", + description: "Existing description", + }, + ], + ]); + applyTaskUpdate(state, { taskId: "t1", status: "in_progress" }); + expect(state.get("t1")).toEqual({ + subject: "Existing subject", + status: "in_progress", + activeForm: "Doing", + description: "Existing description", + }); + }); + + it("is a no-op when no existing entry and no subject in input", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, { taskId: "t1", status: "completed" }); + expect(state.size).toBe(0); + }); + + it("creates a new entry when input provides a subject", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, { + taskId: "t1", + subject: "Brand new", + status: "in_progress", + }); + expect(state.get("t1")?.subject).toBe("Brand new"); + expect(state.get("t1")?.status).toBe("in_progress"); + }); + + it("is a no-op when input has no taskId", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, undefined); + expect(state.size).toBe(0); + }); +}); + +describe("rehydrateTaskState", () => { + it("rebuilds the map from TaskCreate + TaskUpdate transcripts", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { + type: "tool_use", + id: "u1", + name: "TaskCreate", + input: { subject: "First", activeForm: "Doing first" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u1", + content: '{"task":{"id":"t1","subject":"First"}}', + }, + ]), + assistantMsg([ + { + type: "tool_use", + id: "u2", + name: "TaskUpdate", + input: { taskId: "t1", status: "in_progress" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u2", + content: "ok", + }, + ]), + ], + state, + ); + expect(state.get("t1")).toEqual({ + subject: "First", + status: "in_progress", + activeForm: "Doing first", + description: undefined, + }); + }); + + it("ignores tool_result blocks for non-Task tools", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { type: "tool_use", id: "r1", name: "Read", input: { file: "a" } }, + ]), + userMsg([ + { type: "tool_result", tool_use_id: "r1", content: "file contents" }, + ]), + ], + state, + ); + expect(state.size).toBe(0); + }); + + it("skips errored Task tool results", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { + type: "tool_use", + id: "u1", + name: "TaskCreate", + input: { subject: "x" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u1", + content: '{"task":{"id":"t1","subject":"x"}}', + is_error: true, + }, + ]), + ], + state, + ); + expect(state.size).toBe(0); + }); + + it("honors deletes from TaskUpdate", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { + type: "tool_use", + id: "u1", + name: "TaskCreate", + input: { subject: "x" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u1", + content: '{"task":{"id":"t1","subject":"x"}}', + }, + ]), + assistantMsg([ + { + type: "tool_use", + id: "u2", + name: "TaskUpdate", + input: { taskId: "t1", status: "deleted" }, + }, + ]), + userMsg([{ type: "tool_result", tool_use_id: "u2", content: "ok" }]), + ], + state, + ); + expect(state.has("t1")).toBe(false); + }); + + it("ignores tool_result without a matching tool_use", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + userMsg([ + { + type: "tool_result", + tool_use_id: "orphan", + content: '{"task":{"id":"t9","subject":"x"}}', + }, + ]), + ], + state, + ); + expect(state.size).toBe(0); + }); + + it("ignores messages with non-array content", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + { + type: "user", + uuid: "u", + session_id: "s", + parent_tool_use_id: null, + message: { role: "user", content: "plain string" }, + } as SessionMessage, + ], + state, + ); + expect(state.size).toBe(0); + }); +}); + +describe("taskStateToPlanEntries", () => { + it("returns an empty array for an empty state", () => { + expect(taskStateToPlanEntries(new Map())).toEqual([]); + }); + + it("preserves Map insertion order", () => { + const state: TaskState = new Map(); + state.set("c", { subject: "third", status: "pending" }); + state.set("a", { subject: "first", status: "in_progress" }); + state.set("b", { subject: "second", status: "completed" }); + const entries = taskStateToPlanEntries(state); + expect(entries.map((e) => e.content)).toEqual(["third", "first", "second"]); + expect(entries.map((e) => e.status)).toEqual([ + "pending", + "in_progress", + "completed", + ]); + }); + + it("hardcodes priority to medium", () => { + const state: TaskState = new Map([ + ["t1", { subject: "x", status: "pending" }], + ]); + expect(taskStateToPlanEntries(state)[0].priority).toBe("medium"); + }); +}); diff --git a/packages/agent/src/adapters/claude/conversion/task-state.ts b/packages/agent/src/adapters/claude/conversion/task-state.ts new file mode 100644 index 0000000000..b5366714b9 --- /dev/null +++ b/packages/agent/src/adapters/claude/conversion/task-state.ts @@ -0,0 +1,178 @@ +import type { PlanEntry } from "@agentclientprotocol/sdk"; +import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { + TaskCreateInput, + TaskCreateOutput, + TaskUpdateInput, +} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; + +export type TaskEntry = { + subject: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; + description?: string; +}; + +export type TaskState = Map; + +export function parseTaskCreateOutput( + content: unknown, +): TaskCreateOutput | undefined { + const tryParse = (text: string): TaskCreateOutput | undefined => { + try { + const parsed = JSON.parse(text); + if ( + parsed && + typeof parsed === "object" && + parsed.task && + typeof parsed.task.id === "string" + ) { + return parsed as TaskCreateOutput; + } + } catch { + // ignore + } + return undefined; + }; + + if (typeof content === "string") { + return tryParse(content); + } + if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === "object" && + "type" in block && + block.type === "text" + ) { + const text = (block as { text?: unknown }).text; + if (typeof text === "string") { + const parsed = tryParse(text); + if (parsed) return parsed; + } + } + } + } + return undefined; +} + +export function applyTaskCreate( + state: TaskState, + input: TaskCreateInput | undefined, + output: TaskCreateOutput | undefined, +): void { + const taskId = output?.task?.id; + if (!taskId || !input) return; + state.set(taskId, { + subject: input.subject, + status: "pending", + activeForm: input.activeForm, + description: input.description, + }); +} + +export function applyTaskUpdate( + state: TaskState, + input: TaskUpdateInput | undefined, +): void { + if (!input?.taskId) return; + if (input.status === "deleted") { + state.delete(input.taskId); + return; + } + const existing = state.get(input.taskId); + const subject = input.subject ?? existing?.subject; + if (!subject) return; + state.set(input.taskId, { + subject, + status: input.status ?? existing?.status ?? "pending", + activeForm: input.activeForm ?? existing?.activeForm, + description: input.description ?? existing?.description, + }); +} + +export function taskStateToPlanEntries(state: TaskState): PlanEntry[] { + return Array.from(state.values()).map((task) => ({ + content: task.subject, + status: task.status, + priority: "medium", + })); +} + +type ToolUseBlock = { + type: "tool_use"; + id: string; + name: string; + input?: unknown; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content?: unknown; + is_error?: boolean; +}; + +function isToolUseBlock(block: unknown): block is ToolUseBlock { + return ( + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "tool_use" && + typeof (block as { id?: unknown }).id === "string" && + typeof (block as { name?: unknown }).name === "string" + ); +} + +function isToolResultBlock(block: unknown): block is ToolResultBlock { + return ( + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "tool_result" && + typeof (block as { tool_use_id?: unknown }).tool_use_id === "string" + ); +} + +/** + * Rebuild `state` from a JSONL message transcript by replaying Task tool + * inputs/outputs. Used by `resumeSession` to recover the plan panel when the + * agent restarts mid-conversation; `loadSession` covers the same ground via + * the full notification replay in `replaySessionHistory`. + */ +export function rehydrateTaskState( + messages: ReadonlyArray, + state: TaskState, +): void { + const pendingInputs = new Map(); + for (const msg of messages) { + const content = (msg.message as { content?: unknown } | null | undefined) + ?.content; + if (!Array.isArray(content)) continue; + if (msg.type === "assistant") { + for (const block of content) { + if ( + isToolUseBlock(block) && + (block.name === "TaskCreate" || block.name === "TaskUpdate") + ) { + pendingInputs.set(block.id, { name: block.name, input: block.input }); + } + } + } else if (msg.type === "user") { + for (const block of content) { + if (!isToolResultBlock(block) || block.is_error) continue; + const cached = pendingInputs.get(block.tool_use_id); + if (!cached) continue; + if (cached.name === "TaskCreate") { + applyTaskCreate( + state, + cached.input as TaskCreateInput | undefined, + parseTaskCreateOutput(block.content), + ); + } else if (cached.name === "TaskUpdate") { + applyTaskUpdate(state, cached.input as TaskUpdateInput | undefined); + } + pendingInputs.delete(block.tool_use_id); + } + } + } +} diff --git a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts index e960bd467d..0587403e2e 100644 --- a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; import type { - PlanEntry, ToolCall, ToolCallContent, ToolCallLocation, @@ -371,11 +370,36 @@ export function toolInfoFromToolUse( }; } - case "TodoWrite": + case "TaskCreate": { + const subject = + typeof input?.subject === "string" ? input.subject : undefined; return { - title: Array.isArray(input?.todos) - ? `Update TODOs: ${input.todos.map((todo: { content?: string }) => todo.content).join(", ")}` - : "Update TODOs", + title: subject ? `Create task: ${subject}` : "Create task", + kind: "think", + content: [], + }; + } + + case "TaskUpdate": { + const subject = + typeof input?.subject === "string" ? input.subject : undefined; + return { + title: subject ? `Update task: ${subject}` : "Update task", + kind: "think", + content: [], + }; + } + + case "TaskList": + return { + title: "List tasks", + kind: "think", + content: [], + }; + + case "TaskGet": + return { + title: "Get task", kind: "think", content: [], }; @@ -775,20 +799,6 @@ function toAcpContentUpdate( return {}; } -export type ClaudePlanEntry = { - content: string; - status: "pending" | "in_progress" | "completed"; - activeForm: string; -}; - -export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] { - return input.todos.map((input) => ({ - content: input.content, - status: input.status, - priority: "medium", - })); -} - /** * attempt to resolve full file contents for diff generation * diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index 4bcc7e2e4a..2e3dc5ab4e 100644 --- a/packages/agent/src/adapters/claude/hooks.test.ts +++ b/packages/agent/src/adapters/claude/hooks.test.ts @@ -8,10 +8,12 @@ vi.mock("../../enrichment/file-enricher", () => ({ })); import { Logger } from "../../utils/logger"; +import type { TaskState } from "./conversion/task-state"; import { createPreToolUseHook, createReadEnrichmentHook, createSignedCommitGuardHook, + createTaskHook, type EnrichedReadCache, } from "./hooks"; import type { @@ -365,3 +367,163 @@ describe("createSignedCommitGuardHook", () => { expect(result).toEqual({ continue: true }); }); }); + +describe("createTaskHook", () => { + const baseInput = { + session_id: "s", + transcript_path: "/tmp/t", + cwd: "/tmp", + }; + + test("ignores hook events without a task_id", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + const result = await hook( + { ...baseInput, hook_event_name: "PostToolUse" } as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(result).toEqual({ continue: true }); + expect(state.size).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCreated inserts a pending entry and fires onChange", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + const result = await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + task_subject: "Fix bug", + task_description: "details", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(result).toEqual({ continue: true }); + expect(state.get("t1")).toEqual({ + subject: "Fix bug", + status: "pending", + description: "details", + }); + expect(onChange).toHaveBeenCalledOnce(); + }); + + test("TaskCreated is idempotent for an existing task_id", async () => { + const state: TaskState = new Map([ + [ + "t1", + { + subject: "Original", + status: "in_progress" as const, + }, + ], + ]); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + task_subject: "Overwrite attempt", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.get("t1")?.subject).toBe("Original"); + expect(state.get("t1")?.status).toBe("in_progress"); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCreated without task_subject is a no-op", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.size).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCompleted flips status and fires onChange", async () => { + const state: TaskState = new Map([ + ["t1", { subject: "Existing", status: "in_progress" as const }], + ]); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCompleted", + task_id: "t1", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.get("t1")?.status).toBe("completed"); + expect(onChange).toHaveBeenCalledOnce(); + }); + + test("TaskCompleted is a no-op for unknown task_id", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCompleted", + task_id: "unknown", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.size).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCompleted is a no-op for already-completed task", async () => { + const state: TaskState = new Map([ + ["t1", { subject: "Existing", status: "completed" as const }], + ]); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCompleted", + task_id: "t1", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("works without an onChange callback", async () => { + const state: TaskState = new Map(); + const hook = createTaskHook(state); + await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + task_subject: "Fix bug", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.get("t1")?.subject).toBe("Fix bug"); + }); +}); diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 1df94d720e..391d0ee2c9 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -6,6 +6,7 @@ import { import type { Logger } from "../../utils/logger"; import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared"; import { stripCatLineNumbers } from "./conversion/sdk-to-acp"; +import type { TaskState } from "./conversion/task-state"; import { extractPostHogSubTool, isPostHogDestructiveSubTool, @@ -129,6 +130,48 @@ export const registerHookCallback = ( }; }; +/** + * Pre-populate the per-session task list from SDK TaskCreated/TaskCompleted + * hook events. These fire before the matching tool_result chunk arrives, so + * by the time TaskUpdate runs (which only carries taskId + status) the entry + * already exists with a real subject — no placeholder with empty content. + * + * Plan-update emission happens in the tool_result handler, which mirrors the + * old TodoWrite suppress-tool-call + emit-plan flow. + */ +export const createTaskHook = + (taskState: TaskState, onChange?: () => Promise): HookCallback => + async (input: HookInput): Promise<{ continue: boolean }> => { + const taskId = + "task_id" in input && typeof input.task_id === "string" + ? input.task_id + : undefined; + if (!taskId) return { continue: true }; + + let mutated = false; + if (input.hook_event_name === "TaskCreated") { + if (!input.task_subject) return { continue: true }; + // Guard against the SDK firing TaskCreated twice for the same id — + // re-entry would clobber any TaskUpdate that landed in between. + if (taskState.has(taskId)) return { continue: true }; + taskState.set(taskId, { + subject: input.task_subject, + status: "pending", + description: input.task_description, + }); + mutated = true; + } else if (input.hook_event_name === "TaskCompleted") { + const existing = taskState.get(taskId); + if (!existing || existing.status === "completed") { + return { continue: true }; + } + taskState.set(taskId, { ...existing, status: "completed" }); + mutated = true; + } + if (mutated && onChange) await onChange(); + return { continue: true }; + }; + export type OnModeChange = (mode: CodeExecutionMode) => Promise; interface CreatePostToolUseHookParams { @@ -157,10 +200,8 @@ export const createPostToolUseHook = input.tool_input, input.tool_response, ); - delete toolUseCallbacks[toolUseID]; - } else { - delete toolUseCallbacks[toolUseID]; } + delete toolUseCallbacks[toolUseID]; } } return { continue: true }; diff --git a/packages/agent/src/adapters/claude/permissions/permission-options.ts b/packages/agent/src/adapters/claude/permissions/permission-options.ts index ff658b5368..844d0163f3 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-options.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-options.ts @@ -85,8 +85,13 @@ export function buildPermissionOptions( return permissionOptions("Yes, allow all sub-tasks"); } - if (toolName === "TodoWrite") { - return permissionOptions("Yes, allow all todo updates"); + if ( + toolName === "TaskCreate" || + toolName === "TaskUpdate" || + toolName === "TaskGet" || + toolName === "TaskList" + ) { + return permissionOptions("Yes, allow all task updates"); } return permissionOptions("Yes, always allow"); diff --git a/packages/agent/src/adapters/claude/session/commands.ts b/packages/agent/src/adapters/claude/session/commands.ts index 889fc81668..47037142cf 100644 --- a/packages/agent/src/adapters/claude/session/commands.ts +++ b/packages/agent/src/adapters/claude/session/commands.ts @@ -2,6 +2,7 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; const UNSUPPORTED_COMMANDS = [ + "clear", "context", "cost", "keybindings-help", diff --git a/packages/agent/src/adapters/claude/session/mcp-config.ts b/packages/agent/src/adapters/claude/session/mcp-config.ts index 1c169f9b01..b6b1d3a83c 100644 --- a/packages/agent/src/adapters/claude/session/mcp-config.ts +++ b/packages/agent/src/adapters/claude/session/mcp-config.ts @@ -48,6 +48,7 @@ export function loadUserClaudeJsonMcpServers( export function parseMcpServers( params: Pick, + logger?: Logger, ): Record { const mcpServers: Record = {}; if (!Array.isArray(params.mcpServers)) { @@ -56,13 +57,28 @@ export function parseMcpServers( for (const server of params.mcpServers) { if ("type" in server) { - mcpServers[server.name] = { - type: server.type, - url: server.url, - headers: server.headers - ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) - : undefined, - }; + if (server.type === "http" || server.type === "sse") { + mcpServers[server.name] = { + type: server.type, + url: server.url, + headers: server.headers + ? Object.fromEntries( + server.headers.map((e: { name: string; value: string }) => [ + e.name, + e.value, + ]), + ) + : undefined, + }; + } else { + // ACP 0.22 introduced the `sdk` McpServerConfig variant; the SDK + // adapter doesn't construct in-process servers, so surface a warning + // rather than silently dropping the entry. + logger?.warn("parseMcpServers: dropping unsupported MCP server type", { + name: server.name, + type: (server as { type: string }).type, + }); + } } else { mcpServers[server.name] = { type: "stdio", diff --git a/packages/agent/src/adapters/claude/session/model-config.test.ts b/packages/agent/src/adapters/claude/session/model-config.test.ts new file mode 100644 index 0000000000..1225885aae --- /dev/null +++ b/packages/agent/src/adapters/claude/session/model-config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + applyAvailableModelsAllowlist, + resolveInitialModelId, +} from "./model-config"; + +const rawModelOptions = { + currentModelId: "claude-opus-4-8", + options: [ + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], +}; + +describe("applyAvailableModelsAllowlist", () => { + it("falls back to the unfiltered gateway list when every allowlisted model is unknown", () => { + expect( + applyAvailableModelsAllowlist(rawModelOptions, ["claude-opus-4-5"]), + ).toEqual(rawModelOptions); + }); + + it("switches the current model when the previous one is filtered out", () => { + expect( + applyAvailableModelsAllowlist(rawModelOptions, ["claude-sonnet-4-6"]), + ).toEqual({ + currentModelId: "claude-sonnet-4-6", + options: [{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }], + }); + }); +}); + +describe("resolveInitialModelId", () => { + it("keeps a preferred model when it survives filtering", () => { + const filteredModelOptions = applyAvailableModelsAllowlist( + rawModelOptions, + ["claude-opus-4-8", "claude-sonnet-4-6"], + ); + + expect( + resolveInitialModelId(filteredModelOptions, [ + "claude-opus-4-8", + "claude-sonnet-4-6", + ]), + ).toBe("claude-opus-4-8"); + }); + + it("falls back to the filtered current model when the preferred one is disallowed", () => { + const filteredModelOptions = applyAvailableModelsAllowlist( + rawModelOptions, + ["claude-sonnet-4-6"], + ); + + expect( + resolveInitialModelId(filteredModelOptions, [ + "claude-opus-4-8", + "claude-sonnet-4-6", + ]), + ).toBe("claude-sonnet-4-6"); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/model-config.ts b/packages/agent/src/adapters/claude/session/model-config.ts new file mode 100644 index 0000000000..f83ed1b717 --- /dev/null +++ b/packages/agent/src/adapters/claude/session/model-config.ts @@ -0,0 +1,56 @@ +import type { SessionConfigSelectOption } from "@agentclientprotocol/sdk"; + +export interface ModelConfigOptions { + currentModelId: string; + options: SessionConfigSelectOption[]; +} + +/** + * Restrict gateway model options to the user's `availableModels` allowlist + * from settings.json. Unknown allowlist entries are dropped; if every entry + * is unknown we fall back to the gateway list as a safety net. + */ +export function applyAvailableModelsAllowlist( + modelOptions: ModelConfigOptions, + allowlist: string[], +): ModelConfigOptions { + const filtered: SessionConfigSelectOption[] = []; + const seen = new Set(); + + for (const entry of allowlist) { + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + + const match = modelOptions.options.find((o) => o.value === trimmed); + if (match) { + filtered.push(match); + seen.add(trimmed); + } + } + + if (filtered.length === 0) return modelOptions; + + const currentModelId = filtered.some( + (o) => o.value === modelOptions.currentModelId, + ) + ? modelOptions.currentModelId + : filtered[0].value; + + return { currentModelId, options: filtered }; +} + +export function resolveInitialModelId( + modelOptions: ModelConfigOptions, + preferredModelIds: Array, +): string { + const allowedModelIds = new Set(modelOptions.options.map((opt) => opt.value)); + + for (const candidate of preferredModelIds) { + const trimmed = candidate?.trim(); + if (trimmed && allowedModelIds.has(trimmed)) { + return trimmed; + } + } + + return modelOptions.currentModelId; +} diff --git a/packages/agent/src/adapters/claude/session/models.test.ts b/packages/agent/src/adapters/claude/session/models.test.ts new file mode 100644 index 0000000000..724c4fe736 --- /dev/null +++ b/packages/agent/src/adapters/claude/session/models.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + getEffortOptions, + resolveModelPreference, + supports1MContext, + supportsEffort, + supportsMcpInjection, + supportsXhighEffort, + toSdkModelId, +} from "./models"; + +describe("toSdkModelId", () => { + it("maps known gateway IDs to SDK aliases", () => { + expect(toSdkModelId("claude-opus-4-7")).toBe("opus"); + expect(toSdkModelId("claude-opus-4-8")).toBe("opus"); + expect(toSdkModelId("claude-sonnet-4-6")).toBe("sonnet"); + expect(toSdkModelId("claude-haiku-4-5")).toBe("haiku"); + }); + + it("passes unknown IDs through unchanged", () => { + expect(toSdkModelId("custom-model")).toBe("custom-model"); + }); +}); + +describe("model capability flags", () => { + it("flags 1M context support", () => { + expect(supports1MContext("claude-opus-4-7")).toBe(true); + expect(supports1MContext("claude-sonnet-4-6")).toBe(true); + expect(supports1MContext("claude-haiku-4-5")).toBe(false); + }); + + it("flags effort support and xhigh-effort support", () => { + expect(supportsEffort("claude-opus-4-5")).toBe(true); + expect(supportsXhighEffort("claude-opus-4-7")).toBe(true); + expect(supportsXhighEffort("claude-opus-4-5")).toBe(false); + expect(supportsEffort("claude-haiku-4-5")).toBe(false); + }); + + it("excludes MCP injection only for Haiku", () => { + expect(supportsMcpInjection("claude-opus-4-7")).toBe(true); + expect(supportsMcpInjection("claude-haiku-4-5")).toBe(false); + }); +}); + +describe("getEffortOptions", () => { + it("returns null for models without effort support", () => { + expect(getEffortOptions("claude-haiku-4-5")).toBeNull(); + }); + + it("returns low/medium/high for effort-supporting models", () => { + const opts = getEffortOptions("claude-opus-4-5"); + expect(opts?.map((o) => o.value)).toEqual(["low", "medium", "high"]); + }); + + it("appends xhigh and max for xhigh-supporting models", () => { + const opts = getEffortOptions("claude-opus-4-7"); + expect(opts?.map((o) => o.value)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + }); +}); + +describe("resolveModelPreference", () => { + const options = [ + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { value: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { value: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + ]; + + it("returns null for empty preference", () => { + expect(resolveModelPreference("", options)).toBeNull(); + expect(resolveModelPreference(" ", options)).toBeNull(); + }); + + it("matches an exact value", () => { + expect(resolveModelPreference("claude-opus-4-7", options)).toBe( + "claude-opus-4-7", + ); + }); + + it("matches case-insensitively on display name", () => { + expect(resolveModelPreference("claude haiku 4.5", options)).toBe( + "claude-haiku-4-5", + ); + }); + + it("matches by substring", () => { + expect(resolveModelPreference("sonnet", options)).toBe("claude-sonnet-4-6"); + }); + + it("matches by token alias", () => { + expect(resolveModelPreference("opus[1m]", options)).toBe("claude-opus-4-8"); + }); + + it("refuses cross-version alias matches", () => { + const optionsWithAlias = [ + { value: "opus", name: "Claude Opus 4.7" }, + { value: "claude-opus-4-6", name: "Claude Opus 4.6" }, + ]; + expect(resolveModelPreference("claude-opus-4-6", optionsWithAlias)).toBe( + "claude-opus-4-6", + ); + }); + + it("returns null when nothing matches", () => { + expect(resolveModelPreference("gpt-5", options)).toBeNull(); + }); + + it("treats `best` and `default` as wildcards (no tokens contribute)", () => { + expect(resolveModelPreference("best", options)).toBeNull(); + expect(resolveModelPreference("default", options)).toBeNull(); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 4fae2001de..6d8c731997 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -4,6 +4,7 @@ const GATEWAY_TO_SDK_MODEL: Record = { "claude-opus-4-5": "opus", "claude-opus-4-6": "opus", "claude-opus-4-7": "opus", + "claude-opus-4-8": "opus", "claude-sonnet-4-5": "sonnet", "claude-sonnet-4-6": "sonnet", "claude-haiku-4-5": "haiku", @@ -16,6 +17,7 @@ export function toSdkModelId(modelId: string): string { const MODELS_WITH_1M_CONTEXT = new Set([ "claude-opus-4-6", "claude-opus-4-7", + "claude-opus-4-8", "claude-sonnet-4-6", ]); @@ -27,12 +29,14 @@ const MODELS_WITH_EFFORT = new Set([ "claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7", + "claude-opus-4-8", "claude-sonnet-4-6", ]); const MODELS_WITH_XHIGH_EFFORT = new Set([ "claude-opus-4-6", "claude-opus-4-7", + "claude-opus-4-8", ]); export function supportsEffort(modelId: string): boolean { @@ -107,6 +111,31 @@ interface ModelOption { description?: string; } +// Captures a model family version such as `4-6` or `4.7` so we can keep +// `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that +// alias currently resolves to a different family version (e.g. Opus 4.7). +const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/; + +function extractModelFamilyVersion(s: string | undefined): string | null { + if (!s) return null; + const match = s.match(MODEL_FAMILY_VERSION_PATTERN); + return match ? `${match[1]}.${match[2]}` : null; +} + +function modelVersionsCompatible( + preference: string, + candidate: ModelOption, +): boolean { + const preferred = extractModelFamilyVersion(preference); + if (!preferred) return true; + const candidateVersion = + extractModelFamilyVersion(candidate.value) ?? + extractModelFamilyVersion(candidate.name) ?? + extractModelFamilyVersion(candidate.description); + if (!candidateVersion) return true; + return preferred === candidateVersion; +} + function scoreModelMatch( model: ModelOption, tokens: string[], @@ -142,6 +171,7 @@ export function resolveModelPreference( // Substring match const includesMatch = options.find((o) => { + if (!modelVersionsCompatible(trimmed, o)) return false; const value = o.value.toLowerCase(); const display = (o.name ?? "").toLowerCase(); return ( @@ -157,6 +187,7 @@ export function resolveModelPreference( let bestMatch: ModelOption | null = null; let bestScore = 0; for (const model of options) { + if (!modelVersionsCompatible(trimmed, model)) continue; const score = scoreModelMatch(model, tokens, contextHint); if (0 < score && (!bestMatch || bestScore < score)) { bestMatch = model; diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index ae0489bb81..7c843dc593 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -17,6 +17,7 @@ function makeParams() { sessionId: "test-session", isResume: false, settingsManager: new SettingsManager(cwd), + taskState: new Map(), }; } diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 4da0fd3af6..610a7a257c 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -13,12 +13,14 @@ import type { import type { FileEnrichmentDeps } from "../../../enrichment/file-enricher"; import { IS_ROOT } from "../../../utils/common"; import type { Logger } from "../../../utils/logger"; +import type { TaskState } from "../conversion/task-state"; import { createPostToolUseHook, createPreToolUseHook, createReadEnrichmentHook, createSignedCommitGuardHook, createSubagentRewriteHook, + createTaskHook, type EnrichedReadCache, type OnModeChange, } from "../hooks"; @@ -58,6 +60,11 @@ export interface BuildOptionsParams { enrichedReadCache?: EnrichedReadCache; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; + /** Per-session task state populated by createTaskHook from SDK Task* events. */ + taskState: TaskState; + /** Called after createTaskHook mutates taskState so callers can emit a plan + * sessionUpdate to the client. */ + onTaskStateChange?: () => Promise; } export function buildSystemPrompt( @@ -111,6 +118,13 @@ function buildEnvironment(): Record { ? `${existingCustomHeaders}\n${bedrockFallbackHeader}` : bedrockFallbackHeader; + // SDK 0.3.142 made MCP servers connect in the background by default. That + // default is what we want: a slow or unreachable user MCP server (PostHog + // MCP, custom stdio servers) would otherwise stall turn 1 by up to ~5s per + // server. We honor an explicit override from the caller's environment for + // sessions that genuinely need MCP tools available on turn 1. + const mcpNonblocking = process.env.MCP_CONNECTION_NONBLOCKING; + return { ...process.env, ELECTRON_RUN_AS_NODE: "1", @@ -119,6 +133,9 @@ function buildEnvironment(): Record { ENABLE_TOOL_SEARCH: "auto:0", // Enable idle state as end-of-turn signal (required for SDK 0.2.114+) CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1", + ...(mcpNonblocking !== undefined && { + MCP_CONNECTION_NONBLOCKING: mcpNonblocking, + }), // Route to AWS Bedrock as a fallback when Anthropic returns 5xx ANTHROPIC_CUSTOM_HEADERS: customHeaders, }; @@ -133,6 +150,8 @@ function buildHooks( enrichedReadCache: EnrichedReadCache | undefined, registeredAgents: ReadonlySet, cloudMode: boolean, + taskState: TaskState, + onTaskStateChange: (() => Promise) | undefined, ): Options["hooks"] { const postToolUseHooks = [createPostToolUseHook({ onModeChange })]; if (enrichmentDeps && enrichedReadCache) { @@ -149,6 +168,8 @@ function buildHooks( preToolUseHooks.push(createSignedCommitGuardHook(logger)); } + const taskHook = createTaskHook(taskState, onTaskStateChange); + return { ...userHooks, PostToolUse: [ @@ -156,6 +177,8 @@ function buildHooks( { hooks: postToolUseHooks }, ], PreToolUse: [...(userHooks?.PreToolUse || []), { hooks: preToolUseHooks }], + TaskCreated: [...(userHooks?.TaskCreated || []), { hooks: [taskHook] }], + TaskCompleted: [...(userHooks?.TaskCompleted || []), { hooks: [taskHook] }], }; } @@ -195,7 +218,10 @@ Rules: "WebFetch", "WebSearch", "NotebookRead", - "TodoWrite", + "TaskCreate", + "TaskUpdate", + "TaskGet", + "TaskList", ], }; @@ -357,6 +383,8 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.enrichedReadCache, registeredAgentNames, params.cloudMode ?? false, + params.taskState, + params.onTaskStateChange, ), outputFormat: params.outputFormat, abortController: getAbortController( diff --git a/packages/agent/src/adapters/claude/session/settings.test.ts b/packages/agent/src/adapters/claude/session/settings.test.ts index 960c5d4f6e..5f6a91f425 100644 --- a/packages/agent/src/adapters/claude/session/settings.test.ts +++ b/packages/agent/src/adapters/claude/session/settings.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveMainRepoPath } from "./repo-path"; -import { SettingsManager } from "./settings"; +import { mergeAvailableModels, SettingsManager } from "./settings"; function runGit(cwd: string, args: string[]): void { execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] }); @@ -207,3 +207,104 @@ describe("resolveMainRepoPath", () => { } }); }); + +describe("availableModels merge", () => { + let tmpRoot: string; + let cwd: string; + let configDir: string; + let originalConfigDir: string | undefined; + + beforeEach(async () => { + tmpRoot = await fs.promises.realpath( + await fs.promises.mkdtemp(path.join(os.tmpdir(), "available-models-")), + ); + cwd = path.join(tmpRoot, "repo"); + configDir = path.join(tmpRoot, "user"); + await fs.promises.mkdir(cwd, { recursive: true }); + await fs.promises.mkdir(configDir, { recursive: true }); + runGit(cwd, ["init", "-b", "main"]); + runGit(cwd, ["config", "user.email", "test@example.com"]); + runGit(cwd, ["config", "user.name", "test"]); + runGit(cwd, ["commit", "--allow-empty", "-m", "init"]); + + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + }); + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + await fs.promises.rm(tmpRoot, { recursive: true, force: true }); + }); + + async function writeUserSettings(settings: object): Promise { + await fs.promises.writeFile( + path.join(configDir, "settings.json"), + JSON.stringify(settings), + ); + } + + async function writeProjectSettings(settings: object): Promise { + const projectDir = path.join(cwd, ".claude"); + await fs.promises.mkdir(projectDir, { recursive: true }); + await fs.promises.writeFile( + path.join(projectDir, "settings.json"), + JSON.stringify(settings), + ); + } + + it("merges and dedupes availableModels across user and project layers", async () => { + await writeUserSettings({ availableModels: ["model-a", "model-b"] }); + await writeProjectSettings({ availableModels: ["model-b", "model-c"] }); + + const manager = new SettingsManager(cwd); + await manager.initialize(); + + expect(manager.getSettings().availableModels).toEqual([ + "model-a", + "model-b", + "model-c", + ]); + }); + + it("passes through a single layer unchanged", async () => { + await writeProjectSettings({ availableModels: ["only-one"] }); + + const manager = new SettingsManager(cwd); + await manager.initialize(); + + expect(manager.getSettings().availableModels).toEqual(["only-one"]); + }); + + it("leaves availableModels undefined when no layer defines it", async () => { + const manager = new SettingsManager(cwd); + await manager.initialize(); + + expect(manager.getSettings().availableModels).toBeUndefined(); + }); +}); + +describe("mergeAvailableModels", () => { + it("merges and dedupes non-enterprise layers", () => { + expect( + mergeAvailableModels( + ["model-a", "model-b"], + ["model-b", "model-c"], + "project", + ), + ).toEqual(["model-a", "model-b", "model-c"]); + }); + + it("lets enterprise settings replace lower-precedence allowlists", () => { + expect( + mergeAvailableModels( + ["model-a", "model-b"], + ["managed-a", "managed-a"], + "enterprise", + ), + ).toEqual(["managed-a"]); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index 0a2b8e39b4..b4aa9ab6ed 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -196,9 +196,12 @@ export interface ClaudeCodeSettings { permissions?: PermissionSettings; env?: Record; model?: string; + availableModels?: string[]; posthogApprovedExecTools?: string[]; } +type SettingsLayer = "user" | "project" | "local" | "enterprise"; + export type PermissionDecision = "allow" | "deny" | "ask"; export interface PermissionCheckResult { @@ -220,6 +223,22 @@ export function getManagedSettingsPath(): string { } } +export function mergeAvailableModels( + existing: string[] | undefined, + incoming: string[] | undefined, + layer: SettingsLayer, +): string[] | undefined { + if (incoming === undefined) { + return existing; + } + + if (layer === "enterprise") { + return Array.from(new Set(incoming)); + } + + return Array.from(new Set([...(existing ?? []), ...incoming])); +} + export class SettingsManager { private cwd: string; private repoRoot: string; @@ -283,11 +302,14 @@ export class SettingsManager { } private mergeAllSettings(): void { - const allSettings = [ - this.userSettings, - this.projectSettings, - this.localSettings, - this.enterpriseSettings, + const allSettings: Array<{ + layer: SettingsLayer; + settings: ClaudeCodeSettings; + }> = [ + { layer: "user", settings: this.userSettings }, + { layer: "project", settings: this.projectSettings }, + { layer: "local", settings: this.localSettings }, + { layer: "enterprise", settings: this.enterpriseSettings }, ]; const permissions: PermissionSettings = { @@ -298,7 +320,7 @@ export class SettingsManager { const merged: ClaudeCodeSettings = { permissions }; const posthogApprovedExecTools = new Set(); - for (const settings of allSettings) { + for (const { layer, settings } of allSettings) { if (settings.permissions) { if (settings.permissions.allow) { permissions.allow?.push(...settings.permissions.allow); @@ -325,6 +347,11 @@ export class SettingsManager { if (settings.model) { merged.model = settings.model; } + merged.availableModels = mergeAvailableModels( + merged.availableModels, + settings.availableModels, + layer, + ); if (settings.posthogApprovedExecTools) { for (const tool of settings.posthogApprovedExecTools) { posthogApprovedExecTools.add(tool); diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index 2f847e99cb..9074737ae1 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -29,8 +29,10 @@ export const WEB_TOOLS: Set = new Set(["WebSearch", "WebFetch"]); export const AGENT_TOOLS: Set = new Set([ "Task", "Agent", - "TodoWrite", - "Skill", + "TaskCreate", + "TaskUpdate", + "TaskGet", + "TaskList", ]); const BASE_ALLOWED_TOOLS = [ diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index da1f29ad54..c7aeca3dff 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -11,6 +11,7 @@ import type { import type { Pushable } from "../../utils/streams"; import type { BaseSession } from "../base-acp-agent"; import type { ContextBreakdownBaseline } from "./context-breakdown"; +import type { TaskState } from "./conversion/task-state"; import type { McpToolApprovals } from "./mcp/tool-metadata"; import type { SettingsManager } from "./session/settings"; import type { CodeExecutionMode } from "./tools"; @@ -75,6 +76,13 @@ export type Session = BaseSession & { * "command is genuinely unknown" when the session goes idle without an echo. */ knownSlashCommands?: Set; + /** + * Per-session task list accumulated from Task* tool calls. + * SDK >=0.3.142 replaced TodoWrite (snapshot) with TaskCreate/TaskUpdate + * (incremental, keyed by task id). Map iteration preserves insertion order + * which we use for plan entry ordering. + */ + taskState: TaskState; }; export type ToolUseCache = { diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 4b43b3c0f9..f064778ec5 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -466,7 +466,7 @@ export class CodexAcpAgent extends BaseAcpAgent { // Carry taskRunId/taskId across load so prompt() still emits cloud // notifications (TURN_COMPLETE, USAGE_UPDATE) after a reload. newSession - // and unstable_resumeSession both do this; loadSession historically did + // and resumeSession both do this; loadSession historically did // not, which silently broke task-completion tracking on re-attach. resetSessionState(this.sessionState, params.sessionId, params.cwd, { taskRunId: meta?.taskRunId, @@ -489,7 +489,7 @@ export class CodexAcpAgent extends BaseAcpAgent { return response; } - async unstable_resumeSession( + async resumeSession( params: ResumeSessionRequest, ): Promise { const meta = params._meta as NewSessionMeta | undefined; diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index df5d57ba0d..388ca57797 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -16,6 +16,179 @@ import type { TaskRun } from "../types"; import { AgentServer, SSE_KEEPALIVE_INTERVAL_MS } from "./agent-server"; import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt"; +const mockedClaudeSdk = vi.hoisted(() => { + const createSuccessResult = () => ({ + type: "result", + subtype: "success", + duration_ms: 100, + duration_api_ms: 50, + is_error: false, + num_turns: 1, + result: "Done", + stop_reason: null, + total_cost_usd: 0.01, + usage: { + input_tokens: 100, + output_tokens: 50, + output_tokens_details: { thinking_tokens: 0 }, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 0, + }, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + inference_geo: "us", + iterations: [], + speed: "standard", + }, + modelUsage: {}, + permission_denials: [], + uuid: crypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`, + session_id: "test-session", + }); + + const query = vi.fn( + (params: { prompt?: { push?: (message: unknown) => void } }) => { + const queuedMessages: unknown[] = []; + let resolveNext: ((value: IteratorResult) => void) | null = + null; + let isDone = false; + + const flushQueue = () => { + if (!resolveNext) { + return; + } + + if (queuedMessages.length > 0) { + const resolve = resolveNext; + resolveNext = null; + resolve({ + value: queuedMessages.shift(), + done: false, + }); + return; + } + + if (isDone) { + const resolve = resolveNext; + resolveNext = null; + resolve({ value: undefined, done: true }); + } + }; + + const enqueue = (message: unknown) => { + if (isDone) { + return; + } + queuedMessages.push(message); + flushQueue(); + }; + + const prompt = params.prompt; + if (prompt && typeof prompt.push === "function") { + const originalPush = prompt.push.bind(prompt); + prompt.push = (message: unknown) => { + originalPush(message); + + if ( + message && + typeof message === "object" && + "uuid" in message && + typeof message.uuid === "string" + ) { + enqueue({ + type: "user", + uuid: message.uuid, + parent_tool_use_id: null, + message: { + content: [], + }, + }); + enqueue(createSuccessResult()); + } + }; + } + + return { + next: vi.fn(() => { + if (queuedMessages.length > 0) { + return Promise.resolve({ + value: queuedMessages.shift(), + done: false as const, + }); + } + + if (isDone) { + return Promise.resolve({ + value: undefined, + done: true as const, + }); + } + + return new Promise>((resolve) => { + resolveNext = resolve; + }); + }), + return: vi.fn(() => { + isDone = true; + flushQueue(); + return Promise.resolve({ value: undefined, done: true as const }); + }), + throw: vi.fn((error: Error) => { + isDone = true; + flushQueue(); + return Promise.reject(error); + }), + [Symbol.asyncIterator]() { + return this; + }, + interrupt: vi.fn(async () => { + isDone = true; + flushQueue(); + }), + setPermissionMode: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockResolvedValue(undefined), + setMaxThinkingTokens: vi.fn().mockResolvedValue(undefined), + supportedCommands: vi.fn().mockResolvedValue([]), + supportedModels: vi.fn().mockResolvedValue([]), + mcpServerStatus: vi.fn().mockResolvedValue([]), + accountInfo: vi.fn().mockResolvedValue({}), + rewindFiles: vi.fn().mockResolvedValue({ canRewind: false }), + setMcpServers: vi + .fn() + .mockResolvedValue({ added: [], removed: [], errors: {} }), + streamInput: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + initializationResult: vi.fn().mockResolvedValue({ + result: "success", + commands: [], + models: [], + }), + reconnectMcpServer: vi.fn().mockResolvedValue(undefined), + toggleMcpServer: vi.fn().mockResolvedValue(undefined), + supportedAgents: vi.fn().mockResolvedValue([]), + stopTask: vi.fn().mockResolvedValue(undefined), + applyFlagSettings: vi.fn().mockResolvedValue(undefined), + getContextUsage: vi.fn().mockResolvedValue({}), + reloadPlugins: vi.fn().mockResolvedValue(undefined), + seedReadState: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + backgroundTasks: vi.fn().mockResolvedValue([]), + [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), + }; + }, + ); + + return { query }; +}); + +vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({ + ...(await importOriginal()), + query: mockedClaudeSdk.query, +})); + interface TestableServer { getInitialPromptOverride(run: TaskRun): string | null; getClearedPendingUserState(run: TaskRun | null): string[] | null; diff --git a/packages/agent/src/test/mocks/claude-sdk.ts b/packages/agent/src/test/mocks/claude-sdk.ts index 82786b54f4..d2ec6c797b 100644 --- a/packages/agent/src/test/mocks/claude-sdk.ts +++ b/packages/agent/src/test/mocks/claude-sdk.ts @@ -105,6 +105,8 @@ export function createMockQuery( getContextUsage: vi.fn().mockResolvedValue({}), reloadPlugins: vi.fn().mockResolvedValue(undefined), seedReadState: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + backgroundTasks: vi.fn().mockResolvedValue([]), [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), _abortController: abortController, _mockHelpers: { @@ -173,6 +175,7 @@ export function createSuccessResult( usage: { input_tokens: 100, output_tokens: 50, + output_tokens_details: { thinking_tokens: 0 }, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, cache_creation: { @@ -209,6 +212,7 @@ export function createErrorResult( usage: { input_tokens: 100, output_tokens: 50, + output_tokens_details: { thinking_tokens: 0 }, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, cache_creation: { diff --git a/packages/agent/src/test/native-binary.test.ts b/packages/agent/src/test/native-binary.test.ts new file mode 100644 index 0000000000..61c53413d7 --- /dev/null +++ b/packages/agent/src/test/native-binary.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + CLAUDE_CLI_SUPPORT_DIRS, + CLAUDE_CLI_SUPPORT_FILES, + claudeExecutableCandidates, +} from "../../build/native-binary.mjs"; + +describe("claudeExecutableCandidates", () => { + it("includes the legacy cli.js fallback after native binary candidates", () => { + const candidates = claudeExecutableCandidates("/tmp/node_modules"); + expect(candidates.at(-1)).toBe( + "/tmp/node_modules/@anthropic-ai/claude-agent-sdk/cli.js", + ); + }); +}); + +describe("Claude CLI support assets", () => { + it("tracks the files needed by the legacy SDK layout", () => { + expect(CLAUDE_CLI_SUPPORT_FILES).toEqual([ + "package.json", + "manifest.json", + "manifest.zst.json", + "yoga.wasm", + ]); + expect(CLAUDE_CLI_SUPPORT_DIRS).toEqual(["vendor"]); + }); +}); diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index d17d91e4ce..e704a62e9b 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -1,4 +1,5 @@ import { + chmodSync, copyFileSync, cpSync, existsSync, @@ -6,8 +7,42 @@ import { writeFileSync, } from "node:fs"; import { builtinModules } from "node:module"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { defineConfig } from "tsup"; +// Plain ESM helper, shared with apps/code/vite.main.config.mts. +import { + CLAUDE_CLI_SUPPORT_DIRS, + CLAUDE_CLI_SUPPORT_FILES, + claudeBinName, + claudeExecutableCandidates, + targetArch, + targetPlatform, +} from "./build/native-binary.mjs"; + +function nativeBinarySourcePath(): string | undefined { + const candidates = claudeExecutableCandidates( + resolve(import.meta.dirname, "../../node_modules"), + ); + return candidates.find((p: string) => existsSync(p)); +} + +function copyClaudeSupportAssets(sourcePath: string, destDir: string): void { + const sourceDir = dirname(sourcePath); + + for (const file of CLAUDE_CLI_SUPPORT_FILES) { + const source = resolve(sourceDir, file); + if (existsSync(source)) { + copyFileSync(source, resolve(destDir, file)); + } + } + + for (const dir of CLAUDE_CLI_SUPPORT_DIRS) { + const source = resolve(sourceDir, dir); + if (existsSync(source)) { + cpSync(source, resolve(destDir, dir), { recursive: true }); + } + } +} function copyAssets() { const distDir = resolve(import.meta.dirname, "dist"); @@ -22,32 +57,25 @@ function copyAssets() { cpSync(srcTemplatesDir, templatesDir, { recursive: true }); } - const claudeSdkPath = resolve( - import.meta.dirname, - "../../node_modules/@anthropic-ai/claude-agent-sdk", - ); - const cliJsPath = resolve(claudeSdkPath, "cli.js"); - if (existsSync(cliJsPath)) { - copyFileSync(cliJsPath, resolve(claudeCliDir, "cli.js")); + const binName = claudeBinName(); + const nativeBinary = nativeBinarySourcePath(); + if (nativeBinary) { + const dest = resolve(claudeCliDir, binName); + copyFileSync(nativeBinary, dest); + if (targetPlatform() !== "win32") { + chmodSync(dest, 0o755); + } + copyClaudeSupportAssets(nativeBinary, claudeCliDir); + } else { + console.warn( + `[agent/tsup] No Claude executable found for ${targetPlatform()}-${targetArch()}; install @anthropic-ai/claude-agent-sdk optional deps`, + ); } writeFileSync( resolve(claudeCliDir, "package.json"), JSON.stringify({ type: "module" }, null, 2), ); - - const yogaWasmPath = resolve( - import.meta.dirname, - "../../node_modules/yoga-wasm-web/dist/yoga.wasm", - ); - if (existsSync(yogaWasmPath)) { - copyFileSync(yogaWasmPath, resolve(claudeCliDir, "yoga.wasm")); - } - - const vendorDir = resolve(claudeSdkPath, "vendor"); - if (existsSync(vendorDir)) { - cpSync(vendorDir, resolve(claudeCliDir, "vendor"), { recursive: true }); - } } const sharedOptions = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb24b862a4..341d70b9b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,14 +712,14 @@ importers: packages/agent: dependencies: '@agentclientprotocol/sdk': - specifier: 0.19.0 - version: 0.19.0(zod@4.3.6) + specifier: 0.22.1 + version: 0.22.1(zod@4.3.6) '@anthropic-ai/claude-agent-sdk': - specifier: 0.2.112 - version: 0.2.112(zod@4.3.6) + specifier: 0.3.154 + version: 0.3.154(@anthropic-ai/sdk@0.100.0(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) '@anthropic-ai/sdk': - specifier: 0.89.0 - version: 0.89.0(zod@4.3.6) + specifier: 0.100.0 + version: 0.100.0(zod@4.3.6) '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) @@ -913,6 +913,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -921,23 +926,16 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@anthropic-ai/claude-agent-sdk@0.2.112': - resolution: {integrity: sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig==} + '@anthropic-ai/claude-agent-sdk@0.3.154': + resolution: {integrity: sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw==} engines: {node: '>=18.0.0'} peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.81.0': - resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - - '@anthropic-ai/sdk@0.89.0': - resolution: {integrity: sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==} + '@anthropic-ai/sdk@0.100.0': + resolution: {integrity: sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -2616,105 +2614,6 @@ packages: '@ide/backoff@1.0.0': resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -4834,6 +4733,9 @@ packages: '@spacingbat3/lss@1.2.0': resolution: {integrity: sha512-aywhxHNb6l7COooF3m439eT/6QN8E/RSl5IVboSKthMHcp0GlZYMSoS7546rqDLmFRxTD8f1tu/NIS9vtDwYAg==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -7486,6 +7388,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -11036,6 +10941,9 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -12356,6 +12264,10 @@ snapshots: dependencies: zod: 4.3.6 + '@agentclientprotocol/sdk@0.22.1(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -12363,34 +12275,16 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/claude-agent-sdk@0.2.112(zod@4.3.6)': + '@anthropic-ai/claude-agent-sdk@0.3.154(@anthropic-ai/sdk@0.100.0(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.81.0(zod@4.3.6) + '@anthropic-ai/sdk': 0.100.0(zod@4.3.6) '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) zod: 4.3.6 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - transitivePeerDependencies: - - '@cfworker/json-schema' - - supports-color - - '@anthropic-ai/sdk@0.81.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - '@anthropic-ai/sdk@0.89.0(zod@4.3.6)': + '@anthropic-ai/sdk@0.100.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 optionalDependencies: zod: 4.3.6 @@ -14487,68 +14381,6 @@ snapshots: '@ide/backoff@1.0.0': {} - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@3.0.1': @@ -16970,6 +16802,8 @@ snapshots: '@spacingbat3/lss@1.2.0': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@storybook/addon-a11y@10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': @@ -19917,6 +19751,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastq@1.20.1: @@ -24230,6 +24066,11 @@ snapshots: dependencies: type-fest: 0.7.1 + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4309763b4..f2f5858bcf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ minimumReleaseAge: 10080 minimumReleaseAgeExclude: - '@agentclientprotocol/sdk' - '@anthropic-ai/claude-agent-sdk' + - '@anthropic-ai/sdk' - '@pierre/diffs' - '@posthog/quill' - '@posthog/quill-tokens'