diff --git a/package.json b/package.json index 68d07e213..5d428dc3b 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,11 @@ "@ai-sdk/google": "^3.0.30", "@ai-sdk/openai": "^3.0.30", "@alpacahq/alpaca-trade-api": "^3.1.3", - "@anthropic-ai/claude-agent-sdk": "^0.2.72", + "@anthropic-ai/claude-agent-sdk": "^0.3.150", "@anthropic-ai/sdk": "^0.96.0", "@grammyjs/auto-retry": "^2.0.2", "@hono/node-server": "^2.0.0", - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@sinclair/typebox": "0.34.48", "@traderalice/ibkr": "workspace:*", "@traderalice/opentypebb": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60857fd9c..55937625d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^3.1.3 version: 3.1.3 '@anthropic-ai/claude-agent-sdk': - specifier: ^0.2.72 - version: 0.2.72(zod@4.3.6) + specifier: ^0.3.150 + version: 0.3.150(@anthropic-ai/sdk@0.96.0(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) '@anthropic-ai/sdk': specifier: ^0.96.0 version: 0.96.0(zod@4.3.6) @@ -45,8 +45,8 @@ importers: specifier: ^2.0.0 version: 2.0.0(hono@4.12.14) '@modelcontextprotocol/sdk': - specifier: ^1.27.1 - version: 1.27.1(zod@4.3.6) + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 @@ -397,10 +397,56 @@ packages: resolution: {integrity: sha512-0b0mAvxaxh1JVoX70g0/Pw28QT+MZdDbvpu+xkf3ZZUT8iYpMVacrB0nWA1qKSM0inwzrcDlVn9uSunOL1wmNQ==} engines: {node: '>=16.9', npm: '>=6'} - '@anthropic-ai/claude-agent-sdk@0.2.72': - resolution: {integrity: sha512-GR3QaLRCoWO5DkRknaaCH6zzmUNZ3E6VckEKNE7EO5R7qDBexQe9tDKag257pji2NenTrnBDMxznoZrhNCRTzA==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.150': + resolution: {integrity: sha512-YVWJ0MHdSy0tobHO2G5/+vd9iRGyosg3wM6sY4pirezsnwZJBkJv/9IeVIaKqdLv83OA6HUcxxOLGzKSBawq2Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.150': + resolution: {integrity: sha512-72M8mKCa7Tfy66G5hr5z9TirKynQa9sFj+4qDxkAp5LAYnyViUzHOqO6mEjVtwDr2aXnjqkhTdBtc5Hmn1m/nA==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.150': + resolution: {integrity: sha512-KxkrUkGRhVcj4/2LkLrhVcEPl+6McDvtpZlgikHwAczVIf7aCvh01w2meEvuNjvex3dCv0d8CT+WYWxKJUhsbw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.150': + resolution: {integrity: sha512-1nhCXjfbxwhQPTgx2+q8lFYHx8DGJEOdaSd4wLvhGJifd/9QJwtnxaill1q+qdggZDroXHDJOTugttP0be6diA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.150': + resolution: {integrity: sha512-cm+kWR077H4+ZaMPtqrHosjsVba9hjfn31gtK7D96ziz9Mzn/XhkAz68oObDOTBDqj4j+JbmAHSl5JvyGMHcAg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.150': + resolution: {integrity: sha512-G7yOB9O6twOhQH3SvZWIvOcjehfA0HD5f/j49Z/yxZK5U72hOxtnbx7GCbcH/8AyB7JFyHjHpR9hxOxFoJNIhQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.150': + resolution: {integrity: sha512-z9vlm3JdOQ1Vqj9sG8kW+r9miunv4UFQOn0AqoI++J9AgoCBjKGCH2WWmZYhGOvezZqogunXaTciJvhtDhJiWQ==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.150': + resolution: {integrity: sha512-lpAVi7tZdHi3BXRWmCVmOE2O8q7nzbvuMneYKS9rkpIbcjMjOBk6ud/rlp8Cuiqmp4LzZ8ylbbI7vFEiylK6Hg==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.150': + resolution: {integrity: sha512-/RgkK5eTgIbzw5VRvH+T/hWeC5P7dCoYEO5ZWlwTSqvjfdVFOZhkiUKf4gz0ONpeLORAetqWU4rIfcVjQAH5fg==} 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.96.0': @@ -943,105 +989,6 @@ packages: peerDependencies: hono: ^4 - '@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] - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1070,8 +1017,8 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -4673,19 +4620,44 @@ snapshots: - debug - utf-8-validate - '@anthropic-ai/claude-agent-sdk@0.2.72(zod@4.3.6)': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.150': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.150(@anthropic-ai/sdk@0.96.0(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6)': dependencies: + '@anthropic-ai/sdk': 0.96.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 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.150 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.150 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.150 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.150 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.150 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.150 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.150 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.150 '@anthropic-ai/sdk@0.96.0(zod@4.3.6)': dependencies: @@ -5122,68 +5094,6 @@ snapshots: dependencies: hono: 4.12.14 - '@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 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5225,7 +5135,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.18.0 diff --git a/scripts/guardian/dev.ts b/scripts/guardian/dev.ts index 1d2f27a55..aa31f58cf 100644 --- a/scripts/guardian/dev.ts +++ b/scripts/guardian/dev.ts @@ -15,6 +15,7 @@ */ import { resolve } from 'node:path' +import { readdirSync } from 'node:fs' import type { ChildProcess } from 'node:child_process' import { probePorts, @@ -39,10 +40,30 @@ async function main(): Promise { console.log(`[guardian] flag → ${flagPath}`) console.log('') + // On Windows, the Claude Code CLI (claude.exe) lives in a versioned AppData + // subdirectory that isn't on the system PATH. Agent SDK spawns `claude` as a + // subprocess, so we inject the directory here so every child process can find it. + const claudeCodeDir = + process.platform === 'win32' && process.env['APPDATA'] + ? (() => { + const base = `${process.env['APPDATA']}\\Claude\\claude-code` + try { + const entries = readdirSync(base) as string[] + const latest = entries.filter((e) => /^\d/.test(e)).sort().at(-1) + return latest ? `${base}\\${latest}` : undefined + } catch { + return undefined + } + })() + : undefined + const baseEnv = { ...process.env, NODE_OPTIONS: `${process.env['NODE_OPTIONS'] ?? ''} --conditions=openalice-source`.trim(), OPENALICE_USER_DATA_HOME: dataHome, + ...(claudeCodeDir + ? { PATH: `${claudeCodeDir}${process.platform === 'win32' ? ';' : ':'}${process.env['PATH'] ?? ''}` } + : {}), } // ── UTA spec (re-used by Guardian for restart) ──────────── diff --git a/scripts/guardian/shared.ts b/scripts/guardian/shared.ts index 99494b622..792402006 100644 --- a/scripts/guardian/shared.ts +++ b/scripts/guardian/shared.ts @@ -21,7 +21,8 @@ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import { setTimeout as sleep } from 'node:timers/promises' import { watch, mkdir } from 'node:fs/promises' -import { dirname } from 'node:path' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' import { probeFreePort } from '../probe-port.js' export interface GuardianPorts { @@ -53,8 +54,26 @@ export interface SpawnSpec { prefixLogs: boolean } +/** + * Resolve a bare command name to the full path of its node_modules/.bin shim. + * On Windows returns the .cmd path; on POSIX returns the symlink path. + * Falls back to the original command name if not found. + */ +function resolveLocalBin(command: string): string { + const ext = process.platform === 'win32' ? '.cmd' : '' + const candidate = join(process.cwd(), 'node_modules', '.bin', `${command}${ext}`) + return existsSync(candidate) ? candidate : command +} + export function spawnChild(spec: SpawnSpec): ChildProcess { - const child = spawn(spec.command, spec.args, { + // On Windows, .CMD shims cannot be spawn()ed directly (EINVAL). We route + // through cmd.exe /c explicitly — this keeps stdio pipes intact unlike + // the shell:true shorthand, which silently drops child stdout on Windows. + const isWin = process.platform === 'win32' + const resolved = resolveLocalBin(spec.command) + const spawnCmd = isWin ? 'cmd.exe' : resolved + const spawnArgs = isWin ? ['/c', resolved, ...spec.args] : spec.args + const child = spawn(spawnCmd, spawnArgs, { env: spec.env, stdio: spec.prefixLogs ? ['inherit', 'pipe', 'pipe'] : 'inherit', } satisfies SpawnOptions) diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index abcccfd55..13aa0dc05 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -7,12 +7,32 @@ import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk' import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk' -import { createWriteStream, mkdirSync } from 'node:fs' +import { createWriteStream, mkdirSync, readdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' import { pino } from 'pino' import type { ContentBlock } from '../../core/session.js' // Config is now resolved via profile system — override carries all needed values +/** + * Resolve the absolute path to the claude.exe binary on Windows. + * On POSIX returns undefined — claude is expected to be in PATH. + * Selects the latest versioned directory under %APPDATA%\Claude\claude-code\. + */ +function resolveClaudeExePath(): string | undefined { + if (process.platform !== 'win32') return undefined + const appData = process.env['APPDATA'] + if (!appData) return undefined + const base = join(appData, 'Claude', 'claude-code') + try { + const entries = readdirSync(base) as string[] + const latest = entries.filter(e => /^\d/.test(e)).sort().at(-1) + if (!latest) return undefined + const candidate = join(base, latest, 'claude.exe') + return existsSync(candidate) ? candidate : undefined + } catch { return undefined } +} + const logger = pino({ transport: { target: 'pino/file', options: { destination: 'logs/agent-sdk.log', mkdir: true } }, }) @@ -165,9 +185,9 @@ export async function askAgentSdk( const baseUrl = override?.baseUrl if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl - // Opt-in debug: set ALICE_SDK_DEBUG=1 to turn on the SDK's verbose stderr - // + capture every child-process stderr chunk into logs/agent-sdk-debug.log. - // This is what surfaces the actual outbound HTTP URLs the CLI hits. + // stderr is always captured into a buffer so it shows up in error messages. + // Set ALICE_SDK_DEBUG=1 to additionally enable the SDK's verbose debug logs + // and write every stderr chunk to logs/agent-sdk-debug.log. const debugEnabled = process.env.ALICE_SDK_DEBUG === '1' let debugStream: ReturnType | null = null if (debugEnabled) { @@ -180,6 +200,13 @@ export async function askAgentSdk( ) } + // Always collect stderr so we can surface it in error messages. + const stderrChunks: string[] = [] + const stderrCapture = (chunk: string): void => { + stderrChunks.push(chunk) + debugStream?.write(chunk) + } + // MCP servers const mcpServers: Record = {} if (mcpServer) { @@ -190,6 +217,16 @@ export async function askAgentSdk( let resultText = '' let ok = true + // On Windows, resolve the full path to claude.exe so the SDK doesn't have + // to rely on PATH. On POSIX, leave undefined (PATH lookup is reliable). + const claudeExePath = resolveClaudeExePath() + if (claudeExePath) { + logger.info({ claudeExePath }, 'resolved claude.exe path') + console.info(`[agent-sdk] using claude.exe: ${claudeExePath}`) + } else if (process.platform === 'win32') { + console.warn('[agent-sdk] claude.exe not found in AppData — relying on PATH') + } + try { for await (const event of sdkQuery({ prompt, @@ -205,8 +242,9 @@ export async function askAgentSdk( permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, persistSession: false, + ...(claudeExePath ? { pathToClaudeCodeExecutable: claudeExePath } : {}), ...(loginMethod === 'claudeai' ? { forceLoginMethod: 'claudeai' as const } : {}), - ...(debugStream ? { stderr: (chunk: string) => debugStream!.write(chunk) } : {}), + stderr: stderrCapture, }, })) { // assistant message — extract tool_use + text blocks @@ -295,9 +333,12 @@ export async function askAgentSdk( } catch (err) { // Extract as much detail as possible from the error const errObj = err instanceof Error ? err : new Error(String(err)) + // Merge captured stderr into the error object for logging + const capturedStderr = stderrChunks.join('').trim() || undefined const details: Record = { message: errObj.message, stack: errObj.stack, + ...(capturedStderr ? { capturedStderr } : {}), } // SDK errors may carry stderr/stdout/cause as extra properties for (const key of ['stderr', 'stdout', 'cause', 'code', 'signal'] as const) { @@ -307,7 +348,11 @@ export async function askAgentSdk( const extraKeys = Object.keys(errObj).filter(k => !(k in details)) for (const k of extraKeys) details[k] = (errObj as any)[k] - const classification = classifyError(details) + const classification = classifyError({ + ...details, + message: capturedStderr ?? errObj.message, + stderr: capturedStderr, + }) logger.error({ ...details, classification }, 'query_error') if (classification === 'auth') { // User-fixable: don't scream, just hint. Full detail already in logs/agent-sdk.log. @@ -318,7 +363,7 @@ export async function askAgentSdk( console.error('[agent-sdk] Claude Code process error:', details) } ok = false - const stderrHint = details.stderr ? `\nstderr: ${details.stderr}` : '' + const stderrHint = capturedStderr ? `\nstderr: ${capturedStderr}` : '' resultText = `Agent SDK error: ${errObj.message}${stderrHint}` } finally { debugStream?.end() diff --git a/src/core/config.ts b/src/core/config.ts index 32d71ec4c..6a1b86f59 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -275,6 +275,14 @@ const snapshotSchema = z.object({ every: z.string().default('15m'), }) +export const autoTradingSchema = z.object({ + enabled: z.boolean().default(false), + tickEvery: z.string().default('15m'), + marketSnapshotPath: z.string().default('data/market-snapshot.json'), +}) + +export type AutoTradingConfig = z.infer + export const toolsSchema = z.object({ /** Tool names that are disabled. Tools not listed are enabled by default. */ disabled: z.array(z.string()).default([]), @@ -366,6 +374,7 @@ export type Config = { aiProvider: z.infer heartbeat: z.infer snapshot: z.infer + autoTrading: AutoTradingConfig mcp: z.infer connectors: z.infer news: z.infer @@ -408,7 +417,7 @@ export async function loadConfig(): Promise { // is pending. See src/migrations/INDEX.md for the full list. await runMigrations() - const files = ['engine.json', 'agent.json', 'crypto.json', 'securities.json', 'market-data.json', 'compaction.json', 'ai-provider-manager.json', 'heartbeat.json', 'snapshot.json', 'mcp.json', 'connectors.json', 'news.json', 'tools.json', 'webhook.json'] as const + const files = ['engine.json', 'agent.json', 'crypto.json', 'securities.json', 'market-data.json', 'compaction.json', 'ai-provider-manager.json', 'heartbeat.json', 'snapshot.json', 'auto-trading.json', 'mcp.json', 'connectors.json', 'news.json', 'tools.json', 'webhook.json'] as const const raws = await Promise.all(files.map((f) => loadJsonFile(f))) const config: Config = { @@ -421,11 +430,12 @@ export async function loadConfig(): Promise { aiProvider: await parseAndSeed(files[6], aiProviderSchema, raws[6]), heartbeat: await parseAndSeed(files[7], heartbeatSchema, raws[7]), snapshot: await parseAndSeed(files[8], snapshotSchema, raws[8]), - mcp: await parseAndSeed(files[9], mcpSchema, raws[9]), - connectors: await parseAndSeed(files[10], connectorsSchema, raws[10]), - news: await parseAndSeed(files[11], newsCollectorSchema, raws[11]), - tools: await parseAndSeed(files[12], toolsSchema, raws[12]), - webhook: await parseAndSeed(files[13], webhookSchema, raws[13]), + autoTrading: await parseAndSeed(files[9], autoTradingSchema, raws[9]), + mcp: await parseAndSeed(files[10], mcpSchema, raws[10]), + connectors: await parseAndSeed(files[11], connectorsSchema, raws[11]), + news: await parseAndSeed(files[12], newsCollectorSchema, raws[12]), + tools: await parseAndSeed(files[13], toolsSchema, raws[13]), + webhook: await parseAndSeed(files[14], webhookSchema, raws[14]), } // Spawn-time-fixed channel: when guardian (Electron main) spawns the @@ -920,6 +930,7 @@ const sectionSchemas: Record = { aiProvider: aiProviderSchema, heartbeat: heartbeatSchema, snapshot: snapshotSchema, + autoTrading: autoTradingSchema, mcp: mcpSchema, connectors: connectorsSchema, news: newsCollectorSchema, @@ -937,6 +948,7 @@ const sectionFiles: Record = { aiProvider: 'ai-provider-manager.json', heartbeat: 'heartbeat.json', snapshot: 'snapshot.json', + autoTrading: 'auto-trading.json', mcp: 'mcp.json', connectors: 'connectors.json', news: 'news.json', diff --git a/src/domain/auto-trading/market-snapshot.ts b/src/domain/auto-trading/market-snapshot.ts new file mode 100644 index 000000000..877e4494f --- /dev/null +++ b/src/domain/auto-trading/market-snapshot.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { z } from 'zod' + +const signalRowSchema = z + .object({ + all_clear: z.boolean().optional(), + suggested_stop_loss: z.number().optional(), + price: z.number().optional(), + }) + .passthrough() + +const looseSchema = z + .object({ + updated_at: z.string().optional(), + CIRCUIT_BREAKER: z.string().optional(), + signals: z.record(z.string(), signalRowSchema).optional(), + market_context: z + .object({ + fear_greed_index: z + .object({ + value: z.number().nullable().optional(), + label: z.string().nullable().optional(), + }) + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough() + +export type MarketSnapshotSlice = z.infer +export type MarketSnapshotSignalRow = z.infer + +/** Writer runs every 5 minutes; 15 minutes allows one missed cycle plus delay. */ +export const MARKET_SNAPSHOT_MAX_AGE_MS = 15 * 60 * 1000 + +/** + * Returns true when updated_at is present and older than maxAgeMs. + * Missing updated_at is treated as fresh (unknown-age snapshots are not blocked). + */ +export function isSnapshotStale(snap: MarketSnapshotSlice, maxAgeMs: number): boolean { + if (!snap.updated_at) return false + const ts = Date.parse(snap.updated_at) + if (Number.isNaN(ts)) return false + return Date.now() - ts > maxAgeMs +} + +export async function readMarketSnapshotFile( + pathFromCwd: string, +): Promise { + const abs = resolve(pathFromCwd) + try { + const raw = JSON.parse(await readFile(abs, 'utf-8')) as unknown + const parsed = looseSchema.safeParse(raw) + return parsed.success ? parsed.data : undefined + } catch (err: unknown) { + if ( + err instanceof Error && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return undefined + } + throw err + } +} diff --git a/src/domain/auto-trading/scheduler.spec.ts b/src/domain/auto-trading/scheduler.spec.ts new file mode 100644 index 000000000..dd4eeeec5 --- /dev/null +++ b/src/domain/auto-trading/scheduler.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { createAutoTradingScheduler } from './scheduler.js' +import { createEventLog } from '../../core/event-log.js' +import { ConnectorCenter } from '../../core/connector-center.js' +import { createMemoryNotificationsStore } from '../../core/notifications-store.js' +import type { INotificationsStore } from '../../core/notifications-store.js' + +function tempDir(): string { + return join(tmpdir(), `auto-trading-test-${randomUUID()}`) +} + +function freshTimestamp(): string { + return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') +} + +function staleTimestamp(): string { + return new Date(Date.now() - 20 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z') +} + +async function writeSnapshot(path: string, data: object): Promise { + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, JSON.stringify(data), 'utf-8') +} + +async function notifiedTexts(store: INotificationsStore): Promise { + const { entries } = await store.read({ limit: 100 }) + return entries.map((e) => e.text).reverse() // oldest first +} + +describe('AutoTradingScheduler', () => { + let dir: string + let snapshotPath: string + let store: INotificationsStore + let connectorCenter: ConnectorCenter + let eventLog: ReturnType extends Promise ? T : never + + beforeEach(async () => { + dir = tempDir() + await mkdir(dir, { recursive: true }) + snapshotPath = join(dir, 'market-snapshot.json') + store = createMemoryNotificationsStore() + connectorCenter = new ConnectorCenter({ notificationsStore: store }) + eventLog = await createEventLog({ logPath: join(dir, 'events.jsonl') }) + }) + + function makeScheduler() { + return createAutoTradingScheduler({ + config: { + enabled: false, // start disabled; use runNow() in tests + tickEvery: '15m', + marketSnapshotPath: snapshotPath, + }, + connectorCenter, + eventLog, + }) + } + + it('notifies once when a symbol transitions to all_clear=true', async () => { + await writeSnapshot(snapshotPath, { + updated_at: freshTimestamp(), + signals: { + 'BTC/USDT:USDT': { all_clear: true, price: 65000, suggested_stop_loss: 63700 }, + }, + }) + const scheduler = makeScheduler() + await scheduler.runNow() + const texts = await notifiedTexts(store) + expect(texts).toHaveLength(1) + expect(texts[0]).toContain('BTC/USDT:USDT') + expect(texts[0]).toContain('65000') + expect(texts[0]).toContain('63700') + }) + + it('does not re-notify when a symbol stays all_clear=true across ticks', async () => { + const snap = { + updated_at: freshTimestamp(), + signals: { 'BTC/USDT:USDT': { all_clear: true, price: 65000 } }, + } + await writeSnapshot(snapshotPath, snap) + const scheduler = makeScheduler() + await scheduler.runNow() + await scheduler.runNow() + const texts = await notifiedTexts(store) + expect(texts).toHaveLength(1) + }) + + it('re-notifies when a symbol returns to all_clear=true after going false', async () => { + const scheduler = makeScheduler() + + await writeSnapshot(snapshotPath, { + updated_at: freshTimestamp(), + signals: { 'BTC/USDT:USDT': { all_clear: true, price: 65000 } }, + }) + await scheduler.runNow() + + await writeSnapshot(snapshotPath, { + updated_at: freshTimestamp(), + signals: { 'BTC/USDT:USDT': { all_clear: false, price: 64000 } }, + }) + await scheduler.runNow() + + await writeSnapshot(snapshotPath, { + updated_at: freshTimestamp(), + signals: { 'BTC/USDT:USDT': { all_clear: true, price: 65500 } }, + }) + await scheduler.runNow() + + const texts = await notifiedTexts(store) + expect(texts).toHaveLength(2) + }) + + it('does not notify when snapshot is stale (> 15 min old)', async () => { + await writeSnapshot(snapshotPath, { + updated_at: staleTimestamp(), + signals: { 'BTC/USDT:USDT': { all_clear: true, price: 65000 } }, + }) + const scheduler = makeScheduler() + await scheduler.runNow() + const texts = await notifiedTexts(store) + expect(texts).toHaveLength(0) + }) + + it('does not notify when CIRCUIT_BREAKER=HALT', async () => { + await writeSnapshot(snapshotPath, { + updated_at: freshTimestamp(), + CIRCUIT_BREAKER: 'HALT', + signals: { 'BTC/USDT:USDT': { all_clear: true, price: 65000 } }, + }) + const scheduler = makeScheduler() + await scheduler.runNow() + const texts = await notifiedTexts(store) + expect(texts).toHaveLength(0) + }) + + it('does not notify when snapshot file does not exist', async () => { + const scheduler = makeScheduler() + await scheduler.runNow() // snapshotPath never written + const texts = await notifiedTexts(store) + expect(texts).toHaveLength(0) + }) + + it('handles missing price gracefully (shows 0 instead of undefined)', async () => { + await writeSnapshot(snapshotPath, { + updated_at: freshTimestamp(), + signals: { 'ETH/USDT:USDT': { all_clear: true } }, // no price field + }) + const scheduler = makeScheduler() + await scheduler.runNow() + const texts = await notifiedTexts(store) + expect(texts).toHaveLength(1) + expect(texts[0]).not.toContain('undefined') + }) +}) diff --git a/src/domain/auto-trading/scheduler.ts b/src/domain/auto-trading/scheduler.ts new file mode 100644 index 000000000..9cbd02e91 --- /dev/null +++ b/src/domain/auto-trading/scheduler.ts @@ -0,0 +1,125 @@ +/** + * Auto-trading scheduler — Phase 1 (read-only). + * + * Runs a Pump tick every `tickEvery`. On each tick: + * 1. Read market-snapshot.json written by okx_snapshot_writer.py. + * 2. Skip if snapshot is stale (> 15 min) or CIRCUIT_BREAKER=HALT. + * 3. For each symbol that transitions from all_clear=false → true, + * send one notification via ConnectorCenter. + * 4. Same symbol staying true across ticks is NOT re-notified (dedup set). + * + * Phase 2 (open/reduce/stp order execution) is deliberately out of scope + * here — it requires new UTA HTTP routes that don't exist yet. + */ + +import type { ConnectorCenter } from '../../core/connector-center.js' +import type { EventLog } from '../../core/event-log.js' +import { createPump, type Pump } from '../../core/pump.js' +import { + readMarketSnapshotFile, + isSnapshotStale, + MARKET_SNAPSHOT_MAX_AGE_MS, +} from './market-snapshot.js' + +export interface AutoTradingSchedulerConfig { + enabled: boolean + tickEvery: string + marketSnapshotPath: string +} + +export interface AutoTradingScheduler { + start(): void + stop(): void + /** Trigger one tick immediately, outside the schedule (used in tests). */ + runNow(): Promise +} + +export function createAutoTradingScheduler(deps: { + config: AutoTradingSchedulerConfig + connectorCenter: ConnectorCenter + eventLog: EventLog +}): AutoTradingScheduler { + const { config, connectorCenter, eventLog } = deps + + /** Track which symbols were all_clear last tick to avoid duplicate notifications. */ + const prevAllClear = new Set() + + async function tick(): Promise { + const snap = await readMarketSnapshotFile(config.marketSnapshotPath) + if (!snap?.signals) return + + if (isSnapshotStale(snap, MARKET_SNAPSHOT_MAX_AGE_MS)) { + await eventLog.append('auto-trading.signal-notify', { + ok: false, + reason: 'stale_snapshot', + updatedAt: snap.updated_at, + }) + return + } + + if (snap.CIRCUIT_BREAKER === 'HALT') { + await eventLog.append('auto-trading.signal-notify', { + ok: false, + reason: 'snapshot_circuit_halt', + }) + return + } + + const newlyClear: Array<{ symbol: string; price: number; stopLoss?: number }> = [] + const currentClear = new Set() + + for (const [symbol, row] of Object.entries(snap.signals)) { + if (row.all_clear) { + currentClear.add(symbol) + if (!prevAllClear.has(symbol)) { + const rawPrice = (row as Record).price + newlyClear.push({ + symbol, + price: typeof rawPrice === 'number' ? rawPrice : 0, + stopLoss: row.suggested_stop_loss, + }) + } + } + } + + prevAllClear.clear() + for (const s of currentClear) prevAllClear.add(s) + + for (const item of newlyClear) { + const slText = item.stopLoss != null ? `\n止損建議:${item.stopLoss}` : '' + const text = `🟢 進場訊號!${item.symbol}\n價格:${item.price}${slText}` + try { + await connectorCenter.notify(text, { source: 'cron' }) + await eventLog.append('auto-trading.signal-notify', { + ok: true, + symbol: item.symbol, + price: item.price, + }) + } catch (err) { + console.warn( + 'auto-trading-scheduler: notify error:', + err instanceof Error ? err.message : err, + ) + } + } + } + + const pump: Pump = createPump({ + name: 'auto-trading', + every: config.tickEvery, + enabled: config.enabled, + onTick: tick, + }) + + return { + start() { + pump.start() + }, + stop() { + pump.stop() + }, + runNow() { + return pump.runNow() + }, + } +} diff --git a/src/main.ts b/src/main.ts index 5bdfec612..7cbf033db 100644 --- a/src/main.ts +++ b/src/main.ts @@ -49,6 +49,7 @@ import { createListenerRegistry } from './core/listener-registry.js' import { createEventBus } from './core/event-bus.js' import { createCronEngine, createCronListener, createCronTools } from './task/cron/index.js' import { createHeartbeat } from './task/heartbeat/index.js' +import { createAutoTradingScheduler } from './domain/auto-trading/scheduler.js' import { createMetricsListener } from './task/metrics/index.js' import { createAgentWorkListener } from './core/agent-work-listener.js' import { NewsCollectorStore, NewsCollector } from './domain/news/index.js' @@ -296,6 +297,18 @@ async function main() { console.log(`heartbeat: enabled (every ${config.heartbeat.every})`) } + // ==================== Auto-trading Scheduler (Pump-driven, Phase 1) ==================== + + const autoTradingScheduler = createAutoTradingScheduler({ + config: config.autoTrading, + connectorCenter, + eventLog, + }) + autoTradingScheduler.start() + if (config.autoTrading.enabled) { + console.log(`auto-trading: enabled (every ${config.autoTrading.tickEvery}, snapshot: ${config.autoTrading.marketSnapshotPath})`) + } + // ==================== Event Metrics (wildcard observer) ==================== const metricsListener = createMetricsListener({ registry: listenerRegistry }) @@ -452,6 +465,7 @@ async function main() { stopped = true newsCollector?.stop() heartbeat.stop() + autoTradingScheduler.stop() metricsListener.stop() cronListener.stop() cronEngine.stop()