From a8c51fd532f9d0c7d0682d02d3a438bb0ed29a43 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:40:36 +0530 Subject: [PATCH 01/19] feat(eve): port Claude Code agent pattern from gather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace OpenAI with Claude Code (ai-sdk-provider-claude-code) - Add HMAC trusted-header auth (x-eve-principal, BETTER_AUTH_SECRET) - Add eve-principal sign/verify shared between backend + ai-agent - Add EveModule: proxy controller, credentials controller, chats CRUD - Add EveChat Prisma model + migration - Add same-origin Next.js proxy /api/eve/v1/[...path] - Add /api/chats GET/POST and /api/chats/[sessionId] GET - Rewrite ChatSurface with onSessionChange cursor + URL swap on first turn - Add fetchChatHistory for NDJSON resume replay - Update /chat and /chat/:id pages for new/resume flows - Update ChatsList to read from /api/chats (eve sessions) - Remove withEve() from next.config — ai-agent runs separately - Slim accounts to Claude + Codex only; remove getConnectionStatus - Fix resolveClaudeCredsJson to fall back when Claude not linked - Fix @Inject() on EveProxyController to bypass esbuild decorator metadata bug - Update env.ts: drop OPENAI_API_KEY, add BETTER_AUTH_SECRET + CLAUDE_CODE_OAUTH_TOKEN Co-Authored-By: Claude Sonnet 4.6 --- apps/ai-agent/agent/agent.ts | 14 +- apps/ai-agent/agent/channels/eve.ts | 76 ++-- apps/ai-agent/agent/instructions.md | 29 +- apps/ai-agent/agent/lib/env.ts | 7 +- apps/ai-agent/agent/lib/eve-principal.ts | 50 +++ apps/ai-agent/agent/lib/model/claude-code.ts | 191 ++++++++ apps/ai-agent/agent/lib/zuko-api.ts | 22 + apps/ai-agent/agent/lib/zuko-client.ts | 35 -- apps/ai-agent/agent/sandbox/fly-sprite.ts | 168 ++++++- apps/ai-agent/agent/sandbox/sandbox.ts | 9 +- apps/ai-agent/agent/tools/create_task.ts | 20 - apps/ai-agent/agent/tools/delete_task.ts | 15 - .../agent/tools/ensure-session-sprite.ts | 41 ++ apps/ai-agent/agent/tools/get_task.ts | 13 - apps/ai-agent/agent/tools/list_tasks.ts | 47 -- apps/ai-agent/agent/tools/update_task.ts | 29 -- apps/backend/src/accounts/accounts.module.ts | 10 + apps/backend/src/accounts/accounts.service.ts | 81 ++++ .../backend/src/accounts/connection-status.ts | 20 + .../accounts/dto/upsert-account-oauth.dto.ts | 35 ++ .../src/accounts/get-valid-claude-oauth.ts | 203 +++++++++ apps/backend/src/app/app.module.ts | 2 + apps/backend/src/eve/eve-chats.controller.ts | 85 ++++ apps/backend/src/eve/eve-chats.repository.ts | 56 +++ .../src/eve/eve-credentials.controller.ts | 59 +++ apps/backend/src/eve/eve-principal.guard.ts | 92 ++++ apps/backend/src/eve/eve-proxy-headers.ts | 23 + apps/backend/src/eve/eve-proxy.controller.ts | 200 +++++++++ apps/backend/src/eve/eve-target.service.ts | 16 + apps/backend/src/eve/eve.module.ts | 16 + apps/backend/src/eve/resolve-org.ts | 22 + apps/backend/src/utils/eve-principal.ts | 50 +++ apps/web/next.config.ts | 3 +- apps/web/src/app/(app)/chat/[chatId]/page.tsx | 410 ++---------------- apps/web/src/app/(app)/chat/page.tsx | 83 +--- .../src/app/api/chats/[sessionId]/route.ts | 32 ++ apps/web/src/app/api/chats/route.ts | 49 +++ .../web/src/app/api/eve/v1/[...path]/route.ts | 83 ++++ apps/web/src/components/Chat/ChatSurface.tsx | 221 ++++++++++ apps/web/src/components/Chat/ChatsList.tsx | 49 ++- .../src/components/Chat/fetchChatHistory.ts | 54 +++ libs/models/prisma/core.prisma | 20 + .../20260716000000_add_eve_chat/migration.sql | 22 + 43 files changed, 2025 insertions(+), 737 deletions(-) create mode 100644 apps/ai-agent/agent/lib/eve-principal.ts create mode 100644 apps/ai-agent/agent/lib/model/claude-code.ts create mode 100644 apps/ai-agent/agent/lib/zuko-api.ts delete mode 100644 apps/ai-agent/agent/lib/zuko-client.ts delete mode 100644 apps/ai-agent/agent/tools/create_task.ts delete mode 100644 apps/ai-agent/agent/tools/delete_task.ts create mode 100644 apps/ai-agent/agent/tools/ensure-session-sprite.ts delete mode 100644 apps/ai-agent/agent/tools/get_task.ts delete mode 100644 apps/ai-agent/agent/tools/list_tasks.ts delete mode 100644 apps/ai-agent/agent/tools/update_task.ts create mode 100644 apps/backend/src/accounts/accounts.module.ts create mode 100644 apps/backend/src/accounts/accounts.service.ts create mode 100644 apps/backend/src/accounts/connection-status.ts create mode 100644 apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts create mode 100644 apps/backend/src/accounts/get-valid-claude-oauth.ts create mode 100644 apps/backend/src/eve/eve-chats.controller.ts create mode 100644 apps/backend/src/eve/eve-chats.repository.ts create mode 100644 apps/backend/src/eve/eve-credentials.controller.ts create mode 100644 apps/backend/src/eve/eve-principal.guard.ts create mode 100644 apps/backend/src/eve/eve-proxy-headers.ts create mode 100644 apps/backend/src/eve/eve-proxy.controller.ts create mode 100644 apps/backend/src/eve/eve-target.service.ts create mode 100644 apps/backend/src/eve/eve.module.ts create mode 100644 apps/backend/src/eve/resolve-org.ts create mode 100644 apps/backend/src/utils/eve-principal.ts create mode 100644 apps/web/src/app/api/chats/[sessionId]/route.ts create mode 100644 apps/web/src/app/api/chats/route.ts create mode 100644 apps/web/src/app/api/eve/v1/[...path]/route.ts create mode 100644 apps/web/src/components/Chat/ChatSurface.tsx create mode 100644 apps/web/src/components/Chat/fetchChatHistory.ts create mode 100644 libs/models/prisma/migrations/20260716000000_add_eve_chat/migration.sql diff --git a/apps/ai-agent/agent/agent.ts b/apps/ai-agent/agent/agent.ts index e6827b69..9f419dfc 100644 --- a/apps/ai-agent/agent/agent.ts +++ b/apps/ai-agent/agent/agent.ts @@ -1,12 +1,12 @@ import { defineAgent } from 'eve'; -import { openai } from '@ai-sdk/openai'; +import { claudeCode } from './lib/model/claude-code.js'; export default defineAgent({ - // OpenAI as the model. eve's built-in sandbox tools (bash, read_file, - // write_file, glob, grep) drive this session's Fly Sprite via the - // defineSandbox backend in sandbox/ — no model-specific spawn path needed. - model: openai('gpt-4.1'), + // Claude Code as the model — the `claude` CLI spawned inside this session's + // Fly Sprite (see lib/model/claude-code.ts + the sandbox/ folder, where eve + // owns the sprite via defineSandbox). + model: claudeCode('sonnet'), // eve can't look up a context window for a custom (non-AI-Gateway) model, - // which its compaction feature needs. gpt-4.1 accepts ~1M input tokens. - modelContextWindowTokens: 1_047_576, + // which its compaction feature needs. Supply Sonnet's 200k window explicitly. + modelContextWindowTokens: 200_000, }); diff --git a/apps/ai-agent/agent/channels/eve.ts b/apps/ai-agent/agent/channels/eve.ts index e4a5d9a6..f4815e5f 100644 --- a/apps/ai-agent/agent/channels/eve.ts +++ b/apps/ai-agent/agent/channels/eve.ts @@ -1,52 +1,32 @@ +import { type AuthFn } from 'eve/channels/auth'; import { eveChannel } from 'eve/channels/eve'; -import { type AuthFn, UnauthenticatedError } from 'eve/channels/auth'; -import { env } from '../lib/env'; - -interface BetterAuthSession { - user: { id: string; email: string; name: string }; - session: { id: string; activeOrganizationId?: string }; -} - -function betterAuth(): AuthFn { - return async (request) => { - const backendUrl = env().ZUKO_BACKEND_URL; - - // Prefer cookie from request (HTTP API / browser); fall back to env (TUI dev) - const cookieFromRequest = request.headers.get('cookie'); - const cookieFromEnv = env().ZUKO_SESSION_TOKEN - ? `better-auth.session_token=${env().ZUKO_SESSION_TOKEN}` - : null; - const sessionCookie = cookieFromRequest || cookieFromEnv; - if (!sessionCookie) return null; - - const res = await fetch(`${backendUrl}/auth/get-session`, { - headers: { cookie: sessionCookie }, - }); - if (!res.ok) return null; - - const data = (await res.json()) as BetterAuthSession | null; - if (!data?.user?.id) return null; - - const orgId = data.session?.activeOrganizationId; - if (!orgId) { - throw new UnauthenticatedError({ - message: 'No active organization on session. Select one first.', - }); - } - +import { verifyEvePrincipal } from '../lib/eve-principal.js'; + +/** + * Trusted-header AuthFn — the SOLE authenticator. The Zuko NestJS edge + * terminates the user's session and forwards an HMAC-signed principal in + * `x-eve-principal`; we verify it and project it onto eve's `SessionAuthContext`. + * + * Returns `null` when the header is absent or invalid. With no other + * authenticator in the walk, that yields a clean 401 — a signed principal is + * required, with no loopback/local-dev escape hatch. + */ +export const trustedHeaderAuth: AuthFn = (request) => { + const header = request.headers.get('x-eve-principal'); + if (!header) return null; + try { + const { userId, orgId } = verifyEvePrincipal(header); return { - authenticator: 'betterAuth', - issuer: backendUrl, - principalId: data.user.id, + authenticator: 'zuko-trusted-header', + principalId: String(userId), principalType: 'user', - subject: data.user.email, - attributes: { - orgId, - userId: data.user.id, - sessionCookie, - }, + attributes: { orgId: String(orgId) }, }; - }; -} - -export default eveChannel({ auth: [betterAuth()] }); + } catch { + return null; // present-but-invalid → unauthenticated, not a server error + } +}; + +export default eveChannel({ + auth: [trustedHeaderAuth], +}); diff --git a/apps/ai-agent/agent/instructions.md b/apps/ai-agent/agent/instructions.md index 090fa206..bce87b04 100644 --- a/apps/ai-agent/agent/instructions.md +++ b/apps/ai-agent/agent/instructions.md @@ -1,28 +1,3 @@ -# Zuko Task Assistant +# Identity -You are Zuko's task assistant. You manage tasks using the available tools. - -## Behavior - -- **Updates need ids.** Before updating or deleting a task, look it up with - `list_tasks` or `get_task` first. Never guess ids. -- **Report ids back.** After creating or updating a task, state its id and key - fields so the user can verify. -- **Deleting is permanent.** Always confirm with the user before calling - `delete_task`. State the task title and id before proceeding. -- **Root vs subtasks.** For "list all tasks" or any broad listing, omit - `parentId` entirely — do NOT pass `null`. Only pass `parentId: null` when - the user explicitly asks for top-level or root tasks only. -- **Empty strings.** Never pass `search: ""` — omit the field if there is no search term. -- **Marking done.** When marking a task done, set `status: "DONE"` and - `completedAt` to the current ISO 8601 timestamp. -- **Never invent parentId.** Only set `parentId` when the user explicitly says - "subtask of X" or "under task X". Default: omit `parentId` entirely. -- **Statuses** are: `TODO`, `IN_PROGRESS`, `DONE`. - -- **Authentication.** If a tool fails with "Not authenticated", ask the user - to paste their `better-auth.session_token` cookie value (browser DevTools → - Application → Cookies), then call `set_session_token` with it. - -Be concise. Do the work with tools; don't speculate about task state you have -not queried. +You are a helpful assistant. diff --git a/apps/ai-agent/agent/lib/env.ts b/apps/ai-agent/agent/lib/env.ts index 62e1081d..376e82af 100644 --- a/apps/ai-agent/agent/lib/env.ts +++ b/apps/ai-agent/agent/lib/env.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; const envSchema = z.object({ - OPENAI_API_KEY: z.string().min(1), // Fly Sprites auth — sandbox provisioning (agent/sandbox/fly-sprite.ts) SPRITES_TOKEN: z.string().min(1), + // HMAC signing for x-eve-principal — must match backend BETTER_AUTH_SECRET + BETTER_AUTH_SECRET: z.string().min(1), + // Claude Code OAuth token — used when user hasn't linked Claude in Settings + CLAUDE_CODE_OAUTH_TOKEN: z.string().min(1), ZUKO_BACKEND_URL: z.string().url().default('http://localhost:3001'), - // TUI dev: copy better-auth.session_token from browser DevTools → Application → Cookies - ZUKO_SESSION_TOKEN: z.string().optional(), }); let cached: z.infer | undefined; diff --git a/apps/ai-agent/agent/lib/eve-principal.ts b/apps/ai-agent/agent/lib/eve-principal.ts new file mode 100644 index 00000000..568768e7 --- /dev/null +++ b/apps/ai-agent/agent/lib/eve-principal.ts @@ -0,0 +1,50 @@ +// NOTE: keep byte-identical (logic) with apps/backend/src/utils/eve-principal.ts — duplicated across the eve/backend build boundary (eve bundles separately and cannot import backend code). +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export interface EvePrincipal { + readonly userId: number; + readonly orgId: number; +} + +const MAX_PRINCIPAL_AGE_MS = 60_000; + +function secret(): string { + const s = process.env['BETTER_AUTH_SECRET']; + if (!s) throw new Error('eve-principal: BETTER_AUTH_SECRET is required'); + return s; +} + +function hmac(payloadB64: string): string { + return createHmac('sha256', secret()).update(payloadB64).digest('hex'); +} + +export function signEvePrincipal(p: EvePrincipal): string { + const payloadB64 = Buffer.from( + JSON.stringify({ userId: p.userId, orgId: p.orgId, iat: Date.now() }), + ).toString('base64url'); + return `${payloadB64}.${hmac(payloadB64)}`; +} + +export function verifyEvePrincipal(token: string): EvePrincipal { + const dotIdx = token.indexOf('.'); + if (dotIdx === -1 || token.indexOf('.', dotIdx + 1) !== -1) + throw new Error('eve-principal: malformed token'); + const b64 = token.slice(0, dotIdx); + const sig = token.slice(dotIdx + 1); + const expected = hmac(b64); + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new Error('eve-principal: invalid signature'); + } + const { userId, orgId, iat } = JSON.parse( + Buffer.from(b64, 'base64url').toString(), + ); + if (typeof userId !== 'number' || typeof orgId !== 'number') { + throw new Error('eve-principal: invalid payload'); + } + if (typeof iat !== 'number' || Date.now() - iat > MAX_PRINCIPAL_AGE_MS) { + throw new Error('eve-principal: expired token'); + } + return { userId, orgId }; +} diff --git a/apps/ai-agent/agent/lib/model/claude-code.ts b/apps/ai-agent/agent/lib/model/claude-code.ts new file mode 100644 index 00000000..398df35b --- /dev/null +++ b/apps/ai-agent/agent/lib/model/claude-code.ts @@ -0,0 +1,191 @@ +import { randomUUID } from 'node:crypto'; +import { PassThrough } from 'node:stream'; +import type { + SpawnOptions, + SpawnedProcess, +} from '@anthropic-ai/claude-agent-sdk'; +import type { SpriteCommand } from '@fly/sprites'; +import { createClaudeCode } from 'ai-sdk-provider-claude-code'; +import { + getClaudeSessionId, + requireSessionSprite, + setClaudeSessionId, +} from '../../sandbox/fly-sprite'; + +/** Where the `claude` CLI runs inside the sprite. */ +const WORKDIR = '/sandbox'; + +/** The `claude` binary path inside the sprite image (not the host's). */ +const CLAUDE_EXECUTABLE = '/home/sprite/.local/bin/claude'; + +/** claude CLI session-selection flags the provider may inject from its shared + * capture; we strip them and substitute this session's own choice. */ +const VALUE_FLAGS = new Set([ + '--resume', + '--session-id', + '--resume-session-at', +]); +const BOOL_FLAGS = new Set(['--fork-session', '--continue']); + +/** + * Rewrite the `claude` CLI args so this eve session uses its OWN conversation + * id, never the shared model instance's captured one. + * + * The `ai-sdk-provider-claude-code` model is a module-level singleton shared by + * every concurrent eve session and captures the claude session id on the + * instance; a shared id would leak session A's conversation into session B's + * fresh sprite (`--resume` → "No conversation found"). We own the id per + * eve-session in the durable `gather.session` record (see fly-sprite.ts) and + * substitute it here: first spawn mints a fresh id (`--session-id `), + * later spawns resume it (`--resume `). + */ +function scopeSessionArgs(args: readonly string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (VALUE_FLAGS.has(arg)) { + i++; // also drop the flag's value + continue; + } + if (BOOL_FLAGS.has(arg)) continue; + out.push(arg); + } + const existing = getClaudeSessionId(); + if (existing) { + out.push('--resume', existing); + } else { + const id = randomUUID(); + setClaudeSessionId(id); + out.push('--session-id', id); + } + return out; +} + +/** Any Claude auth token the host explicitly set (fallback to the creds file + * the sandbox backend writes into the sprite). */ +function claudeAuthEnv(): Record { + const env: Record = {}; + const oauth = process.env['CLAUDE_CODE_OAUTH_TOKEN']; + const apiKey = process.env['ANTHROPIC_API_KEY']; + if (oauth) env['CLAUDE_CODE_OAUTH_TOKEN'] = oauth; + if (apiKey) env['ANTHROPIC_API_KEY'] = apiKey; + return env; +} + +/** Locale/terminal vars safe to forward verbatim. */ +const SAFE_ENV_KEYS = new Set(['LANG', 'LC_ALL', 'LC_CTYPE', 'TERM']); +/** Prefixes claude / the claude-code SDK legitimately read. */ +const SAFE_ENV_PREFIXES = ['ANTHROPIC_', 'CLAUDE_AGENT_SDK_']; + +/** + * Build a curated env for the sprite-side `claude` CLI — default-deny. + * + * The provider forwards the eve HOST's entire `process.env`, which carries host + * secrets (AWS keys, Google OAuth, DB URLs) that must NEVER cross the isolation + * boundary into the sandbox, plus host-session `CLAUDE_CODE_*` vars whose paths + * point at the host. We forward only an allowlist (locale + `ANTHROPIC_*`/ + * `CLAUDE_AGENT_SDK_*`), then pin sprite-correct `HOME`/`USER`/`PATH` (claude + * reads OAuth from `$HOME/.claude`, so a leaked host `HOME` breaks auth). + */ +function buildSpawnEnv(env: SpawnOptions['env']): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(env)) { + if (v === undefined) continue; + if ( + SAFE_ENV_KEYS.has(k) || + SAFE_ENV_PREFIXES.some((p) => k.startsWith(p)) + ) { + out[k] = v; + } + } + return { + ...out, + ...claudeAuthEnv(), + HOME: '/home/sprite', + USER: 'sprite', + PATH: '/home/sprite/.local/bin:/usr/local/bin:/usr/bin:/bin', + }; +} + +/** + * Adapt a Fly {@link SpriteCommand} to claude-code's {@link SpawnedProcess}. + * + * Two mismatches to bridge: + * 1. Shape: `SpriteCommand.exitCode()` is a METHOD (returns -1 before exit) + * while `SpawnedProcess.exitCode` is a nullable PROPERTY, and `killed` + * doesn't exist on `SpriteCommand` — so we expose real getters. + * 2. Timing: Fly connects the command's WebSocket asynchronously (~100-200ms), + * but the provider writes the init message to stdin synchronously right after + * spawn. Writing to `cmd.stdin` before the WS is open makes `SpriteCommand` + * emit a fatal "WebSocket not open" error ("ProcessTransport is not ready for + * writing"). We hand the provider a buffering `PassThrough` and only pipe it + * into the real stdin once `spawn` fires (WS open). + */ +function toSpawnedProcess(cmd: SpriteCommand): SpawnedProcess { + let killed = false; + const stdin = new PassThrough(); + cmd.once('spawn', () => stdin.pipe(cmd.stdin)); + const adapter = { + stdin, + stdout: cmd.stdout, + get killed() { + return killed; + }, + get exitCode(): number | null { + const code = cmd.exitCode(); + return code < 0 ? null : code; + }, + kill(signal: NodeJS.Signals): boolean { + killed = true; + cmd.kill(signal); + return true; + }, + on(event: 'exit' | 'error', listener: (...args: never[]) => void): void { + cmd.on(event, listener as (...a: unknown[]) => void); + }, + once(event: 'exit' | 'error', listener: (...args: never[]) => void): void { + cmd.once(event, listener as (...a: unknown[]) => void); + }, + off(event: 'exit' | 'error', listener: (...args: never[]) => void): void { + cmd.off(event, listener as (...a: unknown[]) => void); + }, + }; + return adapter as unknown as SpawnedProcess; +} + +/** + * The synchronous `spawnClaudeCodeProcess` hook: spawn the `claude` CLI in THIS + * session's sprite (provisioned + owned by eve's `defineSandbox`; reached via + * the ALS bridge). `sprite.spawn` is synchronous (the transport connects + * lazily), so no await in the model hot path. The CLI needs a full-duplex + * process — writable stdin for streaming input, readable stdout for output. + */ +function spawnInSprite(opts: SpawnOptions): SpawnedProcess { + const sprite = requireSessionSprite(); + const cmd = sprite.spawn(opts.command, scopeSessionArgs(opts.args), { + cwd: opts.cwd ?? WORKDIR, + env: buildSpawnEnv(opts.env), + }); + // Fly's spawn takes no AbortSignal; bridge cancellation to a kill. + if (opts.signal.aborted) { + cmd.kill(); + } else { + opts.signal.addEventListener('abort', () => cmd.kill(), { once: true }); + } + return toSpawnedProcess(cmd); +} + +/** + * Claude Code as eve's model, spawned INSIDE this session's Fly Sprite. eve's + * `model` slot accepts the AI SDK LanguageModel this produces; the synchronous + * `spawnClaudeCodeProcess` hook runs the `claude` CLI in the sprite eve + * provisioned (see the `sandbox/` folder). `bypassPermissions` is safe here — + * the CLI runs inside the sprite's isolation boundary, not on the eve host. + */ +export const claudeCode = createClaudeCode({ + defaultSettings: { + spawnClaudeCodeProcess: spawnInSprite, + pathToClaudeCodeExecutable: CLAUDE_EXECUTABLE, + permissionMode: 'bypassPermissions', + }, +}); diff --git a/apps/ai-agent/agent/lib/zuko-api.ts b/apps/ai-agent/agent/lib/zuko-api.ts new file mode 100644 index 00000000..1e1fce75 --- /dev/null +++ b/apps/ai-agent/agent/lib/zuko-api.ts @@ -0,0 +1,22 @@ +import { EvePrincipal, signEvePrincipal } from './eve-principal'; + +/** + * Fetch the session user's Claude OAuth blob from the Gather backend, authed by + * a short-lived HMAC EvePrincipal token. Returns the inner blob (the caller + * wraps it as `{ claudeAiOauth: blob }` for the sprite creds file). + */ +export async function fetchClaudeOauth(p: EvePrincipal): Promise { + const backendUrl = process.env['ZUKO_BACKEND_URL']; + if (!backendUrl) { + throw new Error('zuko-api: ZUKO_BACKEND_URL is not set'); + } + const url = `${backendUrl}/api/internal/eve/claude-oauth`; + const res = await fetch(url, { + headers: { Authorization: `EvePrincipal ${signEvePrincipal(p)}` }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`zuko-api: GET ${url} → HTTP ${res.status}: ${body}`); + } + return res.json(); +} diff --git a/apps/ai-agent/agent/lib/zuko-client.ts b/apps/ai-agent/agent/lib/zuko-client.ts deleted file mode 100644 index 3378bb3d..00000000 --- a/apps/ai-agent/agent/lib/zuko-client.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ToolContext } from 'eve/tools'; -import { env } from './env'; - -function sessionCookieFromCtx(ctx: ToolContext): string { - const cookie = ctx.session.auth.current?.attributes?.sessionCookie; - if (cookie) return String(cookie); - throw new Error('Not authenticated.'); -} - -export async function zukoFetch( - method: 'GET' | 'POST' | 'PATCH' | 'DELETE', - path: string, - body?: unknown, - ctx?: ToolContext, -): Promise { - const headers: Record = { - cookie: sessionCookieFromCtx(ctx!), - }; - if (body !== undefined) { - headers['content-type'] = 'application/json'; - } - const res = await fetch(`${env().ZUKO_BACKEND_URL}/api${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Zuko API ${method} ${path} → ${res.status}: ${text}`); - } - if (res.status === 204 || res.headers.get('content-length') === '0') { - return undefined as T; - } - return (await res.json()) as T; -} diff --git a/apps/ai-agent/agent/sandbox/fly-sprite.ts b/apps/ai-agent/agent/sandbox/fly-sprite.ts index 74dc3943..15b05a6f 100644 --- a/apps/ai-agent/agent/sandbox/fly-sprite.ts +++ b/apps/ai-agent/agent/sandbox/fly-sprite.ts @@ -2,6 +2,7 @@ import { posix } from 'node:path'; import { Readable } from 'node:stream'; import { buffer } from 'node:stream/consumers'; import { SpritesClient, type Sprite } from '@fly/sprites'; +import { defineState } from 'eve/context'; import type { SandboxBackend, SandboxBackendCreateInput, @@ -9,29 +10,36 @@ import type { SandboxNetworkPolicy, SandboxSession, } from 'eve/sandbox'; -import { env } from '../lib/env.js'; +import { type EvePrincipal } from '../lib/eve-principal.js'; +import { fetchClaudeOauth } from '../lib/zuko-api'; /** * Everything Fly-Sprite for the agent's sandbox, in one place: - * - provisioning + connection helpers + * - provisioning + connection helpers (also used by the claude-code spawn path) * - eve `defineSandbox` backend (`createFlySpriteBackend`, wired in sandbox.ts) + * - the per-session ALS bridge (`ensureSessionSprite` / `requireSessionSprite`) * - * eve owns the lifecycle end to end: its built-in sandbox tools (bash, - * read_file, write_file, glob, grep) and any authored tool's `getSandbox()` - * all land on this backend's per-session sprite. + * eve exposes no way for a non-tool model (claude-code drives its OWN tools) to + * reach eve's `defineSandbox` session — `getSandbox()` lives only on + * tool/callback contexts. So the claude-code path provisions the sprite itself + * via these helpers; the backend uses the SAME helpers + deterministic name, so + * both converge on one per-session VM. */ const FLY_SPRITE_BACKEND_NAME = 'fly-sprite'; -/** Where the agent works inside the sprite. */ +/** Where the agent (and the `claude` CLI) works inside the sprite. */ const WORKDIR = '/sandbox'; +/** Absolute path the `claude` CLI reads OAuth from inside the sprite. */ +const CLAUDE_CREDENTIALS_PATH = '/home/sprite/.claude/.credentials.json'; + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /** Serializable handle to a provisioned Fly Sprite — JSON-safe so it survives - * eve's captured-state serialization across turns. */ + * durable-state serialization (nested in {@link ZukoSessionState}). */ interface SandboxState { readonly type: 'fly-sprite'; readonly externalId: string; @@ -39,12 +47,32 @@ interface SandboxState { readonly createdAt: number; } +/** + * This session's durable record. Deliberately mirrors eve's own `eve.sandbox` + * envelope (`{ backendName, sessionKey, metadata }`) so the two stay swappable — + * `metadata.sandboxState` is byte-for-byte what eve's backend `captureState()` + * carries. We also fold the claude conversation id in here, so one record holds + * the whole session's sandbox identity instead of two loose state keys. + */ +interface ZukoSessionState { + readonly backendName: typeof FLY_SPRITE_BACKEND_NAME; + readonly sessionKey: string; + readonly metadata: { + readonly sandboxState: SandboxState; + readonly claudeSessionId?: string; + }; +} + // --------------------------------------------------------------------------- // Provisioning + connection helpers // --------------------------------------------------------------------------- function flyToken(): string { - return env().SPRITES_TOKEN; + const t = process.env['SPRITES_TOKEN']; + if (!t) { + throw new Error('SPRITES_TOKEN required to provision a Fly Sprite'); + } + return t; } /** @@ -106,10 +134,40 @@ function spriteNameForSession(sessionId: string): string { .slice(0, 63); } -/** Prepare a connected sprite: create the workdir. Idempotent. */ -async function prepareSprite(sprite: Sprite): Promise { +/** Resolve the Claude creds document to write into the sprite. The backend is + * the SOLE source: with a principal we fetch that user's creds (a failed fetch + * THROWS — no silent fallback to a shared token). Without a principal (only + * eve's own sandbox lifecycle, which is unauthenticated) there are no creds to + * write — returns `null`. */ +export async function resolveClaudeCredsJson( + principal?: EvePrincipal, +): Promise { + if (!principal) return null; + try { + const blob = await fetchClaudeOauth(principal); + return JSON.stringify({ claudeAiOauth: blob }); + } catch { + // Claude not linked — CLI falls back to CLAUDE_CODE_OAUTH_TOKEN env var + return null; + } +} + +/** + * Prepare a connected sprite: create the workdir + write the Claude OAuth creds. + * Idempotent. Creds come from the backend (per-user, keyed by `principal`); with + * no principal none are written. When absent, the CLI falls back to + * `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` forwarded via the spawn env. + */ +async function prepareSprite( + sprite: Sprite, + principal?: EvePrincipal, +): Promise { const fs = sprite.filesystem('/'); await fs.mkdir(WORKDIR, { recursive: true }); + const creds = await resolveClaudeCredsJson(principal); + if (creds) { + await fs.writeFile(CLAUDE_CREDENTIALS_PATH, creds, { mode: 0o600 }); + } } // --------------------------------------------------------------------------- @@ -417,16 +475,19 @@ function buildHandle( }; }, async shutdown() { - // No-op: sprites persist across turns (reconnected from captured state) - // and auto-hibernate — the server going away doesn't require teardown. + // No-op on eve-server shutdown. A Fly Sprite is remote compute that + // auto-hibernates when idle and persists across turns, so it stays + // reattachable from the captured `sandboxState` on the next server + // start (eve's durable-session contract) — nothing to stop here. }, }; } /** * A `SandboxBackend` that provisions a per-session Fly Sprite. eve owns the - * lifecycle: `create` on first turn, resume from captured `sandboxState` later - * (deterministic per-session name, so a retried turn reconnects the same VM). + * lifecycle: `create` on first turn, resume from captured `sandboxState` later. + * Shares the sprite toolkit with the claude-code spawn path, so both converge on + * the same per-session VM. */ export function createFlySpriteBackend(): SandboxBackend { return { @@ -450,3 +511,82 @@ export function createFlySpriteBackend(): SandboxBackend { }, }; } + +// --------------------------------------------------------------------------- +// Per-session ALS bridge for the claude-code spawn hook +// --------------------------------------------------------------------------- + +/** + * This session's durable record ({@link ZukoSessionState}). `defineState` is + * DURABLE — serialized at the end of each step and restored across turns — so we + * store only JSON-safe identifiers (the {@link SandboxState} reconnect record + + * the claude conversation id), NEVER a live `Sprite` (a WebSocket handle that + * can't serialize). ALS-scoped: resolves to THIS session even under concurrency. + * + * Named `zuko.session` to mirror eve's own `eve.sandbox` envelope; the `eve.` + * prefix is reserved (`defineState` rejects it), so we namespace under `zuko.`. + */ +const sessionState = defineState( + 'zuko.session', + () => null, +); + +/** + * Provision this session's sprite once and stash its serializable record. Called + * from the `step.started` resolver, which eve awaits BEFORE each model call in + * the session ALS. Idempotent: turn 1 provisions, later turns short-circuit on + * the durable record — so a session reuses ONE VM (deterministic name; even a + * cross-process resume reconnects the same sprite). Creds are fetched at most + * once per session ONLY on success — a failed fetch throws before + * `sessionState.update()` runs, so the durable record is never written and the + * next turn retries the fetch. `principal`, when present (an authenticated + * zuko user), routes creds through the backend instead of the env fallback — + * see {@link resolveClaudeCredsJson}. + */ +export async function ensureSessionSprite( + sessionId: string, + principal?: EvePrincipal, +): Promise { + if (sessionState.get()) return; + const sandboxState = await provisionSprite(spriteNameForSession(sessionId)); + await prepareSprite(connectSprite(sandboxState.externalId), principal); + sessionState.update(() => ({ + backendName: FLY_SPRITE_BACKEND_NAME, + sessionKey: sessionId, + metadata: { sandboxState }, + })); +} + +/** Synchronous accessor for the spawn hook — reconnects from the stored record + * (`connectSprite` is sync + lazy). Throws if the resolver hasn't run. */ +export function requireSessionSprite(): Sprite { + const state = sessionState.get(); + if (!state) { + throw new Error( + 'no session sprite — the step.started resolver (tools/ensure-session-sprite.ts) must run before the model call', + ); + } + return connectSprite(state.metadata.sandboxState.externalId); +} + +/** + * This session's `claude` conversation id, folded into the same durable record. + * `undefined` until the first spawn mints one (see {@link setClaudeSessionId}). + */ +export function getClaudeSessionId(): string | undefined { + return sessionState.get()?.metadata.claudeSessionId; +} + +/** Persist this session's `claude` conversation id into the durable record so + * later turns can `--resume` it. Requires {@link ensureSessionSprite} to have + * run first (the record must already exist). */ +export function setClaudeSessionId(claudeSessionId: string): void { + sessionState.update((prev) => { + if (!prev) { + throw new Error( + 'no session record — ensureSessionSprite must run before setClaudeSessionId', + ); + } + return { ...prev, metadata: { ...prev.metadata, claudeSessionId } }; + }); +} diff --git a/apps/ai-agent/agent/sandbox/sandbox.ts b/apps/ai-agent/agent/sandbox/sandbox.ts index e98ddb3f..235019e2 100644 --- a/apps/ai-agent/agent/sandbox/sandbox.ts +++ b/apps/ai-agent/agent/sandbox/sandbox.ts @@ -2,9 +2,12 @@ import { defineSandbox } from 'eve/sandbox'; import { createFlySpriteBackend } from './fly-sprite.js'; /** - * eve's per-session sandbox, backed by a Fly Sprite. eve owns the lifecycle - * (provision on first use, resume from captured state, dispose) and exposes its - * built-in sandbox tools (bash, read_file, write_file, glob, grep) to the model. + * eve's per-session sandbox, backed by a Fly Sprite. Declares the sandbox so eve + * owns the lifecycle when its own tools run (provision on first use, resume, + * shutdown). The claude-code model doesn't drive eve's tools and eve exposes no + * way for it to reach this session, so it self-provisions the sprite via the + * shared helpers in fly-sprite.ts — the same deterministic name + backend, so + * both converge on one per-session VM. */ export default defineSandbox({ backend: createFlySpriteBackend(), diff --git a/apps/ai-agent/agent/tools/create_task.ts b/apps/ai-agent/agent/tools/create_task.ts deleted file mode 100644 index ea258a3d..00000000 --- a/apps/ai-agent/agent/tools/create_task.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineTool } from 'eve/tools'; -import { z } from 'zod'; -import { zukoFetch } from '../lib/zuko-client'; - -export default defineTool({ - description: 'Create a new task.', - inputSchema: z.object({ - title: z.string().min(1).describe('Task title'), - description: z.string().optional().describe('Plain-text description'), - status: z - .enum(['TODO', 'IN_PROGRESS', 'DONE']) - .optional() - .default('TODO') - .describe('Initial status'), - assignee: z.string().optional().describe('Assignee user ID (string)'), - }), - async execute(input, ctx) { - return zukoFetch('POST', '/tasks', input, ctx); - }, -}); diff --git a/apps/ai-agent/agent/tools/delete_task.ts b/apps/ai-agent/agent/tools/delete_task.ts deleted file mode 100644 index 560eff0b..00000000 --- a/apps/ai-agent/agent/tools/delete_task.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineTool } from 'eve/tools'; -import { z } from 'zod'; -import { zukoFetch } from '../lib/zuko-client'; - -export default defineTool({ - description: - 'Permanently delete a task by ID. Always confirm with the user before calling this.', - inputSchema: z.object({ - id: z.number().int().positive().describe('Task ID to delete'), - }), - async execute({ id }, ctx) { - await zukoFetch('DELETE', `/tasks/${id}`, undefined, ctx); - return { deleted: true, id }; - }, -}); diff --git a/apps/ai-agent/agent/tools/ensure-session-sprite.ts b/apps/ai-agent/agent/tools/ensure-session-sprite.ts new file mode 100644 index 00000000..161f9bad --- /dev/null +++ b/apps/ai-agent/agent/tools/ensure-session-sprite.ts @@ -0,0 +1,41 @@ +import { defineDynamic } from 'eve/tools'; +import { type EvePrincipal } from '../lib/eve-principal.js'; +import { ensureSessionSprite } from '../sandbox/fly-sprite.js'; + +/** Pull {userId, orgId} from the eve session auth, or `undefined` if the + * session carries no principal (only eve's own unauthenticated sandbox + * lifecycle — no creds are then written). */ +export function principalFromSession(auth: { + current?: { + principalId?: string; + attributes?: Record; + } | null; +}): EvePrincipal | undefined { + const cur = auth.current; + if (!cur) return undefined; + const userId = Number(cur.principalId); + const orgRaw = cur.attributes?.['orgId']; + const orgId = Number(Array.isArray(orgRaw) ? orgRaw[0] : orgRaw); + if (!Number.isFinite(userId) || !Number.isFinite(orgId)) return undefined; + return { userId, orgId }; +} + +/** + * Not a real dynamic tool — borrowed as eve's awaited pre-model seam. A + * `step.started` resolver runs BEFORE each model call, inside the session's + * AsyncLocalStorage, and eve awaits it. We use it to provision this session's + * Fly Sprite once (via the sandbox helpers) so the synchronous + * `spawnClaudeCodeProcess` hook (model/claude-code.ts) can read its handle. + * Contributes no tools (`return null`). + */ +export default defineDynamic({ + events: { + 'step.started': async (_event, ctx) => { + await ensureSessionSprite( + ctx.session.id, + principalFromSession(ctx.session.auth), + ); + return null; + }, + }, +}); diff --git a/apps/ai-agent/agent/tools/get_task.ts b/apps/ai-agent/agent/tools/get_task.ts deleted file mode 100644 index 784d5741..00000000 --- a/apps/ai-agent/agent/tools/get_task.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineTool } from 'eve/tools'; -import { z } from 'zod'; -import { zukoFetch } from '../lib/zuko-client'; - -export default defineTool({ - description: 'Get full details for a single task by ID, including subtasks.', - inputSchema: z.object({ - id: z.number().int().positive().describe('Task ID'), - }), - async execute({ id }, ctx) { - return zukoFetch('GET', `/tasks/${id}`, undefined, ctx); - }, -}); diff --git a/apps/ai-agent/agent/tools/list_tasks.ts b/apps/ai-agent/agent/tools/list_tasks.ts deleted file mode 100644 index 4520da29..00000000 --- a/apps/ai-agent/agent/tools/list_tasks.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { defineTool } from 'eve/tools'; -import { z } from 'zod'; -import { zukoFetch } from '../lib/zuko-client'; - -export default defineTool({ - description: - 'List tasks in the organization with optional pagination and filtering.', - inputSchema: z.object({ - page: z - .number() - .int() - .positive() - .optional() - .describe('Page number (default 1)'), - limit: z - .number() - .int() - .positive() - .max(100) - .optional() - .describe('Items per page (default 50)'), - parentId: z - .number() - .int() - .nullable() - .optional() - .describe('Filter by parent task id; null for root-level tasks only'), - search: z - .string() - .min(1) - .optional() - .describe('Search by task title; omit to return all'), - }), - async execute(input, ctx) { - const params = new URLSearchParams(); - if (input.page !== undefined) params.set('page', String(input.page)); - if (input.limit !== undefined) params.set('limit', String(input.limit)); - if (input.parentId !== undefined) - params.set( - 'parentId', - input.parentId === null ? 'null' : String(input.parentId), - ); - if (input.search !== undefined) params.set('search', input.search); - const qs = params.toString(); - return zukoFetch('GET', `/tasks${qs ? `?${qs}` : ''}`, undefined, ctx); - }, -}); diff --git a/apps/ai-agent/agent/tools/update_task.ts b/apps/ai-agent/agent/tools/update_task.ts deleted file mode 100644 index a53a9cbe..00000000 --- a/apps/ai-agent/agent/tools/update_task.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { defineTool } from 'eve/tools'; -import { z } from 'zod'; -import { zukoFetch } from '../lib/zuko-client'; - -export default defineTool({ - description: 'Update an existing task by ID.', - inputSchema: z.object({ - id: z.number().int().positive().describe('Task ID to update'), - title: z.string().optional().describe('New title'), - description: z.string().optional().describe('New plain-text description'), - status: z - .enum(['TODO', 'IN_PROGRESS', 'DONE']) - .optional() - .describe('New status'), - assignee: z - .string() - .nullable() - .optional() - .describe('New assignee user ID; null to unassign'), - completedAt: z - .string() - .nullable() - .optional() - .describe('ISO 8601 completion timestamp; null to clear'), - }), - async execute({ id, ...dto }, ctx) { - return zukoFetch('PATCH', `/tasks/${id}`, dto, ctx); - }, -}); diff --git a/apps/backend/src/accounts/accounts.module.ts b/apps/backend/src/accounts/accounts.module.ts new file mode 100644 index 00000000..40331267 --- /dev/null +++ b/apps/backend/src/accounts/accounts.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AccountsService } from './accounts.service'; + +@Module({ + imports: [PrismaModule], + providers: [AccountsService], + exports: [AccountsService], +}) +export class AccountsModule {} diff --git a/apps/backend/src/accounts/accounts.service.ts b/apps/backend/src/accounts/accounts.service.ts new file mode 100644 index 00000000..33ff372b --- /dev/null +++ b/apps/backend/src/accounts/accounts.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import type { ClaudeAiOauth } from './dto/upsert-account-oauth.dto'; +import { getValidClaudeOauthWithStore } from './get-valid-claude-oauth'; + +@Injectable() +export class AccountsService { + constructor(private readonly db: PrismaService) {} + + async getClaudeAccount(userId: number) { + return this.db.account.findFirst({ + where: { userId, providerId: 'claude' }, + select: { + accessToken: true, + refreshToken: true, + accessTokenExpiresAt: true, + scope: true, + }, + orderBy: { id: 'desc' }, + }); + } + + async upsertClaudeOauth(userId: number, creds: ClaudeAiOauth): Promise { + const data = { + userId, + providerId: 'claude', + accessToken: creds.accessToken, + refreshToken: creds.refreshToken, + accessTokenExpiresAt: new Date(creds.expiresAt), + scope: creds.scopes.join(' '), + }; + await this.db.$transaction(async (tx) => { + const existing = await tx.account.findFirst({ + where: { userId, providerId: 'claude' }, + select: { id: true }, + }); + if (existing) { + await tx.account.update({ where: { id: existing.id }, data }); + } else { + await tx.account.create({ + data: { ...data, accountId: `claude_${userId}` }, + }); + } + }); + } + + async deleteByProvider(userId: number, providerId: string): Promise { + await this.db.account.deleteMany({ where: { userId, providerId } }); + } + + async getValidClaudeOauth(userId: number): Promise { + return getValidClaudeOauthWithStore( + { + read: (uid) => + this.db.account.findFirst({ + where: { userId: uid, providerId: 'claude' }, + select: { + id: true, + accessToken: true, + refreshToken: true, + accessTokenExpiresAt: true, + scope: true, + }, + orderBy: { id: 'desc' }, + }), + persist: async (id, oauth) => { + await this.db.account.update({ + where: { id }, + data: { + accessToken: oauth.accessToken, + refreshToken: oauth.refreshToken, + accessTokenExpiresAt: new Date(oauth.expiresAt), + scope: oauth.scopes.join(' '), + }, + }); + }, + }, + userId, + ); + } +} diff --git a/apps/backend/src/accounts/connection-status.ts b/apps/backend/src/accounts/connection-status.ts new file mode 100644 index 00000000..83cf0c84 --- /dev/null +++ b/apps/backend/src/accounts/connection-status.ts @@ -0,0 +1,20 @@ +export type ConnectionStatus = { + connectionId: string; + status: 'connected' | 'disconnected'; + scope: 'individual' | 'org-wide'; + connectedBy: { name: string; isCurrentUser: boolean } | null; + connectedAt: string | null; +}; + +export const INDIVIDUAL_CONNECTIONS: ReadonlyArray<{ + connectionId: string; + providerId: string; +}> = [ + { connectionId: 'claude-code', providerId: 'claude' }, + { connectionId: 'codex', providerId: 'codex' }, +]; + +export const ORG_NATIVE_CONNECTIONS: ReadonlyArray<{ + connectionId: string; + provider: string; +}> = []; diff --git a/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts b/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts new file mode 100644 index 00000000..bb09a93f --- /dev/null +++ b/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Allow-list of providerIds we accept. Add new entries when a new + * vendor needs UI-side credential entry. + */ +export const SUPPORTED_ACCOUNT_PROVIDERS = ['claude', 'jira'] as const; + +/** + * Shape of the blob `claude login` writes to + * ~/.claude/.credentials.json under the `claudeAiOauth` key. We persist + * the OAuth fields directly on the account row; vendor-specific extras + * (subscriptionType, rateLimitTier) are ignored. + */ +export const claudeAiOauthSchema = z.object({ + accessToken: z.string().min(8).max(4000), + refreshToken: z.string().min(8).max(4000), + expiresAt: z.number().int().positive(), + scopes: z.array(z.string()).default([]), +}); + +export type ClaudeAiOauth = z.infer; + +/** + * Body for `POST /accounts/oauth`. Discriminated on providerId so we + * can plug new vendors (OpenAI, etc.) in without rewriting callers. + */ +export const upsertAccountOauthSchema = z.discriminatedUnion('providerId', [ + z.object({ + providerId: z.literal('claude'), + credentials: claudeAiOauthSchema, + }), +]); + +export type UpsertAccountOauthDto = z.infer; diff --git a/apps/backend/src/accounts/get-valid-claude-oauth.ts b/apps/backend/src/accounts/get-valid-claude-oauth.ts new file mode 100644 index 00000000..e1a730d7 --- /dev/null +++ b/apps/backend/src/accounts/get-valid-claude-oauth.ts @@ -0,0 +1,203 @@ +import type { Pool } from 'pg'; +import type { ClaudeAiOauth } from './dto/upsert-account-oauth.dto'; + +// Claude Code's PKCE OAuth client. Confirmed by reading the `claude` binary +// that the CLI installs (the `claude login` flow uses this same constant +// for both initial auth and refresh). +export const CLAUDE_OAUTH_TOKEN_URL = + 'https://platform.claude.com/v1/oauth/token'; +export const CLAUDE_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; + +// Refresh slightly ahead of true expiry so claims dispatched within the last +// 60s of validity don't race the 401 response in the runner. +export const REFRESH_LEEWAY_MS = 60_000; + +export interface ClaudeAccountRow { + id: number; + accessToken: string | null; + refreshToken: string | null; + accessTokenExpiresAt: Date | null; + scope: string | null; +} + +/** + * The two reads + one write the refresh flow needs, abstracted over the data + * layer. The request path backs this with Prisma (the shared `zuko-backend` + * pool); workflow step bodies back it with raw `pg` (steps are bundled + * separately and MUST NOT import `@zuko/models` — see step-db.ts). One + * control flow, two stores. + */ +export interface ClaudeOauthStore { + read(userId: number): Promise; + persist(id: number, oauth: ClaudeAiOauth): Promise; +} + +/** + * Return the user's Claude OAuth blob, refreshing it against Anthropic if the + * access token is expired (or expires within `REFRESH_LEEWAY_MS`). Persists the + * rotated token back to the `account` row. + * + * This mirrors how Better Auth refreshes every other provider's token (see + * better-auth `getAccessToken`): an optimistic read → refresh → update — no + * dedicated pool, no SELECT … FOR UPDATE, no lock held across the network call. + * + * The one wrinkle Better Auth's providers (Google, GitHub, …) don't have: + * Anthropic *single-uses* the refresh_token — it rotates and invalidates the + * old one on every refresh. So two requests refreshing the same user + * concurrently race, and the loser's refresh_token is already dead by the time + * it calls Anthropic. We recover without a lock: if our refresh throws, re-read + * the row — the winner has almost certainly just written a fresh token, so we + * return that instead of surfacing a spurious error. If the re-read is still + * stale, the failure is real and we rethrow. + * + * Throws "Claude not connected" if no usable row exists. Throws on a genuine + * refresh failure with an actionable "Reconnect Claude under Settings" + * message — callers should surface it verbatim. + */ +export async function getValidClaudeOauthWithStore( + store: ClaudeOauthStore, + userId: number, +): Promise { + const account = await store.read(userId); + if (!account?.accessToken || !account.refreshToken) { + throw new Error('Claude not connected. Add it under Settings.'); + } + + if (isFresh(account.accessTokenExpiresAt)) { + return rowToOauth(account); + } + + // Stale: refresh against Anthropic, persist the rotated row, return. + try { + const refreshed = await fetchRefreshFromAnthropic(account.refreshToken); + await store.persist(account.id, refreshed); + return refreshed; + } catch (err) { + // A concurrent caller may have rotated the refresh_token out from under us + // (Anthropic invalidates it on use). If the row is now fresh, that race — + // not a real failure — is what we hit; return the winner's token. + const latest = await store.read(userId); + if ( + latest?.accessToken && + latest.refreshToken && + isFresh(latest.accessTokenExpiresAt) + ) { + return rowToOauth(latest); + } + throw err; + } +} + +/** + * Raw-`pg` entry point for workflow step bodies. Keeps the step bundle free of + * `@zuko/models` / Prisma (see step-db.ts) — it only touches the passed pool. + */ +export function getValidClaudeOauthFromPool( + pool: Pool, + userId: number, +): Promise { + const store: ClaudeOauthStore = { + async read(uid) { + const { rows } = await pool.query<{ + id: number; + access_token: string | null; + refresh_token: string | null; + access_token_expires_at: Date | null; + scope: string | null; + }>( + `SELECT id, access_token, refresh_token, access_token_expires_at, scope + FROM "accounts" + WHERE user_id = $1 AND provider_id = 'claude' + ORDER BY id DESC + LIMIT 1`, + [uid], + ); + const r = rows[0]; + return r + ? { + id: r.id, + accessToken: r.access_token, + refreshToken: r.refresh_token, + accessTokenExpiresAt: r.access_token_expires_at, + scope: r.scope, + } + : null; + }, + async persist(id, oauth) { + await pool.query( + `UPDATE "accounts" + SET access_token = $1, + refresh_token = $2, + access_token_expires_at = $3, + scope = $4 + WHERE id = $5`, + [ + oauth.accessToken, + oauth.refreshToken, + new Date(oauth.expiresAt), + oauth.scopes.join(' '), + id, + ], + ); + }, + }; + return getValidClaudeOauthWithStore(store, userId); +} + +function isFresh(expiresAt: Date | null): boolean { + return (expiresAt?.getTime() ?? 0) - Date.now() > REFRESH_LEEWAY_MS; +} + +// Callers guard that accessToken/refreshToken are present before calling. +function rowToOauth(row: ClaudeAccountRow): ClaudeAiOauth { + return { + accessToken: row.accessToken ?? '', + refreshToken: row.refreshToken ?? '', + expiresAt: row.accessTokenExpiresAt?.getTime() ?? 0, + scopes: row.scope ? row.scope.split(' ').filter(Boolean) : [], + }; +} + +async function fetchRefreshFromAnthropic( + refreshToken: string, +): Promise { + const res = await fetch(CLAUDE_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: CLAUDE_OAUTH_CLIENT_ID, + }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error( + `Claude OAuth refresh failed (${res.status} ${res.statusText}): ${detail.slice(0, 200)}. Reconnect Claude under Settings.`, + ); + } + const data = (await res.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; + }; + if (!data.access_token || !data.expires_in) { + throw new Error( + 'Claude OAuth refresh: malformed response (missing access_token or expires_in).', + ); + } + // Anthropic rotates refresh_token on each refresh; fall back to the + // submitted one only if the response omits it (defensive — observed + // behavior is that they always rotate). + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + scopes: + typeof data.scope === 'string' + ? data.scope.split(' ').filter(Boolean) + : [], + }; +} diff --git a/apps/backend/src/app/app.module.ts b/apps/backend/src/app/app.module.ts index 84ff1963..0c0dfe62 100644 --- a/apps/backend/src/app/app.module.ts +++ b/apps/backend/src/app/app.module.ts @@ -21,6 +21,7 @@ import { McpModule } from './mcp/mcp.module'; import { auth } from '../libs/better-auth/auth'; import { IcpModule } from './icp/icp.module'; import { PagesModule } from './pages/pages.module'; +import { EveModule } from '../eve/eve.module'; const authModule = AuthModule.forRoot({ auth, disableGlobalAuthGuard: true }); @@ -51,6 +52,7 @@ const authModule = AuthModule.forRoot({ auth, disableGlobalAuthGuard: true }); IcpModule, PagesModule, McpModule, + EveModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/eve/eve-chats.controller.ts b/apps/backend/src/eve/eve-chats.controller.ts new file mode 100644 index 00000000..7c81fdbb --- /dev/null +++ b/apps/backend/src/eve/eve-chats.controller.ts @@ -0,0 +1,85 @@ +import { + BadRequestException, + Body, + Controller, + Get, + NotFoundException, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@thallesp/nestjs-better-auth'; +import type { Request } from 'express'; +import { EveChatsRepository } from './eve-chats.repository'; + +interface RequestWithUser extends Request { + user: { id: string }; + session: { session: { activeOrganizationId?: string } }; +} + +function resolveIds(req: RequestWithUser): { + userId: number; + organizationId: number; +} { + const userId = parseInt(req.user.id, 10); + const orgIdRaw = + (req.headers['x-organization-id'] as string) || + req.session?.session?.activeOrganizationId; + const organizationId = orgIdRaw ? parseInt(orgIdRaw, 10) : NaN; + if (!Number.isFinite(userId) || !Number.isFinite(organizationId)) { + throw new BadRequestException('Could not resolve user or organization'); + } + return { userId, organizationId }; +} + +/** Thin per-user index of eve sessions (the conversation lives in eve). */ +@UseGuards(AuthGuard) +@Controller('eve-chats') +export class EveChatsController { + constructor(private readonly repo: EveChatsRepository) {} + + @Post() + async create( + @Req() req: RequestWithUser, + @Body() + body: { + sessionId?: string; + title?: string; + continuationToken?: string; + streamIndex?: number; + }, + ) { + const { userId, organizationId } = resolveIds(req); + const sessionId = (body.sessionId ?? '').trim(); + if (!sessionId) throw new BadRequestException('sessionId required'); + return this.repo.upsertBySessionId( + userId, + organizationId, + sessionId, + (body.title ?? '').slice(0, 80), + { + continuationToken: body.continuationToken, + streamIndex: body.streamIndex, + }, + ); + } + + @Get() + async list(@Req() req: RequestWithUser) { + const { userId, organizationId } = resolveIds(req); + const sessions = await this.repo.listByUser(userId, organizationId); + return { sessions }; + } + + @Get(':sessionId') + async findOne( + @Req() req: RequestWithUser, + @Param('sessionId') sessionId: string, + ) { + const { userId, organizationId } = resolveIds(req); + const row = await this.repo.getBySessionId(sessionId, userId, organizationId); + if (!row) throw new NotFoundException('Session not found'); + return row; + } +} diff --git a/apps/backend/src/eve/eve-chats.repository.ts b/apps/backend/src/eve/eve-chats.repository.ts new file mode 100644 index 00000000..21f976f7 --- /dev/null +++ b/apps/backend/src/eve/eve-chats.repository.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class EveChatsRepository { + constructor(private readonly prisma: PrismaService) {} + + upsertBySessionId( + userId: number, + organizationId: number, + sessionId: string, + title: string, + cursor: { continuationToken?: string; streamIndex?: number }, + ) { + return this.prisma.eveChat.upsert({ + where: { sessionId }, + create: { + sessionId, + title, + createdById: userId, + organizationId, + continuationToken: cursor.continuationToken, + streamIndex: cursor.streamIndex, + }, + update: { + ...(cursor.continuationToken !== undefined + ? { continuationToken: cursor.continuationToken } + : {}), + ...(cursor.streamIndex !== undefined + ? { streamIndex: cursor.streamIndex } + : {}), + }, + select: { sessionId: true }, + }); + } + + listByUser(userId: number, organizationId: number) { + return this.prisma.eveChat.findMany({ + where: { createdById: userId, organizationId }, + select: { sessionId: true, title: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + getBySessionId(sessionId: string, userId: number, organizationId: number) { + return this.prisma.eveChat.findFirst({ + where: { sessionId, createdById: userId, organizationId }, + select: { + sessionId: true, + title: true, + continuationToken: true, + streamIndex: true, + }, + }); + } +} diff --git a/apps/backend/src/eve/eve-credentials.controller.ts b/apps/backend/src/eve/eve-credentials.controller.ts new file mode 100644 index 00000000..0c2da953 --- /dev/null +++ b/apps/backend/src/eve/eve-credentials.controller.ts @@ -0,0 +1,59 @@ +import { + BadRequestException, + Controller, + Get, + Req, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { AllowAnonymous } from '@thallesp/nestjs-better-auth'; +import { AccountsService } from '../accounts/accounts.service'; +import { + EvePrincipalGuard, + type EvePrincipalContext, +} from './eve-principal.guard'; + +/** + * Internal endpoint the eve agent calls to get the session user's Claude OAuth. + * Route: GET /api/internal/eve/claude-oauth + * Auth: EvePrincipalGuard (Authorization: EvePrincipal ); global + * AppAuthGuard bypassed via @AllowAnonymous(). + */ +@AllowAnonymous() +@UseGuards(EvePrincipalGuard) +@Controller('internal/eve') +export class EveCredentialsController { + constructor(private readonly accountsService: AccountsService) {} + + @Get('claude-oauth') + async claudeOauth( + @Req() req: Request & { evePrincipal: EvePrincipalContext }, + ) { + try { + return await this.accountsService.getValidClaudeOauth( + req.evePrincipal.userId, + ); + } catch (err) { + // getValidClaudeOauth throws two distinct shapes (see + // get-valid-claude-oauth.ts): an actionable "not connected" / + // "reconnect under Settings" error the client can act on (→ 400), or a + // transient upstream failure (Anthropic unreachable, network error, + // etc.) that's retryable and should surface as a 5xx, not a 400. + if (isActionable(err)) { + throw new BadRequestException( + err instanceof Error ? err.message : String(err), + ); + } + throw err; + } + } +} + +/** + * Both actionable errors thrown by getValidClaudeOauthWithStore reference + * connecting/reconnecting "under Settings" — that's the signal the client + * should act rather than retry. + */ +function isActionable(err: unknown): boolean { + return err instanceof Error && err.message.includes('under Settings.'); +} diff --git a/apps/backend/src/eve/eve-principal.guard.ts b/apps/backend/src/eve/eve-principal.guard.ts new file mode 100644 index 00000000..86c0e6fe --- /dev/null +++ b/apps/backend/src/eve/eve-principal.guard.ts @@ -0,0 +1,92 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { verifyEvePrincipal } from '../utils/eve-principal'; +import type { Request } from 'express'; + +/** Shape attached to `req.evePrincipal` after guard passes. */ +export interface EvePrincipalContext { + readonly userId: number; + readonly orgId: number; + /** Snapshot of the user's display name at auth time — used for audit actors. */ + readonly name: string | null; + /** Snapshot of the user's email at auth time — used for audit actors. */ + readonly email: string | null; + /** Snapshot of the user's avatar URL at auth time — used for audit actors. */ + readonly image: string | null; +} + +/** + * Reads `Authorization: EvePrincipal `, verifies the HMAC-signed + * principal (60 s TTL), re-checks org membership, and attaches + * `{userId, orgId}` to `req.evePrincipal`. + * + * Security invariants (cross-tenant boundary): + * - 401 if header missing or wrong scheme. + * - 401 if token is forged or expired (`verifyEvePrincipal` throws). + * - 403 (`ForbiddenException`) if the principal's userId is NOT a member of + * the principal's orgId — prevents a valid token from being replayed + * against an org the user left. + */ +@Injectable() +export class EvePrincipalGuard implements CanActivate { + constructor(private readonly databaseService: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context + .switchToHttp() + .getRequest(); + + const authHeader = req.headers['authorization']; + if ( + typeof authHeader !== 'string' || + !authHeader.startsWith('EvePrincipal ') + ) { + throw new UnauthorizedException( + 'Missing or invalid Authorization header — expected: EvePrincipal ', + ); + } + + const token = authHeader.slice('EvePrincipal '.length); + + let userId: number; + let orgId: number; + try { + ({ userId, orgId } = verifyEvePrincipal(token)); + } catch { + // verifyEvePrincipal throws on bad signature or expired token — + // surface as 401, NOT 500, so the calling tool can handle it cleanly. + throw new UnauthorizedException('Invalid or expired eve principal token'); + } + + // Membership re-check — same query as EveProxyController (Phase 1). + // A valid token is not sufficient: the user must still belong to the org + // it names (e.g. they might have been removed since the token was issued). + // We fetch the user's profile in the same query so audit actors carry the + // real identity (name/email/image) without an extra round-trip. + const member = await this.databaseService.member.findFirst({ + where: { userId, organizationId: orgId }, + include: { + user: { select: { name: true, email: true, image: true } }, + }, + }); + + if (!member) { + throw new ForbiddenException('User does not belong to this organisation'); + } + + req.evePrincipal = { + userId, + orgId, + name: member.user.name, + email: member.user.email, + image: member.user.image, + }; + return true; + } +} diff --git a/apps/backend/src/eve/eve-proxy-headers.ts b/apps/backend/src/eve/eve-proxy-headers.ts new file mode 100644 index 00000000..4265130e --- /dev/null +++ b/apps/backend/src/eve/eve-proxy-headers.ts @@ -0,0 +1,23 @@ +import { signEvePrincipal } from '../utils/eve-principal'; + +const STRIP = new Set([ + 'cookie', + 'authorization', + 'host', + 'content-length', + 'connection', + 'x-eve-principal', +]); + +export function buildUpstreamHeaders( + incoming: Record, + principal: { userId: number; orgId: number }, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(incoming)) { + if (v === undefined || STRIP.has(k.toLowerCase())) continue; + out[k] = Array.isArray(v) ? v.join(',') : v; + } + out['x-eve-principal'] = signEvePrincipal(principal); + return out; +} diff --git a/apps/backend/src/eve/eve-proxy.controller.ts b/apps/backend/src/eve/eve-proxy.controller.ts new file mode 100644 index 00000000..eb94ac35 --- /dev/null +++ b/apps/backend/src/eve/eve-proxy.controller.ts @@ -0,0 +1,200 @@ +import { + All, + Controller, + ForbiddenException, + Inject, + Logger, + Req, + Res, +} from '@nestjs/common'; +import { AllowAnonymous } from '@thallesp/nestjs-better-auth'; +import type { + Request as ExpressRequest, + Response as ExpressResponse, +} from 'express'; +import { fromNodeHeaders } from 'better-auth/node'; +import { PrismaService } from '../prisma/prisma.service'; +import { EveTargetService } from './eve-target.service'; +import { resolveOrgId } from './resolve-org'; +import { buildUpstreamHeaders } from './eve-proxy-headers'; +import { auth } from '../libs/better-auth/auth'; + +export { buildUpstreamHeaders } from './eve-proxy-headers'; + +/** + * Catch-all reverse proxy for `/eve/v1/*`. + * + * Security invariants: + * - 401 if no Better Auth session (global AppAuthGuard would also fire, but + * we @AllowAnonymous() so we own the 401 response ourselves for clarity). + * - cookie / authorization NEVER forwarded to eve (stripped by buildUpstreamHeaders). + * - Identity forwarded as a short-lived HMAC-signed x-eve-principal token. + * - Org membership verified against DB before principal is signed (prevents + * cross-tenant IDOR: user cannot forge a principal for an org they don't + * belong to by supplying a different x-org-id header). + * + * Streaming: + * - Client disconnect → AbortController.abort() cancels the upstream fetch. + * - Upstream response body piped chunk-by-chunk without buffering (NDJSON + * `/stream` endpoints work live). + * - content-length dropped from upstream response so browsers don't complain + * about mismatches caused by hop-by-hop encoding differences. + * - set-cookie NEVER relayed from upstream to the browser (cross-tenant + * trust boundary — eve's cookies are not this client's to receive). + * + * Request body: + * - body-parser is bypassed for /eve/v1/* (see configureBodyParsing in + * body-parser.ts), so the Express req is still a raw readable stream. + * - The stream is buffered before the upstream fetch (see readRawBody) — + * bodies are small JSON because the eve channel disables uploads. + */ +@AllowAnonymous() +@Controller('eve/v1') +export class EveProxyController { + private readonly logger = new Logger(EveProxyController.name); + + constructor( + @Inject(EveTargetService) private readonly eve: EveTargetService, + @Inject(PrismaService) private readonly databaseService: PrismaService, + ) {} + + @All('*') + async proxy( + @Req() req: ExpressRequest, + @Res() res: ExpressResponse, + ): Promise { + // --- Auth (Pattern-2) --- + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + if (!session?.user?.id) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + + const userId = Number(session.user.id); + if (!Number.isFinite(userId)) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + const orgId = resolveOrgId(req); + + // --- Org membership check (replicates OrganisationGuard — same table/query) --- + // Prevent cross-tenant IDOR: the authenticated user MUST be a member of + // the requested org before we sign it into x-eve-principal. + const member = await this.databaseService.member.findFirst({ + where: { userId, organizationId: orgId }, + }); + if (!member) { + throw new ForbiddenException('User does not belong to this organisation'); + } + + // --- Upstream fetch with AbortController for client-disconnect propagation --- + const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const url = `${this.eve.baseUrl}${upstreamPath}`; + const controller = new AbortController(); + res.on('close', () => controller.abort()); + + const hasBody = !['GET', 'HEAD'].includes(req.method.toUpperCase()); + + // Recover the request body as a Buffer/string, wherever it ended up. + // Our configureBodyParsing bypasses /eve/v1/*, but + // @thallesp/nestjs-better-auth registers its OWN global express.json() + // (enabled by default), which consumes the stream after our bypass — + // streaming req into fetch then fails under undici with "Response body + // object should not be disturbed or locked", and a raw re-read yields + // an empty body (both verified against a live eve dev server; the + // exploration branch missed this because its tests mock fetch). So: + // prefer the saved rawBody, then the parsed req.body (re-serialized — + // safe, eve verifies the principal header, not body bytes), and only + // fall back to reading the stream. Buffering is fine: the eve channel + // disables uploads, so bodies are small JSON. Response-side NDJSON + // streaming is unaffected. + const body = hasBody ? await recoverRequestBody(req) : undefined; + + let upstream: Response; + try { + upstream = await fetch(url, { + method: req.method, + headers: buildUpstreamHeaders( + req.headers as Record, + { userId, orgId }, + ), + body: body as Buffer | string | undefined, + signal: controller.signal, + }); + } catch (err) { + if (controller.signal.aborted) { + // Client disconnected before upstream responded — nothing to write. + return; + } + this.logger.error(`eve proxy fetch error: ${String(err)}`); + if (!res.headersSent) { + res.status(502).json({ error: 'eve upstream unavailable' }); + } + return; + } + + // --- Forward response status + headers --- + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + // Drop content-length: the streaming pipe may deliver different byte + // counts than what the upstream declared (e.g. gzip decompression). + if (key.toLowerCase() === 'content-length') return; + // Drop set-cookie: a cross-tenant proxy must never relay an upstream + // cookie to the browser (trust-boundary hardening). + if (key.toLowerCase() === 'set-cookie') return; + res.setHeader(key, value); + }); + + // --- Stream body to client --- + if (!upstream.body) { + res.end(); + return; + } + + const reader = upstream.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + } catch (err) { + if (!controller.signal.aborted) { + this.logger.error(`eve proxy stream error: ${String(err)}`); + } + } finally { + res.end(); + } + } +} + +async function recoverRequestBody( + req: ExpressRequest, +): Promise { + const rawBody = (req as ExpressRequest & { rawBody?: Buffer }).rawBody; + if (rawBody?.length) return rawBody; + + const parsed = (req as ExpressRequest & { body?: unknown }).body; + if (Buffer.isBuffer(parsed) && parsed.length > 0) return parsed; + if (typeof parsed === 'string' && parsed.length > 0) return parsed; + if ( + parsed !== undefined && + parsed !== null && + typeof parsed === 'object' && + Object.keys(parsed).length > 0 + ) { + return JSON.stringify(parsed); + } + + // Stream untouched (no middleware consumed it) — read it ourselves. + if (!req.readableDidRead) { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks); + } + return undefined; +} diff --git a/apps/backend/src/eve/eve-target.service.ts b/apps/backend/src/eve/eve-target.service.ts new file mode 100644 index 00000000..2c59f2e3 --- /dev/null +++ b/apps/backend/src/eve/eve-target.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Resolves the ai-agent (eve) upstream for the reverse proxy. + * + * Eve runs as a SEPARATE deployment (apps/ai-agent): in prod `EVE_BASE_URL` + * points at its Fly private address (e.g. http://zuko-ai-agent.flycast), + * in dev at a locally running `eve dev` server. This replaces the + * exploration branch's in-process EveSupervisorService — spawning eve as a + * backend child inherited DATABASE_URL and let eve's workflow worker steal + * backend chat jobs from the shared queue (zuko-6msv). + */ +@Injectable() +export class EveTargetService { + readonly baseUrl = process.env['EVE_BASE_URL'] ?? 'http://127.0.0.1:2100'; +} diff --git a/apps/backend/src/eve/eve.module.ts b/apps/backend/src/eve/eve.module.ts new file mode 100644 index 00000000..cfd511a8 --- /dev/null +++ b/apps/backend/src/eve/eve.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AccountsService } from '../accounts/accounts.service'; +import { EveCredentialsController } from './eve-credentials.controller'; +import { EveProxyController } from './eve-proxy.controller'; +import { EvePrincipalGuard } from './eve-principal.guard'; +import { EveTargetService } from './eve-target.service'; +import { EveChatsController } from './eve-chats.controller'; +import { EveChatsRepository } from './eve-chats.repository'; + +@Module({ + imports: [PrismaModule], + controllers: [EveCredentialsController, EveProxyController, EveChatsController], + providers: [EvePrincipalGuard, EveTargetService, EveChatsRepository, AccountsService], +}) +export class EveModule {} diff --git a/apps/backend/src/eve/resolve-org.ts b/apps/backend/src/eve/resolve-org.ts new file mode 100644 index 00000000..e6779f77 --- /dev/null +++ b/apps/backend/src/eve/resolve-org.ts @@ -0,0 +1,22 @@ +import { BadRequestException } from '@nestjs/common'; +import type { Request } from 'express'; + +/** + * Derive the orgId for an eve proxy request. + * + * The canonical pattern in this codebase is the `x-org-id` header + * (set by the frontend for every org-scoped request, validated by + * OrganisationGuard). We read that here directly — no DB round-trip, + * no Better-Auth-org plugin dependency. If the header is absent or + * invalid we throw a BadRequestException (400), matching the behavior + * of OrganisationGuard, rather than letting an unhandled Error surface + * as a 500. + */ +export function resolveOrgId(req: Request): number { + const header = req.headers['x-org-id']; + const raw = Array.isArray(header) ? header[0] : header; + if (raw && /^\d+$/.test(raw)) return Number(raw); + throw new BadRequestException( + 'eve proxy: could not resolve orgId — x-org-id header missing or invalid', + ); +} diff --git a/apps/backend/src/utils/eve-principal.ts b/apps/backend/src/utils/eve-principal.ts new file mode 100644 index 00000000..02d88f3c --- /dev/null +++ b/apps/backend/src/utils/eve-principal.ts @@ -0,0 +1,50 @@ +// NOTE: keep byte-identical (logic) with apps/ai-agent/agent/lib/eve-principal.ts — duplicated across the eve/backend build boundary (eve bundles separately and cannot import backend code). +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export interface EvePrincipal { + readonly userId: number; + readonly orgId: number; +} + +const MAX_PRINCIPAL_AGE_MS = 60_000; + +function secret(): string { + const s = process.env['BETTER_AUTH_SECRET']; + if (!s) throw new Error('eve-principal: BETTER_AUTH_SECRET is required'); + return s; +} + +function hmac(payloadB64: string): string { + return createHmac('sha256', secret()).update(payloadB64).digest('hex'); +} + +export function signEvePrincipal(p: EvePrincipal): string { + const payloadB64 = Buffer.from( + JSON.stringify({ userId: p.userId, orgId: p.orgId, iat: Date.now() }), + ).toString('base64url'); + return `${payloadB64}.${hmac(payloadB64)}`; +} + +export function verifyEvePrincipal(token: string): EvePrincipal { + const dotIdx = token.indexOf('.'); + if (dotIdx === -1 || token.indexOf('.', dotIdx + 1) !== -1) + throw new Error('eve-principal: malformed token'); + const b64 = token.slice(0, dotIdx); + const sig = token.slice(dotIdx + 1); + const expected = hmac(b64); + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new Error('eve-principal: invalid signature'); + } + const { userId, orgId, iat } = JSON.parse( + Buffer.from(b64, 'base64url').toString(), + ); + if (typeof userId !== 'number' || typeof orgId !== 'number') { + throw new Error('eve-principal: invalid payload'); + } + if (typeof iat !== 'number' || Date.now() - iat > MAX_PRINCIPAL_AGE_MS) { + throw new Error('eve-principal: expired token'); + } + return { userId, orgId }; +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 3967b590..51bafa30 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,5 +1,4 @@ import type { NextConfig } from 'next'; -import { withEve } from 'eve/next'; const nextConfig: NextConfig = { // shiki gets auto-externalized by Turbopack with a broken hashed specifier @@ -20,4 +19,4 @@ const nextConfig: NextConfig = { }, }; -export default withEve(nextConfig, { eveRoot: '../ai-agent' }); +export default nextConfig; diff --git a/apps/web/src/app/(app)/chat/[chatId]/page.tsx b/apps/web/src/app/(app)/chat/[chatId]/page.tsx index 5029f3d3..3096c011 100644 --- a/apps/web/src/app/(app)/chat/[chatId]/page.tsx +++ b/apps/web/src/app/(app)/chat/[chatId]/page.tsx @@ -1,383 +1,51 @@ 'use client'; -import { useEveAgent } from 'eve/react'; -import type { EveMessage, EveMessagePart } from 'eve/react'; -import { - Conversation, - ConversationContent, - Message, - MessageContent, - MessageResponse, - TooltipProvider, -} from '@zuko/ui-kit'; -import { type ChatEntity } from '@/components/Chat/ChatContextManager'; -import { ChatInput } from '@/components/Chat/ChatInput'; -import { - Tool, - ToolContent, - ToolHeader, - ToolOutput, -} from '@/components/Chat/Tool'; -import { useState, useCallback, useEffect, useRef } from 'react'; import { useParams } from 'next/navigation'; -import { useInvalidateChats } from '@/hooks/use-chats'; -import { contactsApi } from '@/lib/api/contacts'; -import { companiesApi } from '@/lib/api/companies'; -import { dealsApi } from '@/lib/api/deals'; -import { CHAT_ENTITY_TYPE_LABEL } from '@/lib/constants'; +import { useQuery } from '@tanstack/react-query'; +import { ChatSurface } from '@/components/Chat/ChatSurface'; +import { fetchChatHistory } from '@/components/Chat/fetchChatHistory'; + +interface EveChatRow { + sessionId: string; + title: string | null; + continuationToken: string | null; + streamIndex: number | null; +} export default function ChatPage() { const params = useParams(); - const chatId = params.chatId as string; - const invalidateChats = useInvalidateChats(); - - const [historyMessages, setHistoryMessages] = useState([]); - const [messagesLoaded, setMessagesLoaded] = useState(false); - const [firstMessageSent, setFirstMessageSent] = useState(false); - const [initialContext, setInitialContext] = useState([]); - const [_chatContext, setChatContext] = useState([]); - - const persistedCountRef = useRef(0); - - const agent = useEveAgent({ - onFinish: useCallback( - (snapshot: { data: { messages: readonly EveMessage[] } }) => { - const msgs = snapshot.data.messages; - if (msgs.length <= persistedCountRef.current) return; - - const lastUser = [...msgs].reverse().find((m) => m.role === 'user'); - const lastAssistant = [...msgs] - .reverse() - .find((m) => m.role === 'assistant'); - if (!lastUser || !lastAssistant) return; - - const userText = lastUser.parts - .filter((p) => p.type === 'text') - .map((p) => (p as { type: 'text'; text: string }).text) - .join(''); - const assistantText = lastAssistant.parts - .filter((p) => p.type === 'text') - .map((p) => (p as { type: 'text'; text: string }).text) - .join(''); - - persistedCountRef.current = msgs.length; - - if (userText.trim() && assistantText.trim()) { - fetch(`/api/proxy/api/chats/${chatId}/messages`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - userMessage: userText, - assistantMessage: assistantText, - }), - }).catch(() => {}); - } - }, - [chatId], - ), - }); - - const isBusy = agent.status === 'submitted' || agent.status === 'streaming'; - const liveMessages = agent.data.messages; - const allMessages = [...historyMessages, ...liveMessages]; - const hasMessages = allMessages.length > 0; - - const handleFirstMessage = useCallback( - async (data: string) => { - localStorage.removeItem(`chat-${chatId}-firstMessage`); - try { - let messageText: string; - let contextEntities: Array<{ type: string; id: number }> = []; - - try { - const parsed = JSON.parse(data) as { - text: string; - contextEntities?: Array<{ type: string; id: number }>; - }; - messageText = parsed.text; - contextEntities = parsed.contextEntities ?? []; - } catch { - messageText = data; - } - - if (contextEntities.length > 0) { - const hydrated: ChatEntity[] = await Promise.all( - contextEntities.map(async (ref): Promise => { - const type = ref.type as 'contact' | 'company' | 'deal'; - try { - if (type === 'contact') { - const c = await contactsApi.getContact(ref.id); - return { - type: 'contact', - id: ref.id, - name: c.name, - metadata: { type: 'contact', entityId: ref.id }, - }; - } - if (type === 'company') { - const c = await companiesApi.getCompany(ref.id); - return { - type: 'company', - id: ref.id, - name: c.companyName, - metadata: { type: 'company', entityId: ref.id }, - }; - } - const d = await dealsApi.getDeal(ref.id); - return { - type: 'deal', - id: ref.id, - name: d.title, - metadata: { type: 'deal', entityId: ref.id }, - }; - } catch { - return { - type, - id: ref.id, - name: CHAT_ENTITY_TYPE_LABEL[type], - metadata: { type, entityId: ref.id }, - }; - } - }), - ); - setInitialContext(hydrated); - } - - const contextPrefix = contextEntities.length - ? `Context:\n${contextEntities.map((e) => `- ${e.type} id=${e.id}`).join('\n')}\n\n` - : ''; - - await agent.send({ message: contextPrefix + messageText }); - setFirstMessageSent(true); - setMessagesLoaded(true); - setTimeout(() => invalidateChats(), 2000); - } catch (error) { - console.error('[ChatPage] Error parsing first message:', error); - setMessagesLoaded(true); - } + const sessionId = params.chatId as string; + + const { data, isLoading, isError } = useQuery({ + queryKey: ['chat-session', sessionId], + enabled: !!sessionId, + queryFn: async () => { + const res = await fetch(`/api/chats/${sessionId}`); + if (!res.ok) throw new Error('not found'); + const row = (await res.json()) as EveChatRow; + const history = await fetchChatHistory(sessionId); + return { row, history }; }, - [chatId, agent, invalidateChats], - ); - - const loadMessageHistory = useCallback(async () => { - try { - const res = await fetch(`/api/proxy/api/chats/${chatId}/messages`, { - credentials: 'include', - }); - if (res.ok) { - const data = (await res.json()) as { - messages?: Array<{ - id?: string; - role: string; - content?: string; - parts?: EveMessagePart[]; - }>; - contextEntities?: Array<{ type: string; id: number; name: string }>; - }; - const rows = data.messages ?? []; - const contextRefs = data.contextEntities ?? []; - - const formatted: EveMessage[] = rows.map((msg, index) => ({ - id: msg.id ?? `msg-${index}`, - role: msg.role as 'user' | 'assistant', - parts: Array.isArray(msg.parts) - ? (msg.parts as EveMessagePart[]) - : [{ type: 'text' as const, text: msg.content ?? '' }], - metadata: undefined, - })); - - if (formatted.length > 0) setHistoryMessages(formatted); - - const hydratedEntities: ChatEntity[] = contextRefs.map((ref) => ({ - type: ref.type as 'contact' | 'company' | 'deal', - id: ref.id, - name: ref.name, - metadata: { type: ref.type, entityId: ref.id }, - })); - if (hydratedEntities.length > 0) setInitialContext(hydratedEntities); - } - } catch (error) { - console.error('[ChatPage] Error loading messages:', error); - } finally { - setMessagesLoaded(true); - } - }, [chatId]); - - useEffect(() => { - if (!messagesLoaded && !firstMessageSent && chatId) { - const firstMessageData = localStorage.getItem( - `chat-${chatId}-firstMessage`, - ); - if (firstMessageData) { - handleFirstMessage(firstMessageData); - } else { - loadMessageHistory(); - } - } - }, [ - chatId, - messagesLoaded, - firstMessageSent, - handleFirstMessage, - loadMessageHistory, - ]); - - const handleSubmitMessage = async (msg: { - text: string; - files?: unknown[]; - metadata?: { contextEntities?: Array<{ type: string; id: number }> }; - }) => { - if (!msg.text.trim() || isBusy) return; - - const contextEntities = msg.metadata?.contextEntities ?? []; - const contextPrefix = contextEntities.length - ? `Context:\n${contextEntities.map((e) => `- ${e.type} id=${e.id}`).join('\n')}\n\n` - : ''; - - const isFirst = historyMessages.length === 0 && liveMessages.length === 0; - await agent.send({ message: contextPrefix + msg.text }); - if (isFirst) setTimeout(() => invalidateChats(), 2000); - }; - - return ( - -
- {hasMessages ? ( - <> -
- - - {allMessages.map((message, index) => ( - - ))} - - -
-
-
- -
-
- - ) : ( -
-
-

- Start a conversation -

-

- Ask me anything to get started -

-
-
- -
-
- )} -
-
- ); -} + }); -function EveMessageView({ - message, - isStreaming, -}: { - message: EveMessage; - isStreaming: boolean; -}) { - const lastTextIndex = message.parts.reduce( - (last, part, i) => (part.type === 'text' ? i : last), - -1, - ); + if (isError) + return ( +

Session not found.

+ ); + if (isLoading || !data) + return

Loading…

; return ( - - - {message.parts.map((part, i) => ( - - ))} - - + ); } - -function EvePartView({ - part, - showCaret, -}: { - part: EveMessagePart; - showCaret: boolean; -}) { - switch (part.type) { - case 'step-start': - return null; - case 'text': - return ( - - {(part as { type: 'text'; text: string }).text + - (showCaret ? '▋' : '')} - - ); - case 'dynamic-tool': { - const toolPart = part as { - type: 'dynamic-tool'; - toolName: string; - state: string; - input?: unknown; - output?: string; - errorText?: string; - }; - const sdkState = toolPart.errorText - ? 'output-error' - : toolPart.output - ? 'output-available' - : 'input-streaming'; - return ( - - - - - - - ); - } - default: - return null; - } -} diff --git a/apps/web/src/app/(app)/chat/page.tsx b/apps/web/src/app/(app)/chat/page.tsx index c9269485..860a7d47 100644 --- a/apps/web/src/app/(app)/chat/page.tsx +++ b/apps/web/src/app/(app)/chat/page.tsx @@ -1,86 +1,7 @@ -/** - * New chat page - uses unified ChatInput component - */ - 'use client'; -import { TooltipProvider } from '@zuko/ui-kit'; -import { type ChatEntity } from '@/components/Chat/ChatContextManager'; -import { ChatInput } from '@/components/Chat/ChatInput'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { ChatSurface } from '@/components/Chat/ChatSurface'; export default function NewChatPage() { - const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [_chatContext, setChatContext] = useState([]); - - const handleSubmitMessage = async (msg: { - text: string; - files?: any[]; - metadata?: any; - }) => { - if (!msg.text.trim() || isSubmitting) { - return; - } - - setIsSubmitting(true); - - try { - // Step 1: Create the chat - const createResponse = await fetch('/api/proxy/api/chats', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!createResponse.ok) { - throw new Error('Failed to create chat'); - } - - const chat = await createResponse.json(); - const chatId = chat.id; - - // Step 2: Store the first message AND context entities in localStorage for the chat page to pick up - const firstMessageData = { - text: msg.text, - contextEntities: msg.metadata?.contextEntities || [], - }; - localStorage.setItem( - `chat-${chatId}-firstMessage`, - JSON.stringify(firstMessageData), - ); - - // Step 3: Redirect to the new chat - router.replace(`/chat/${chatId}`); - } catch (error) { - console.error('[NewChatPage] Error creating chat:', error); - setIsSubmitting(false); - throw error; // Re-throw so PromptInput doesn't clear the input on error - } - }; - - return ( - -
-
-

- Start a conversation -

-

- Ask me anything to get started -

-
-
- -
-
-
- ); + return ; } diff --git a/apps/web/src/app/api/chats/[sessionId]/route.ts b/apps/web/src/app/api/chats/[sessionId]/route.ts new file mode 100644 index 00000000..1d10f8e3 --- /dev/null +++ b/apps/web/src/app/api/chats/[sessionId]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { cookies, headers } from 'next/headers'; + +const BACKEND_URL = + process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await params; + try { + const cookieHeader = (await headers()).get('cookie') ?? ''; + const cookieStore = await cookies(); + const orgId = cookieStore.get('x-organization-id')?.value ?? ''; + const res = await fetch( + `${BACKEND_URL}/api/eve-chats/${encodeURIComponent(sessionId)}`, + { + headers: { cookie: cookieHeader, 'x-organization-id': orgId }, + cache: 'no-store', + }, + ); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); + } catch (error) { + const s = error instanceof Error ? 500 : 500; + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to load session' }, + { status: s }, + ); + } +} diff --git a/apps/web/src/app/api/chats/route.ts b/apps/web/src/app/api/chats/route.ts new file mode 100644 index 00000000..507c5e09 --- /dev/null +++ b/apps/web/src/app/api/chats/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { cookies, headers } from 'next/headers'; + +const BACKEND_URL = + process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001'; + +async function backendHeaders() { + const cookieHeader = (await headers()).get('cookie') ?? ''; + const cookieStore = await cookies(); + const orgId = cookieStore.get('x-organization-id')?.value ?? ''; + return { cookie: cookieHeader, 'x-organization-id': orgId }; +} + +export async function GET() { + try { + const hdrs = await backendHeaders(); + const res = await fetch(`${BACKEND_URL}/api/eve-chats`, { + headers: hdrs, + cache: 'no-store', + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to list sessions' }, + { status: 500 }, + ); + } +} + +export async function POST(request: Request) { + try { + const hdrs = await backendHeaders(); + const body = await request.json().catch(() => ({})); + const res = await fetch(`${BACKEND_URL}/api/eve-chats`, { + method: 'POST', + headers: { ...hdrs, 'content-type': 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to save session' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/eve/v1/[...path]/route.ts b/apps/web/src/app/api/eve/v1/[...path]/route.ts new file mode 100644 index 00000000..cc21ed22 --- /dev/null +++ b/apps/web/src/app/api/eve/v1/[...path]/route.ts @@ -0,0 +1,83 @@ +import { cookies, headers } from 'next/headers'; + +const BACKEND_URL = + process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001'; + +interface BetterAuthSession { + user: { id: string }; + session: { activeOrganizationId?: string }; +} + +/** + * Same-origin catch-all proxy for every eve client call (`/api/eve/v1/*`): + * session create/continue, the NDJSON stream, info, health. + * + * Forwards session cookie so the backend's EveProxyController owns auth + * (validates the session, resolves org membership, signs x-eve-principal). + * The response body — including the live NDJSON stream — is piped through + * unbuffered. + * + * Org resolution: better-auth stores activeOrganizationId on the session + * record (not a separate cookie), so we fetch the session once per request + * to derive x-org-id for the backend's resolveOrgId(). + */ +async function proxy(req: Request, path: string[]): Promise { + const cookieHeader = (await headers()).get('cookie') ?? ''; + const cookieStore = await cookies(); + + // Resolve active org from session — backend needs x-org-id header. + let orgId = ''; + try { + const sessionRes = await fetch(`${BACKEND_URL}/auth/get-session`, { + headers: { cookie: cookieHeader }, + cache: 'no-store', + }); + if (sessionRes.ok) { + const data = (await sessionRes.json()) as BetterAuthSession | null; + orgId = data?.session?.activeOrganizationId ?? ''; + } + } catch { + // non-fatal — backend will 400 if x-org-id missing + } + + const { search } = new URL(req.url); + const target = `${BACKEND_URL}/api/eve/v1/${path.map(encodeURIComponent).join('/')}${search}`; + + const init: RequestInit = { + method: req.method, + headers: { + cookie: cookieHeader, + 'x-org-id': orgId, + ...(req.method === 'POST' ? { 'content-type': 'application/json' } : {}), + }, + }; + if (req.method === 'POST') init.body = await req.text(); + + const upstream = await fetch(target, init); + + return new Response(upstream.body, { + status: upstream.status, + headers: { + 'content-type': + upstream.headers.get('content-type') ?? 'application/json', + 'cache-control': 'no-cache, no-transform', + 'x-accel-buffering': 'no', + }, + }); +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + return proxy(req, path); +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + return proxy(req, path); +} diff --git a/apps/web/src/components/Chat/ChatSurface.tsx b/apps/web/src/components/Chat/ChatSurface.tsx new file mode 100644 index 00000000..2e92f2fb --- /dev/null +++ b/apps/web/src/components/Chat/ChatSurface.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { useEveAgent } from 'eve/react'; +import type { EveMessage, EveMessagePart, HandleMessageStreamEvent } from 'eve/react'; +import { + Conversation, + ConversationContent, + Message, + MessageContent, + MessageResponse, + TooltipProvider, +} from '@zuko/ui-kit'; +import { ChatInput } from './ChatInput'; +import { + Tool, + ToolContent, + ToolHeader, + ToolOutput, +} from './Tool'; + +interface ChatSurfaceProps { + /** True on /chat/:id resume — suppresses row creation. */ + resume?: boolean; + initialSession?: { + sessionId: string; + streamIndex: number; + continuationToken?: string; + }; + initialEvents?: readonly HandleMessageStreamEvent[]; +} + +/** + * Eve chat surface shared by /chat (new) and /chat/:id (resume). Owns + * useEveAgent. On the FIRST session of a new chat it records a thin EveChat row + * (POST /api/chats) and swaps the URL to /chat/:sessionId via + * history.replaceState — no Next remount, live turn uninterrupted. + */ +export function ChatSurface({ resume, initialSession, initialEvents }: ChatSurfaceProps) { + const navigatedRef = useRef(!!resume); + const firstMessageRef = useRef(''); + const lastTokenRef = useRef(initialSession?.continuationToken); + + const persist = useCallback( + async ( + s: { sessionId?: string; continuationToken?: string; streamIndex: number }, + navigate: boolean, + ) => { + try { + await fetch('/api/chats', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sessionId: s.sessionId, + title: firstMessageRef.current.slice(0, 80), + continuationToken: s.continuationToken, + streamIndex: s.streamIndex, + }), + }); + if (navigate) + window.history.replaceState(null, '', `/chat/${s.sessionId}`); + } catch { + if (navigate) navigatedRef.current = false; + lastTokenRef.current = undefined; + } + }, + [], + ); + + const { data, status, error, send } = useEveAgent({ + host: '/api', + ...(initialSession ? { initialSession } : {}), + ...(initialEvents ? { initialEvents } : {}), + onSessionChange: (s) => { + if (!s.sessionId) return; + const firstTime = !navigatedRef.current; + const tokenChanged = + !!s.continuationToken && s.continuationToken !== lastTokenRef.current; + if (!firstTime && !tokenChanged) return; + if (firstTime) navigatedRef.current = true; + if (s.continuationToken) lastTokenRef.current = s.continuationToken; + void persist(s, firstTime); + }, + }); + + const [input, setInput] = useState(''); + const working = status === 'submitted' || status === 'streaming'; + const hasMessages = data.messages.length > 0; + + async function handleSubmit(msg: { text: string }) { + const message = msg.text.trim(); + if (!message || working) return; + if (!firstMessageRef.current) firstMessageRef.current = message; + await send({ message }).catch(() => {}); + } + + return ( + +
+ {hasMessages ? ( + <> +
+ + + {data.messages.map((message, index) => ( + + ))} + + +
+
+
+ +
+
+ + ) : ( +
+
+

+ Start a conversation +

+

+ Ask me anything to get started +

+
+
+ +
+
+ )} + + {status === 'error' && ( +

+ {error?.message ?? 'The agent failed to complete this turn.'} +

+ )} +
+
+ ); +} + +function ChatMessageView({ + message, + isStreaming, +}: { + message: EveMessage; + isStreaming: boolean; +}) { + const lastTextIndex = message.parts.reduce( + (last, part, i) => (part.type === 'text' ? i : last), + -1, + ); + return ( + + + {message.parts.map((part, i) => ( + + ))} + + + ); +} + +function ChatPartView({ part, showCaret }: { part: EveMessagePart; showCaret: boolean }) { + switch (part.type) { + case 'step-start': + return null; + case 'text': + return ( + + {(part as { type: 'text'; text: string }).text + (showCaret ? '▋' : '')} + + ); + case 'dynamic-tool': { + const toolPart = part as { + type: 'dynamic-tool'; + toolName: string; + state: string; + input?: unknown; + output?: string; + errorText?: string; + }; + const sdkState = toolPart.errorText + ? 'output-error' + : toolPart.output + ? 'output-available' + : 'input-streaming'; + return ( + + + + + + + ); + } + default: + return null; + } +} diff --git a/apps/web/src/components/Chat/ChatsList.tsx b/apps/web/src/components/Chat/ChatsList.tsx index f60a9d54..b4e66b5d 100644 --- a/apps/web/src/components/Chat/ChatsList.tsx +++ b/apps/web/src/components/Chat/ChatsList.tsx @@ -2,38 +2,43 @@ import { useMemo } from 'react'; import { useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline'; import { Button, Link } from '@zuko/ui-kit'; import { PageHeader } from '@/components/shared'; -import { useChats } from '@/hooks/use-chats'; import { BaseTable } from '@/components/Table'; import type { ColumnDef } from '@tanstack/react-table'; -interface Chat { - id: number; - title: string; +interface EveSession { + sessionId: string; + title: string | null; createdAt: string; - updatedAt: string; } -function formatUpdatedAt(date: string): string { +function formatDate(date: string): string { const d = new Date(date); const diffDays = Math.floor((Date.now() - d.getTime()) / 86400000); if (diffDays === 0) return 'Today'; if (diffDays === 1) return 'Yesterday'; if (diffDays < 7) return `${diffDays} days ago`; - return d.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } export function ChatsList() { const router = useRouter(); - const { data: chats = [], isLoading } = useChats(); - const columns = useMemo[]>( + const { data, isLoading } = useQuery({ + queryKey: ['eve-chats'], + queryFn: async () => { + const res = await fetch('/api/chats'); + if (!res.ok) return { sessions: [] as EveSession[] }; + return (await res.json()) as { sessions: EveSession[] }; + }, + }); + + const sessions = data?.sessions ?? []; + + const columns = useMemo[]>( () => [ { id: 'title', @@ -42,22 +47,22 @@ export function ChatsList() { cell: ({ row }) => (
e.stopPropagation()} > - {row.original.title || 'Untitled chat'} + {row.original.title?.trim() || 'Untitled chat'}
), }, { - id: 'updatedAt', - header: 'Last updated', - accessorKey: 'updatedAt', + id: 'createdAt', + header: 'Started', + accessorKey: 'createdAt', cell: ({ row }) => ( - {formatUpdatedAt(row.original.updatedAt)} + {formatDate(row.original.createdAt)} ), }, @@ -73,11 +78,11 @@ export function ChatsList() { action={} /> - + columns={columns} - data={chats} + data={sessions} loading={isLoading} - onRowClick={(chat) => router.push(`/chat/${chat.id}`)} + onRowClick={(s) => router.push(`/chat/${s.sessionId}`)} entityName="chats" showAddColumn={false} showEmptyState diff --git a/apps/web/src/components/Chat/fetchChatHistory.ts b/apps/web/src/components/Chat/fetchChatHistory.ts new file mode 100644 index 00000000..fd4a5df5 --- /dev/null +++ b/apps/web/src/components/Chat/fetchChatHistory.ts @@ -0,0 +1,54 @@ +import type { HandleMessageStreamEvent } from 'eve/client'; + +/** + * Replay a past eve session's event log so `/chat/:id` can render the + * conversation on reopen. + * + * Reads the stream incrementally — historical events arrive in a burst, then + * the stream goes quiet waiting for new turns. We stop once no bytes arrive + * for IDLE_MS. The collected events feed `useEveAgent`'s `initialEvents` + * (reduced into `data.messages`), and `streamIndex` positions the cursor to + * continue after them. + */ +const IDLE_MS = 1500; + +export interface ChatHistory { + events: HandleMessageStreamEvent[]; + streamIndex: number; +} + +export async function fetchChatHistory(sessionId: string): Promise { + const res = await fetch( + `/api/eve/v1/session/${encodeURIComponent(sessionId)}/stream?startIndex=0`, + ); + if (!res.ok || !res.body) return { events: [], streamIndex: 0 }; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const events: HandleMessageStreamEvent[] = []; + let buffer = ''; + + try { + for (;;) { + const result = await Promise.race([ + reader.read(), + new Promise<'idle'>((resolve) => + setTimeout(() => resolve('idle'), IDLE_MS), + ), + ]); + if (result === 'idle') break; + if (result.done) break; + buffer += decoder.decode(result.value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (line.trim()) + events.push(JSON.parse(line) as HandleMessageStreamEvent); + } + } + } finally { + await reader.cancel().catch(() => {}); + } + + return { events, streamIndex: events.length }; +} diff --git a/libs/models/prisma/core.prisma b/libs/models/prisma/core.prisma index 9a3c91fc..8768e62a 100644 --- a/libs/models/prisma/core.prisma +++ b/libs/models/prisma/core.prisma @@ -26,6 +26,7 @@ model User { dealOwners DealOwner[] activities Activity[] createdChats Chat[] @relation("ChatCreator") + eveChats EveChat[] @relation("UserEveChats") chatParticipations ChatParticipant[] members Member[] invitations Invitation[] @@ -201,12 +202,31 @@ model Organization { icpProfiles IcpProfile[] @relation("OrganizationIcpProfiles") campaigns Campaign[] @relation("OrganizationCampaigns") pages Page[] @relation("OrganizationPages") + eveChats EveChat[] @@unique([slug]) @@map("organization") @@schema("public") } +model EveChat { + sessionId String @id @map("session_id") + title String? + createdById Int @map("created_by_id") + organizationId Int @map("organization_id") + continuationToken String? @map("continuation_token") + streamIndex Int @default(0) @map("stream_index") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + createdBy User @relation("UserEveChats", fields: [createdById], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([createdById, organizationId]) + @@map("eve_chat") + @@schema("public") +} + model Connection { id Int @id @default(autoincrement()) organizationId Int @map("organization_id") diff --git a/libs/models/prisma/migrations/20260716000000_add_eve_chat/migration.sql b/libs/models/prisma/migrations/20260716000000_add_eve_chat/migration.sql new file mode 100644 index 00000000..908451ea --- /dev/null +++ b/libs/models/prisma/migrations/20260716000000_add_eve_chat/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "public"."eve_chat" ( + "session_id" TEXT NOT NULL, + "title" TEXT, + "created_by_id" INTEGER NOT NULL, + "organization_id" INTEGER NOT NULL, + "continuation_token" TEXT, + "stream_index" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "eve_chat_pkey" PRIMARY KEY ("session_id") +); + +-- CreateIndex +CREATE INDEX "eve_chat_created_by_id_organization_id_idx" ON "public"."eve_chat"("created_by_id", "organization_id"); + +-- AddForeignKey +ALTER TABLE "public"."eve_chat" ADD CONSTRAINT "eve_chat_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."eve_chat" ADD CONSTRAINT "eve_chat_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 13f583cdbed2ba4f7ff675a4f6947daf29af8e40 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:44:25 +0530 Subject: [PATCH 02/19] style: format eve files for line length consistency Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/eve/eve-chats.controller.ts | 6 +- apps/backend/src/eve/eve.module.ts | 13 +++- .../src/app/api/chats/[sessionId]/route.ts | 5 +- apps/web/src/app/api/chats/route.ts | 10 ++- apps/web/src/components/Chat/ChatSurface.tsx | 63 ++++++++++++++----- apps/web/src/components/Chat/ChatsList.tsx | 6 +- .../src/components/Chat/fetchChatHistory.ts | 4 +- 7 files changed, 82 insertions(+), 25 deletions(-) diff --git a/apps/backend/src/eve/eve-chats.controller.ts b/apps/backend/src/eve/eve-chats.controller.ts index 7c81fdbb..1bcda7d9 100644 --- a/apps/backend/src/eve/eve-chats.controller.ts +++ b/apps/backend/src/eve/eve-chats.controller.ts @@ -78,7 +78,11 @@ export class EveChatsController { @Param('sessionId') sessionId: string, ) { const { userId, organizationId } = resolveIds(req); - const row = await this.repo.getBySessionId(sessionId, userId, organizationId); + const row = await this.repo.getBySessionId( + sessionId, + userId, + organizationId, + ); if (!row) throw new NotFoundException('Session not found'); return row; } diff --git a/apps/backend/src/eve/eve.module.ts b/apps/backend/src/eve/eve.module.ts index cfd511a8..4f5e0742 100644 --- a/apps/backend/src/eve/eve.module.ts +++ b/apps/backend/src/eve/eve.module.ts @@ -10,7 +10,16 @@ import { EveChatsRepository } from './eve-chats.repository'; @Module({ imports: [PrismaModule], - controllers: [EveCredentialsController, EveProxyController, EveChatsController], - providers: [EvePrincipalGuard, EveTargetService, EveChatsRepository, AccountsService], + controllers: [ + EveCredentialsController, + EveProxyController, + EveChatsController, + ], + providers: [ + EvePrincipalGuard, + EveTargetService, + EveChatsRepository, + AccountsService, + ], }) export class EveModule {} diff --git a/apps/web/src/app/api/chats/[sessionId]/route.ts b/apps/web/src/app/api/chats/[sessionId]/route.ts index 1d10f8e3..76b59b0f 100644 --- a/apps/web/src/app/api/chats/[sessionId]/route.ts +++ b/apps/web/src/app/api/chats/[sessionId]/route.ts @@ -25,7 +25,10 @@ export async function GET( } catch (error) { const s = error instanceof Error ? 500 : 500; return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to load session' }, + { + error: + error instanceof Error ? error.message : 'Failed to load session', + }, { status: s }, ); } diff --git a/apps/web/src/app/api/chats/route.ts b/apps/web/src/app/api/chats/route.ts index 507c5e09..86dcfce5 100644 --- a/apps/web/src/app/api/chats/route.ts +++ b/apps/web/src/app/api/chats/route.ts @@ -22,7 +22,10 @@ export async function GET() { return NextResponse.json(data, { status: res.status }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to list sessions' }, + { + error: + error instanceof Error ? error.message : 'Failed to list sessions', + }, { status: 500 }, ); } @@ -42,7 +45,10 @@ export async function POST(request: Request) { return NextResponse.json(data, { status: res.status }); } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to save session' }, + { + error: + error instanceof Error ? error.message : 'Failed to save session', + }, { status: 500 }, ); } diff --git a/apps/web/src/components/Chat/ChatSurface.tsx b/apps/web/src/components/Chat/ChatSurface.tsx index 2e92f2fb..3db557f1 100644 --- a/apps/web/src/components/Chat/ChatSurface.tsx +++ b/apps/web/src/components/Chat/ChatSurface.tsx @@ -2,7 +2,11 @@ import { useCallback, useRef, useState } from 'react'; import { useEveAgent } from 'eve/react'; -import type { EveMessage, EveMessagePart, HandleMessageStreamEvent } from 'eve/react'; +import type { + EveMessage, + EveMessagePart, + HandleMessageStreamEvent, +} from 'eve/react'; import { Conversation, ConversationContent, @@ -12,12 +16,7 @@ import { TooltipProvider, } from '@zuko/ui-kit'; import { ChatInput } from './ChatInput'; -import { - Tool, - ToolContent, - ToolHeader, - ToolOutput, -} from './Tool'; +import { Tool, ToolContent, ToolHeader, ToolOutput } from './Tool'; interface ChatSurfaceProps { /** True on /chat/:id resume — suppresses row creation. */ @@ -36,14 +35,24 @@ interface ChatSurfaceProps { * (POST /api/chats) and swaps the URL to /chat/:sessionId via * history.replaceState — no Next remount, live turn uninterrupted. */ -export function ChatSurface({ resume, initialSession, initialEvents }: ChatSurfaceProps) { +export function ChatSurface({ + resume, + initialSession, + initialEvents, +}: ChatSurfaceProps) { const navigatedRef = useRef(!!resume); const firstMessageRef = useRef(''); - const lastTokenRef = useRef(initialSession?.continuationToken); + const lastTokenRef = useRef( + initialSession?.continuationToken, + ); const persist = useCallback( async ( - s: { sessionId?: string; continuationToken?: string; streamIndex: number }, + s: { + sessionId?: string; + continuationToken?: string; + streamIndex: number; + }, navigate: boolean, ) => { try { @@ -107,7 +116,8 @@ export function ChatSurface({ resume, initialSession, initialEvents }: ChatSurfa key={message.id ?? String(index)} message={message} isStreaming={ - status === 'streaming' && index === data.messages.length - 1 + status === 'streaming' && + index === data.messages.length - 1 } /> ))} @@ -147,7 +157,10 @@ export function ChatSurface({ resume, initialSession, initialEvents }: ChatSurfa )} {status === 'error' && ( -

+

{error?.message ?? 'The agent failed to complete this turn.'}

)} @@ -174,7 +187,9 @@ function ChatMessageView({ ))} @@ -182,14 +197,21 @@ function ChatMessageView({ ); } -function ChatPartView({ part, showCaret }: { part: EveMessagePart; showCaret: boolean }) { +function ChatPartView({ + part, + showCaret, +}: { + part: EveMessagePart; + showCaret: boolean; +}) { switch (part.type) { case 'step-start': return null; case 'text': return ( - {(part as { type: 'text'; text: string }).text + (showCaret ? '▋' : '')} + {(part as { type: 'text'; text: string }).text + + (showCaret ? '▋' : '')} ); case 'dynamic-tool': { @@ -208,9 +230,16 @@ function ChatPartView({ part, showCaret }: { part: EveMessagePart; showCaret: bo : 'input-streaming'; return ( - + - + ); diff --git a/apps/web/src/components/Chat/ChatsList.tsx b/apps/web/src/components/Chat/ChatsList.tsx index b4e66b5d..4a8db55c 100644 --- a/apps/web/src/components/Chat/ChatsList.tsx +++ b/apps/web/src/components/Chat/ChatsList.tsx @@ -21,7 +21,11 @@ function formatDate(date: string): string { if (diffDays === 0) return 'Today'; if (diffDays === 1) return 'Yesterday'; if (diffDays < 7) return `${diffDays} days ago`; - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + return d.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); } export function ChatsList() { diff --git a/apps/web/src/components/Chat/fetchChatHistory.ts b/apps/web/src/components/Chat/fetchChatHistory.ts index fd4a5df5..1b71a14e 100644 --- a/apps/web/src/components/Chat/fetchChatHistory.ts +++ b/apps/web/src/components/Chat/fetchChatHistory.ts @@ -17,7 +17,9 @@ export interface ChatHistory { streamIndex: number; } -export async function fetchChatHistory(sessionId: string): Promise { +export async function fetchChatHistory( + sessionId: string, +): Promise { const res = await fetch( `/api/eve/v1/session/${encodeURIComponent(sessionId)}/stream?startIndex=0`, ); From f37bb7eb62b7ebe3c5e8ae9b1927cc07d79aafc3 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:49:40 +0530 Subject: [PATCH 03/19] chore: fix knip unused file/export warnings - Delete accounts.module.ts, connection-status.ts (unused) - Delete web/src/hooks/use-chats.ts (replaced by /api/chats route) - Remove SUPPORTED_ACCOUNT_PROVIDERS, UpsertAccountOauthDto, upsertAccountOauthSchema (unused exports) - Unexport getValidClaudeOauthFromPool (internal helper only) - Add @anthropic-ai/claude-agent-sdk + ai-sdk-provider-claude-code to ai-agent deps; remove @ai-sdk/openai Co-Authored-By: Claude Sonnet 4.6 --- .agents/skills/caveman/README.md | 51 +++++++++++ .agents/skills/caveman/SKILL.md | 84 +++++++++++++++++++ apps/ai-agent/package.json | 3 +- apps/backend/src/accounts/accounts.module.ts | 10 --- .../backend/src/accounts/connection-status.ts | 20 ----- .../accounts/dto/upsert-account-oauth.dto.ts | 13 --- .../src/accounts/get-valid-claude-oauth.ts | 2 +- apps/web/src/hooks/use-chats.ts | 36 -------- skills-lock.json | 11 +++ 9 files changed, 149 insertions(+), 81 deletions(-) create mode 100644 .agents/skills/caveman/README.md create mode 100644 .agents/skills/caveman/SKILL.md delete mode 100644 apps/backend/src/accounts/accounts.module.ts delete mode 100644 apps/backend/src/accounts/connection-status.ts delete mode 100644 apps/web/src/hooks/use-chats.ts create mode 100644 skills-lock.json diff --git a/.agents/skills/caveman/README.md b/.agents/skills/caveman/README.md new file mode 100644 index 00000000..a6c1d5fa --- /dev/null +++ b/.agents/skills/caveman/README.md @@ -0,0 +1,51 @@ +# caveman + +Talk like smart caveman. Same brain, fewer tokens. + +## What it does + +Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts 65% of output tokens (measured) with full accuracy preserved. Mode persists for the whole session until changed or stopped. + +Six intensity levels: + +| Level | What change | +| -------------- | ------------------------------------------------------------------- | +| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. | +| `full` | Default. Drop articles, fragments OK, short synonyms. | +| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. | +| `wenyan-lite` | Classical Chinese register, light compression. | +| `wenyan-full` | Maximum 文言文. 80-90% character reduction. | +| `wenyan-ultra` | Extreme classical compression. | + +Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part. + +## How to invoke + +``` +/caveman # full mode (default) +/caveman lite # lighter compression +/caveman ultra # extreme compression +/caveman wenyan # classical Chinese +stop caveman # back to normal prose +``` + +## Example output + +Question: "Why does my React component re-render?" + +Normal prose: + +> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue. + +Caveman (full): + +> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`. + +Caveman (ultra): + +> Inline obj prop → new ref → re-render. `useMemo`. + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview, install, benchmarks diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 00000000..80cdea34 --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,84 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation. + +No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations | +| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" + +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop, new ref, re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。" +- wenyan-ultra: "新參照則重繪。useMemo 包之。" + +Example — "Explain database connection pooling." + +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool reuse open DB connections. No per-request handshake." +- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。" +- wenyan-ultra: "池蓄連,免逐請新開,省握手。" + +## Auto-Clarity + +Drop caveman when: + +- Security warnings +- Irreversible action confirmations +- Multi-step sequences where fragment order or omitted conjunctions risk misread +- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions) +- User asks to clarify or repeats question + +Resume caveman after clear part done. + +Example — destructive op: + +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> +> ```sql +> DROP TABLE users; +> ``` +> +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. diff --git a/apps/ai-agent/package.json b/apps/ai-agent/package.json index 13f19658..de6674b9 100644 --- a/apps/ai-agent/package.json +++ b/apps/ai-agent/package.json @@ -53,9 +53,10 @@ } }, "dependencies": { - "@ai-sdk/openai": "^4.0.8", + "@anthropic-ai/claude-agent-sdk": "*", "@fly/sprites": "0.0.1-rc37", "ai": "^7.0.0", + "ai-sdk-provider-claude-code": "*", "eve": "0.20.0", "zod": "^4.3.6" }, diff --git a/apps/backend/src/accounts/accounts.module.ts b/apps/backend/src/accounts/accounts.module.ts deleted file mode 100644 index 40331267..00000000 --- a/apps/backend/src/accounts/accounts.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PrismaModule } from '../prisma/prisma.module'; -import { AccountsService } from './accounts.service'; - -@Module({ - imports: [PrismaModule], - providers: [AccountsService], - exports: [AccountsService], -}) -export class AccountsModule {} diff --git a/apps/backend/src/accounts/connection-status.ts b/apps/backend/src/accounts/connection-status.ts deleted file mode 100644 index 83cf0c84..00000000 --- a/apps/backend/src/accounts/connection-status.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type ConnectionStatus = { - connectionId: string; - status: 'connected' | 'disconnected'; - scope: 'individual' | 'org-wide'; - connectedBy: { name: string; isCurrentUser: boolean } | null; - connectedAt: string | null; -}; - -export const INDIVIDUAL_CONNECTIONS: ReadonlyArray<{ - connectionId: string; - providerId: string; -}> = [ - { connectionId: 'claude-code', providerId: 'claude' }, - { connectionId: 'codex', providerId: 'codex' }, -]; - -export const ORG_NATIVE_CONNECTIONS: ReadonlyArray<{ - connectionId: string; - provider: string; -}> = []; diff --git a/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts b/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts index bb09a93f..fe808d68 100644 --- a/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts +++ b/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts @@ -4,8 +4,6 @@ import { z } from 'zod'; * Allow-list of providerIds we accept. Add new entries when a new * vendor needs UI-side credential entry. */ -export const SUPPORTED_ACCOUNT_PROVIDERS = ['claude', 'jira'] as const; - /** * Shape of the blob `claude login` writes to * ~/.claude/.credentials.json under the `claudeAiOauth` key. We persist @@ -21,15 +19,4 @@ export const claudeAiOauthSchema = z.object({ export type ClaudeAiOauth = z.infer; -/** - * Body for `POST /accounts/oauth`. Discriminated on providerId so we - * can plug new vendors (OpenAI, etc.) in without rewriting callers. - */ -export const upsertAccountOauthSchema = z.discriminatedUnion('providerId', [ - z.object({ - providerId: z.literal('claude'), - credentials: claudeAiOauthSchema, - }), -]); -export type UpsertAccountOauthDto = z.infer; diff --git a/apps/backend/src/accounts/get-valid-claude-oauth.ts b/apps/backend/src/accounts/get-valid-claude-oauth.ts index e1a730d7..409580d0 100644 --- a/apps/backend/src/accounts/get-valid-claude-oauth.ts +++ b/apps/backend/src/accounts/get-valid-claude-oauth.ts @@ -92,7 +92,7 @@ export async function getValidClaudeOauthWithStore( * Raw-`pg` entry point for workflow step bodies. Keeps the step bundle free of * `@zuko/models` / Prisma (see step-db.ts) — it only touches the passed pool. */ -export function getValidClaudeOauthFromPool( +function getValidClaudeOauthFromPool( pool: Pool, userId: number, ): Promise { diff --git a/apps/web/src/hooks/use-chats.ts b/apps/web/src/hooks/use-chats.ts deleted file mode 100644 index 0a968e9f..00000000 --- a/apps/web/src/hooks/use-chats.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; - -interface Chat { - id: number; - title: string; - createdAt: string; - updatedAt: string; -} - -export const CHATS_QUERY_KEY = ['chats']; - -export function useChats() { - return useQuery({ - queryKey: CHATS_QUERY_KEY, - queryFn: async (): Promise => { - const response = await fetch('/api/proxy/api/chats', { - credentials: 'include', - }); - - if (!response.ok) { - throw new Error('Failed to fetch chats'); - } - - const data = await response.json(); - return data.chats || []; - }, - }); -} - -export function useInvalidateChats() { - const queryClient = useQueryClient(); - - return () => { - queryClient.invalidateQueries({ queryKey: CHATS_QUERY_KEY }); - }; -} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..bd077dd0 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "caveman": { + "source": "juliusbrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman/SKILL.md", + "computedHash": "d18cdf73a5f5c496d681a43a6846336b110223bdffa6817b8992529c57f1a815" + } + } +} From 3a12f333ff38e06e83e507fdfd8533a0f45efff3 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:52:34 +0530 Subject: [PATCH 04/19] feat(accounts): replace OpenAI with Anthropic Claude SDK integration - Replace @ai-sdk/openai dependency with @anthropic-ai/claude-agent-sdk - Add ai-sdk-provider-claude-code provider for Claude Code integration - Add @stablelib/base64 dependency for SDK requirements - Update @anthropic-ai/sdk and supporting platform-specific binaries - Remove trailing whitespace in upsert-account-oauth.dto.ts for consistency Co-Authored-By: Claude Sonnet 4.6 --- .../accounts/dto/upsert-account-oauth.dto.ts | 2 - bun.lock | 55 ++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts b/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts index fe808d68..54fe0f0e 100644 --- a/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts +++ b/apps/backend/src/accounts/dto/upsert-account-oauth.dto.ts @@ -18,5 +18,3 @@ export const claudeAiOauthSchema = z.object({ }); export type ClaudeAiOauth = z.infer; - - diff --git a/bun.lock b/bun.lock index b9f7aff7..33705131 100644 --- a/bun.lock +++ b/bun.lock @@ -116,9 +116,10 @@ "name": "@zuko/ai-agent", "version": "0.1.0", "dependencies": { - "@ai-sdk/openai": "^4.0.8", + "@anthropic-ai/claude-agent-sdk": "*", "@fly/sprites": "0.0.1-rc37", "ai": "^7.0.0", + "ai-sdk-provider-claude-code": "*", "eve": "0.20.0", "zod": "^4.3.6", }, @@ -320,8 +321,6 @@ "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.12", "", { "dependencies": { "@ai-sdk/provider": "4.0.2", "@ai-sdk/provider-utils": "5.0.5", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Y7Fy8xJwPz7ZC0DhSQG3HIVk+drup42hrIj6yqKlib3CxwiR0F7nYyUI8+kPrEtbZEoyKoRstvT4/o0HEyFBHA=="], - "@ai-sdk/openai": ["@ai-sdk/openai@4.0.8", "", { "dependencies": { "@ai-sdk/provider": "4.0.2", "@ai-sdk/provider-utils": "5.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yW2UuS6jlu2LRYCd+cSpVdu/umf+QjypihYAiGvKk5HnpqQf21ffNjFbke9xo9jhT+TBt2YR30Fs5Sy0md/dfA=="], - "@ai-sdk/provider": ["@ai-sdk/provider@4.0.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.5", "", { "dependencies": { "@ai-sdk/provider": "4.0.2", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A=="], @@ -334,6 +333,26 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.210", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.210", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.210" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-siEV0CMN8N02wA3r1kUsiBc5Il1Qv2M761eFKq5q2vsAbsWSicjQI6mCVFj73tge4CuDy51oRSXz3CLrjtjSNA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.210", "", { "os": "darwin", "cpu": "arm64" }, "sha512-twQYlDQvVf0ilWQuvwZ8RcglizPNZt9Xyi10IhxDQEGm7bmYFTB0OUAIVKsYMlL1vjI9ZLCdNyPovo9FCaiehw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.210", "", { "os": "darwin", "cpu": "x64" }, "sha512-QcBJIphqHbPJtV0f9UOU8KJPeDLKjgNmPUMQZCW/aVYs09b2rFGTp6x0QqJ6qGzDc0MDRa3gZre07TjklGES5g=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.210", "", { "os": "linux", "cpu": "arm64" }, "sha512-jY+7Thi+XJREKOSPfW4zr95XNIInHOkzgZ7/jIL6I9v5YQlbmcaYiBjh/zXNDPnjwZHeZkbudsaiF8Lxfej6ww=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.210", "", { "os": "linux", "cpu": "arm64" }, "sha512-PSI9VzaYxhmkNdyiICknGRHA7xPARTXn8rHjhcOqGgMv2gRipxeFIBa/bZAhc+bG6rLFSG9P6S71c1n1hg630w=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.210", "", { "os": "linux", "cpu": "x64" }, "sha512-uk+tVP0fJm9e2PTyW2ps1WSl3BrABcqTT6vhZVUMX0ccMFoAGEsIpBTyc5BjyFYLkCRWCK+i6GCT4+0MDiWnEQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.210", "", { "os": "linux", "cpu": "x64" }, "sha512-OVlYOr3rZIQhue4cuwe/185Uw+eKhPpNSYk873QcI/39nEtGe51aKQvjccKxedEQedt9lDpBwv0hegyfw0/99w=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.210", "", { "os": "win32", "cpu": "arm64" }, "sha512-IHgcG7wx72AVV3UpJgnTIJYL1sbl9wjPUieEEBCzytwkLEhuRnN009dXxt6RGc3F9gOtmE4Dct7iFae3OPjvsA=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.210", "", { "os": "win32", "cpu": "x64" }, "sha512-FwhWDbNpbSJWHZ7e4Y62J1TEy1mUeDXbJ22RgSfh423UCFztGPhgUlz/YDEjFx30x/CDU7g9YsViqjGeN4PlAg=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.111.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q=="], + "@auth/agent": ["@auth/agent@0.4.6", "", { "dependencies": { "jose": "^6.0.0" }, "peerDependencies": { "ai": ">=3.0.0" }, "optionalPeers": ["ai"] }, "sha512-zkYMpltwARLNyQey6+GM9ZoQzAti1qDy38ySAJyvrjm7addQrTtOX1N4+bZVefxrgNV22wZnPlg75hLEnuRFKQ=="], "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.16", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-EKnvkXSmz3IpA99tCNuI+dLFXyZyClSm8zns9sB/elvkU+MTuomAs6toJMPMBf98/fICG/urXDkzGz0/c3yyAQ=="], @@ -1668,6 +1687,8 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -2154,6 +2175,8 @@ "ai": ["ai@7.0.16", "", { "dependencies": { "@ai-sdk/gateway": "4.0.12", "@ai-sdk/provider": "4.0.2", "@ai-sdk/provider-utils": "5.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OuA+uz6ZUVId+SVeB5bThi25Ofee/FmLvffAA368tlgqYCe2qAhqZUpfHL0dd9QC/iPiszdvCQYENJavSo/0gw=="], + "ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@4.0.1", "", { "dependencies": { "@ai-sdk/provider": "^4.0.2", "@ai-sdk/provider-utils": "^5.0.5", "@anthropic-ai/claude-agent-sdk": "0.3.205" }, "peerDependencies": { "zod": "^4.1.8" } }, "sha512-ZC/nSCG7INgZvLzaxBEQ03tkTKaaPqoZ1uQ2ozIPBiMlKrDLxWAAKa3Dpg4WygcVrzetxDLLS01itcBD5HEYRA=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -2810,6 +2833,8 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="], @@ -3230,6 +3255,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -4220,6 +4247,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], @@ -4354,6 +4383,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-loader": ["ts-loader@9.6.2", "", { "dependencies": { "chalk": "^4.1.0", "picomatch": "^4.0.0", "source-map": "^0.7.4" }, "peerDependencies": { "loader-utils": "*", "typescript": "*", "webpack": "^4.0.0 || ^5.0.0" }, "optionalPeers": ["loader-utils"] }, "sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA=="], @@ -4854,6 +4885,8 @@ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.205", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -5404,6 +5437,22 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.205", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.205", "", { "os": "darwin", "cpu": "x64" }, "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.205", "", { "os": "win32", "cpu": "arm64" }, "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.205", "", { "os": "win32", "cpu": "x64" }, "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg=="], + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], From f95f4e1a044690043bf13e944cccf94d02b8e50a Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:55:43 +0530 Subject: [PATCH 05/19] chore: remove skills-lock.json and .agents from tracking Co-Authored-By: Claude Sonnet 4.6 --- .agents/skills/caveman/README.md | 51 ------------------- .agents/skills/caveman/SKILL.md | 84 -------------------------------- skills-lock.json | 11 ----- 3 files changed, 146 deletions(-) delete mode 100644 .agents/skills/caveman/README.md delete mode 100644 .agents/skills/caveman/SKILL.md delete mode 100644 skills-lock.json diff --git a/.agents/skills/caveman/README.md b/.agents/skills/caveman/README.md deleted file mode 100644 index a6c1d5fa..00000000 --- a/.agents/skills/caveman/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# caveman - -Talk like smart caveman. Same brain, fewer tokens. - -## What it does - -Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts 65% of output tokens (measured) with full accuracy preserved. Mode persists for the whole session until changed or stopped. - -Six intensity levels: - -| Level | What change | -| -------------- | ------------------------------------------------------------------- | -| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. | -| `full` | Default. Drop articles, fragments OK, short synonyms. | -| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. | -| `wenyan-lite` | Classical Chinese register, light compression. | -| `wenyan-full` | Maximum 文言文. 80-90% character reduction. | -| `wenyan-ultra` | Extreme classical compression. | - -Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part. - -## How to invoke - -``` -/caveman # full mode (default) -/caveman lite # lighter compression -/caveman ultra # extreme compression -/caveman wenyan # classical Chinese -stop caveman # back to normal prose -``` - -## Example output - -Question: "Why does my React component re-render?" - -Normal prose: - -> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue. - -Caveman (full): - -> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`. - -Caveman (ultra): - -> Inline obj prop → new ref → re-render. `useMemo`. - -## See also - -- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions -- [Caveman README](../../README.md) — repo overview, install, benchmarks diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md deleted file mode 100644 index 80cdea34..00000000 --- a/.agents/skills/caveman/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: caveman -description: > - Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman - while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, - wenyan-lite, wenyan-full, wenyan-ultra. - Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", - "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. ---- - -Respond terse like smart caveman. All technical substance stay. Only fluff die. - -## Persistence - -ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". - -Default: **full**. Switch: `/caveman lite|full|ultra`. - -## Rules - -Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact. - -Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation. - -No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is. - -Pattern: `[thing] [action] [reason]. [next step].` - -Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." -Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" - -## Intensity - -| Level | What change | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | -| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations | -| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch | -| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | -| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | -| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | - -Example — "Why React component re-render?" - -- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." -- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." -- ultra: "Inline obj prop, new ref, re-render. `useMemo`." -- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" -- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。" -- wenyan-ultra: "新參照則重繪。useMemo 包之。" - -Example — "Explain database connection pooling." - -- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." -- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." -- ultra: "Pool reuse open DB connections. No per-request handshake." -- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。" -- wenyan-ultra: "池蓄連,免逐請新開,省握手。" - -## Auto-Clarity - -Drop caveman when: - -- Security warnings -- Irreversible action confirmations -- Multi-step sequences where fragment order or omitted conjunctions risk misread -- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions) -- User asks to clarify or repeats question - -Resume caveman after clear part done. - -Example — destructive op: - -> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. -> -> ```sql -> DROP TABLE users; -> ``` -> -> Caveman resume. Verify backup exist first. - -## Boundaries - -Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index bd077dd0..00000000 --- a/skills-lock.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 1, - "skills": { - "caveman": { - "source": "juliusbrussee/caveman", - "sourceType": "github", - "skillPath": "skills/caveman/SKILL.md", - "computedHash": "d18cdf73a5f5c496d681a43a6846336b110223bdffa6817b8992529c57f1a815" - } - } -} From de6dd3a3bc60fbdec381d947c56c9e0a9018d9ed Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:56:19 +0530 Subject: [PATCH 06/19] chore: gitignore .agents/ and skills-lock.json Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 51bdf240..c861d9f3 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,7 @@ GEMINI.md # Nango runtime data nango-data/ + +# Claude Code agent skills +.agents/ +skills-lock.json From 4fd993f30d43664bb659cf3388a526996d6d8e20 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:56:33 +0530 Subject: [PATCH 07/19] chore(ai-agent): pin claude-agent-sdk and ai-sdk-provider-claude-code versions Co-Authored-By: Claude Sonnet 4.6 --- apps/ai-agent/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-agent/package.json b/apps/ai-agent/package.json index de6674b9..5e8dcdb7 100644 --- a/apps/ai-agent/package.json +++ b/apps/ai-agent/package.json @@ -53,10 +53,10 @@ } }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "*", + "@anthropic-ai/claude-agent-sdk": "^0.2.114", "@fly/sprites": "0.0.1-rc37", "ai": "^7.0.0", - "ai-sdk-provider-claude-code": "*", + "ai-sdk-provider-claude-code": "^3.5.0", "eve": "0.20.0", "zod": "^4.3.6" }, From c1e1761b4f358eb0abd3d82431349485927a0c2c Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 02:57:29 +0530 Subject: [PATCH 08/19] chore: update bun.lock Co-Authored-By: Claude Sonnet 4.6 --- bun.lock | 54 ++++++++++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/bun.lock b/bun.lock index 33705131..516fadc1 100644 --- a/bun.lock +++ b/bun.lock @@ -116,10 +116,10 @@ "name": "@zuko/ai-agent", "version": "0.1.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "*", + "@anthropic-ai/claude-agent-sdk": "^0.2.114", "@fly/sprites": "0.0.1-rc37", "ai": "^7.0.0", - "ai-sdk-provider-claude-code": "*", + "ai-sdk-provider-claude-code": "^3.5.0", "eve": "0.20.0", "zod": "^4.3.6", }, @@ -333,25 +333,25 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.210", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.210", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.210" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-siEV0CMN8N02wA3r1kUsiBc5Il1Qv2M761eFKq5q2vsAbsWSicjQI6mCVFj73tge4CuDy51oRSXz3CLrjtjSNA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.141", "", { "dependencies": { "@anthropic-ai/sdk": "^0.93.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.141", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.141" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-AIBacMWGcZIUcXlUoObqjwJ6pmJI3BayAqPAFXuvSq3DHJXdiuZVs7l/zTB5l3nRhRv5cqSrI2XbiDeHgZWizw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.210", "", { "os": "darwin", "cpu": "arm64" }, "sha512-twQYlDQvVf0ilWQuvwZ8RcglizPNZt9Xyi10IhxDQEGm7bmYFTB0OUAIVKsYMlL1vjI9ZLCdNyPovo9FCaiehw=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.210", "", { "os": "darwin", "cpu": "x64" }, "sha512-QcBJIphqHbPJtV0f9UOU8KJPeDLKjgNmPUMQZCW/aVYs09b2rFGTp6x0QqJ6qGzDc0MDRa3gZre07TjklGES5g=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.141", "", { "os": "darwin", "cpu": "x64" }, "sha512-4iAdarJaQ+2R58s6QJswZCzUdz2WQmL5lYG7Y+FLzWbRSROFfcH0QYpmOqSaPXd2KRQhIJwEacqecDZd/Q1XKQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.210", "", { "os": "linux", "cpu": "arm64" }, "sha512-jY+7Thi+XJREKOSPfW4zr95XNIInHOkzgZ7/jIL6I9v5YQlbmcaYiBjh/zXNDPnjwZHeZkbudsaiF8Lxfej6ww=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.141", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jdf0ZEwJzOP8sE6rPqdJN+SxMb0/L8sxJg4twCv/7S+Qzk0hJtls+wxSi+0Tjh6EEMaNxJqEGc7S3fx99Wi99Q=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.210", "", { "os": "linux", "cpu": "arm64" }, "sha512-PSI9VzaYxhmkNdyiICknGRHA7xPARTXn8rHjhcOqGgMv2gRipxeFIBa/bZAhc+bG6rLFSG9P6S71c1n1hg630w=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.141", "", { "os": "linux", "cpu": "arm64" }, "sha512-6H1AJ/AVaWNnV22kubUPkOTRzZFH0+qP9k7WlhriHMN9gtgZcVAsITMddDeGjQsQJMCAdhXFd6sgi7TM1LdeOQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.210", "", { "os": "linux", "cpu": "x64" }, "sha512-uk+tVP0fJm9e2PTyW2ps1WSl3BrABcqTT6vhZVUMX0ccMFoAGEsIpBTyc5BjyFYLkCRWCK+i6GCT4+0MDiWnEQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.141", "", { "os": "linux", "cpu": "x64" }, "sha512-DVjp72f3HmrRYpbneWZZWIqkUht5kTZXS7wXGFiwzLz6eNYEgjjh+GcsnhIi8UOwZUtNiKUrjZnoP38ovFqV8A=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.210", "", { "os": "linux", "cpu": "x64" }, "sha512-OVlYOr3rZIQhue4cuwe/185Uw+eKhPpNSYk873QcI/39nEtGe51aKQvjccKxedEQedt9lDpBwv0hegyfw0/99w=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.141", "", { "os": "linux", "cpu": "x64" }, "sha512-fTI1YuM4cxOa4nSgsyMAdB5ELizkWp+w5Ispo4JnnYtcczMAL4D9GBNjWPW0sUzKvjsJOUVim68SmWLWhUOpXQ=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.210", "", { "os": "win32", "cpu": "arm64" }, "sha512-IHgcG7wx72AVV3UpJgnTIJYL1sbl9wjPUieEEBCzytwkLEhuRnN009dXxt6RGc3F9gOtmE4Dct7iFae3OPjvsA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.141", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wm10J6kfbufbPGFELokiJ/7Y5Oqug4Uag3HXFsV8g7TWCpaItx/oqVaJoiGptuAtXQB7xGLQVTuk082wER+Y5w=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.210", "", { "os": "win32", "cpu": "x64" }, "sha512-FwhWDbNpbSJWHZ7e4Y62J1TEy1mUeDXbJ22RgSfh423UCFztGPhgUlz/YDEjFx30x/CDU7g9YsViqjGeN4PlAg=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.141", "", { "os": "win32", "cpu": "x64" }, "sha512-IXuP29YJuWbR5Q6xOHrjFVGG54V2s1FC61UVNwEN5fpxL09MwPnbwtQL6fqgzt/U1MP7vWAwpXZriYAklkH/mg=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.111.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="], "@auth/agent": ["@auth/agent@0.4.6", "", { "dependencies": { "jose": "^6.0.0" }, "peerDependencies": { "ai": ">=3.0.0" }, "optionalPeers": ["ai"] }, "sha512-zkYMpltwARLNyQey6+GM9ZoQzAti1qDy38ySAJyvrjm7addQrTtOX1N4+bZVefxrgNV22wZnPlg75hLEnuRFKQ=="], @@ -1687,8 +1687,6 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -2175,7 +2173,7 @@ "ai": ["ai@7.0.16", "", { "dependencies": { "@ai-sdk/gateway": "4.0.12", "@ai-sdk/provider": "4.0.2", "@ai-sdk/provider-utils": "5.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OuA+uz6ZUVId+SVeB5bThi25Ofee/FmLvffAA368tlgqYCe2qAhqZUpfHL0dd9QC/iPiszdvCQYENJavSo/0gw=="], - "ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@4.0.1", "", { "dependencies": { "@ai-sdk/provider": "^4.0.2", "@ai-sdk/provider-utils": "^5.0.5", "@anthropic-ai/claude-agent-sdk": "0.3.205" }, "peerDependencies": { "zod": "^4.1.8" } }, "sha512-ZC/nSCG7INgZvLzaxBEQ03tkTKaaPqoZ1uQ2ozIPBiMlKrDLxWAAKa3Dpg4WygcVrzetxDLLS01itcBD5HEYRA=="], + "ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@3.5.3", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "@anthropic-ai/claude-agent-sdk": "^0.3.205" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-+uzxoU4X3ldzi14jbNvQ9SIitEmjXUM8wYwBC9xb7nfF0W03OB+EvzGrFMwkiE9mVSkFijXIDFzKBhMQ5zkqcg=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -2833,8 +2831,6 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], - "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="], @@ -4247,8 +4243,6 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], @@ -4885,7 +4879,11 @@ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.205", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw=="], + "ai-sdk-provider-claude-code/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "ai-sdk-provider-claude-code/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XPR6o7561RYUkfYlLYouWsvm6Gv2tYIQy5pttGRkvML98u138ClPThn5yiQg5rMQutTtZLB+GUC8qFFCzDKQQQ=="], + + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.210", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.210", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.210", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.210", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.210" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-siEV0CMN8N02wA3r1kUsiBc5Il1Qv2M761eFKq5q2vsAbsWSicjQI6mCVFj73tge4CuDy51oRSXz3CLrjtjSNA=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], @@ -5437,21 +5435,21 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.205", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.210", "", { "os": "darwin", "cpu": "arm64" }, "sha512-twQYlDQvVf0ilWQuvwZ8RcglizPNZt9Xyi10IhxDQEGm7bmYFTB0OUAIVKsYMlL1vjI9ZLCdNyPovo9FCaiehw=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.205", "", { "os": "darwin", "cpu": "x64" }, "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.210", "", { "os": "darwin", "cpu": "x64" }, "sha512-QcBJIphqHbPJtV0f9UOU8KJPeDLKjgNmPUMQZCW/aVYs09b2rFGTp6x0QqJ6qGzDc0MDRa3gZre07TjklGES5g=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.210", "", { "os": "linux", "cpu": "arm64" }, "sha512-jY+7Thi+XJREKOSPfW4zr95XNIInHOkzgZ7/jIL6I9v5YQlbmcaYiBjh/zXNDPnjwZHeZkbudsaiF8Lxfej6ww=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.210", "", { "os": "linux", "cpu": "arm64" }, "sha512-PSI9VzaYxhmkNdyiICknGRHA7xPARTXn8rHjhcOqGgMv2gRipxeFIBa/bZAhc+bG6rLFSG9P6S71c1n1hg630w=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.210", "", { "os": "linux", "cpu": "x64" }, "sha512-uk+tVP0fJm9e2PTyW2ps1WSl3BrABcqTT6vhZVUMX0ccMFoAGEsIpBTyc5BjyFYLkCRWCK+i6GCT4+0MDiWnEQ=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.210", "", { "os": "linux", "cpu": "x64" }, "sha512-OVlYOr3rZIQhue4cuwe/185Uw+eKhPpNSYk873QcI/39nEtGe51aKQvjccKxedEQedt9lDpBwv0hegyfw0/99w=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.205", "", { "os": "win32", "cpu": "arm64" }, "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.210", "", { "os": "win32", "cpu": "arm64" }, "sha512-IHgcG7wx72AVV3UpJgnTIJYL1sbl9wjPUieEEBCzytwkLEhuRnN009dXxt6RGc3F9gOtmE4Dct7iFae3OPjvsA=="], - "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.205", "", { "os": "win32", "cpu": "x64" }, "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg=="], + "ai-sdk-provider-claude-code/@anthropic-ai/claude-agent-sdk/@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.210", "", { "os": "win32", "cpu": "x64" }, "sha512-FwhWDbNpbSJWHZ7e4Y62J1TEy1mUeDXbJ22RgSfh423UCFztGPhgUlz/YDEjFx30x/CDU7g9YsViqjGeN4PlAg=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], From 97419c7bf347692fb1fdf7f160cf345af8cd8b8d Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 03:01:06 +0530 Subject: [PATCH 09/19] fix(backend): remove unused getValidClaudeOauthFromPool to fix TS6133 Co-Authored-By: Claude Sonnet 4.6 --- .../src/accounts/get-valid-claude-oauth.ts | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/apps/backend/src/accounts/get-valid-claude-oauth.ts b/apps/backend/src/accounts/get-valid-claude-oauth.ts index 409580d0..1616ab03 100644 --- a/apps/backend/src/accounts/get-valid-claude-oauth.ts +++ b/apps/backend/src/accounts/get-valid-claude-oauth.ts @@ -1,4 +1,3 @@ -import type { Pool } from 'pg'; import type { ClaudeAiOauth } from './dto/upsert-account-oauth.dto'; // Claude Code's PKCE OAuth client. Confirmed by reading the `claude` binary @@ -88,61 +87,6 @@ export async function getValidClaudeOauthWithStore( } } -/** - * Raw-`pg` entry point for workflow step bodies. Keeps the step bundle free of - * `@zuko/models` / Prisma (see step-db.ts) — it only touches the passed pool. - */ -function getValidClaudeOauthFromPool( - pool: Pool, - userId: number, -): Promise { - const store: ClaudeOauthStore = { - async read(uid) { - const { rows } = await pool.query<{ - id: number; - access_token: string | null; - refresh_token: string | null; - access_token_expires_at: Date | null; - scope: string | null; - }>( - `SELECT id, access_token, refresh_token, access_token_expires_at, scope - FROM "accounts" - WHERE user_id = $1 AND provider_id = 'claude' - ORDER BY id DESC - LIMIT 1`, - [uid], - ); - const r = rows[0]; - return r - ? { - id: r.id, - accessToken: r.access_token, - refreshToken: r.refresh_token, - accessTokenExpiresAt: r.access_token_expires_at, - scope: r.scope, - } - : null; - }, - async persist(id, oauth) { - await pool.query( - `UPDATE "accounts" - SET access_token = $1, - refresh_token = $2, - access_token_expires_at = $3, - scope = $4 - WHERE id = $5`, - [ - oauth.accessToken, - oauth.refreshToken, - new Date(oauth.expiresAt), - oauth.scopes.join(' '), - id, - ], - ); - }, - }; - return getValidClaudeOauthWithStore(store, userId); -} function isFresh(expiresAt: Date | null): boolean { return (expiresAt?.getTime() ?? 0) - Date.now() > REFRESH_LEEWAY_MS; From 191363ff23734c13133954f86562e889f9cf7806 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 03:04:04 +0530 Subject: [PATCH 10/19] format: format the code Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/accounts/get-valid-claude-oauth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/accounts/get-valid-claude-oauth.ts b/apps/backend/src/accounts/get-valid-claude-oauth.ts index 1616ab03..f071ed1c 100644 --- a/apps/backend/src/accounts/get-valid-claude-oauth.ts +++ b/apps/backend/src/accounts/get-valid-claude-oauth.ts @@ -87,7 +87,6 @@ export async function getValidClaudeOauthWithStore( } } - function isFresh(expiresAt: Date | null): boolean { return (expiresAt?.getTime() ?? 0) - Date.now() > REFRESH_LEEWAY_MS; } From eb0828fdce6f0f3b1d83f13401fe2d2e94a66396 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 03:07:42 +0530 Subject: [PATCH 11/19] fix(web): remove unused cookieStore in eve proxy route Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/api/eve/v1/[...path]/route.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/app/api/eve/v1/[...path]/route.ts b/apps/web/src/app/api/eve/v1/[...path]/route.ts index cc21ed22..96247c57 100644 --- a/apps/web/src/app/api/eve/v1/[...path]/route.ts +++ b/apps/web/src/app/api/eve/v1/[...path]/route.ts @@ -1,4 +1,4 @@ -import { cookies, headers } from 'next/headers'; +import { headers } from 'next/headers'; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001'; @@ -23,7 +23,6 @@ interface BetterAuthSession { */ async function proxy(req: Request, path: string[]): Promise { const cookieHeader = (await headers()).get('cookie') ?? ''; - const cookieStore = await cookies(); // Resolve active org from session — backend needs x-org-id header. let orgId = ''; From 3cbb79a5e42e29ef14dae492b4b9531d72bbe186 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 03:26:00 +0530 Subject: [PATCH 12/19] fix(web): remove unused state and imports from chat components - Remove unused useState import from ChatSurface - Remove unused input state variable from ChatSurface - Update EveMessage import source from 'eve/react' to 'eve/client' - Add missing id field to EveSession interface in ChatsList - Clean up unused dependencies to reduce bundle size and improve type accuracy Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/Chat/ChatSurface.tsx | 5 ++--- apps/web/src/components/Chat/ChatsList.tsx | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Chat/ChatSurface.tsx b/apps/web/src/components/Chat/ChatSurface.tsx index 3db557f1..e4d1bb04 100644 --- a/apps/web/src/components/Chat/ChatSurface.tsx +++ b/apps/web/src/components/Chat/ChatSurface.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useRef } from 'react'; import { useEveAgent } from 'eve/react'; import type { EveMessage, EveMessagePart, HandleMessageStreamEvent, -} from 'eve/react'; +} from 'eve/client'; import { Conversation, ConversationContent, @@ -92,7 +92,6 @@ export function ChatSurface({ }, }); - const [input, setInput] = useState(''); const working = status === 'submitted' || status === 'streaming'; const hasMessages = data.messages.length > 0; diff --git a/apps/web/src/components/Chat/ChatsList.tsx b/apps/web/src/components/Chat/ChatsList.tsx index 4a8db55c..1fc0066a 100644 --- a/apps/web/src/components/Chat/ChatsList.tsx +++ b/apps/web/src/components/Chat/ChatsList.tsx @@ -10,6 +10,7 @@ import { BaseTable } from '@/components/Table'; import type { ColumnDef } from '@tanstack/react-table'; interface EveSession { + id: string; sessionId: string; title: string | null; createdAt: string; From 0227c84ba0754ba2efd787235a915e738ba3cb22 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 03:38:16 +0530 Subject: [PATCH 13/19] test(web): refactor chat tests to use useEveAgent hook instead of localStorage - Add useEveAgent mock to replace localStorage-based session management - Import and mock eve/react useEveAgent hook with capture of onSessionChange callback - Replace localStorage stub with useEveAgent mock implementation in beforeEach - Update NewChatPage tests to trigger navigation via onSessionChange callback - Change navigation spy from mockReplace to window.history.replaceState - Simplify test expectations to verify mockSend calls instead of localStorage checks - Update placeholder text matchers to remove "@" hint references - Refactor session creation test to use act() wrapper for callback invocation - Remove first message localStorage persistence assertions - Update fetch mock expectations to use /api/chats instead of /api/proxy/api/chats - Migrate from localStorage-based context entity storage to message parameter passing - Consolidate fetch setup and reduce boilerplate in individual tests Co-Authored-By: Claude Sonnet 4.6 --- apps/web/__tests__/chat.test.tsx | 154 ++++++++++++++----------------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/apps/web/__tests__/chat.test.tsx b/apps/web/__tests__/chat.test.tsx index e0b4414e..1ee666f6 100644 --- a/apps/web/__tests__/chat.test.tsx +++ b/apps/web/__tests__/chat.test.tsx @@ -1,12 +1,13 @@ /** * @vitest-environment jsdom */ -import React from 'react'; +import React, { act } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { TooltipProvider } from '@zuko/ui-kit'; +import { useEveAgent } from 'eve/react'; import { ChatInput } from '@/components/Chat/ChatInput'; import { Tool, @@ -61,6 +62,10 @@ vi.mock('@/lib/api/deals', () => ({ }, })); +vi.mock('eve/react', () => ({ + useEveAgent: vi.fn(), +})); + // jsdom does not provide ResizeObserver (needed by Radix dropdown when menu opens) vi.stubGlobal( 'ResizeObserver', @@ -920,26 +925,25 @@ describe('Tool component', () => { describe('NewChatPage', () => { const originalFetch = globalThis.fetch; - const store: Record = {}; + let capturedOnSessionChange: ((s: { + sessionId?: string; + continuationToken?: string; + streamIndex: number; + }) => void) | undefined; + const mockSend = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - Object.keys(store).forEach((k) => delete store[k]); - vi.stubGlobal('localStorage', { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - removeItem: (k: string) => { - delete store[k]; - }, - clear: () => { - Object.keys(store).forEach((k) => delete store[k]); - }, - get length() { - return Object.keys(store).length; - }, - key: () => null, + capturedOnSessionChange = undefined; + mockSend.mockResolvedValue(undefined); + vi.mocked(useEveAgent).mockImplementation((opts: any) => { + capturedOnSessionChange = opts.onSessionChange; + return { + data: { messages: [] }, + status: 'idle', + error: null, + send: mockSend, + } as any; }); }); @@ -960,45 +964,46 @@ describe('NewChatPage', () => { render(, { wrapper }); expect( - screen.getByPlaceholderText(/ask anything.*try typing @/i), + screen.getByPlaceholderText(/ask anything/i), ).toBeInTheDocument(); }); - it('creates chat and redirects with message stored in localStorage on submit', async () => { + it('creates chat and navigates to /chat/:sessionId on session change', async () => { const user = userEvent.setup(); - const mockChatId = 'chat-123'; + const replaceStateSpy = vi.spyOn(window.history, 'replaceState'); globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ id: mockChatId }), + json: () => Promise.resolve({}), }) as any; render(, { wrapper }); - const input = screen.getByPlaceholderText(/ask anything.*try typing @/i); + const input = screen.getByPlaceholderText(/ask anything/i); await user.type(input, 'First message'); await user.click(screen.getByRole('button', { name: /submit/i })); + await waitFor(() => expect(mockSend).toHaveBeenCalledWith({ message: 'First message' })); + + act(() => { + capturedOnSessionChange?.({ + sessionId: 'chat-123', + streamIndex: 1, + continuationToken: 'tok', + }); + }); + await waitFor(() => { expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/proxy/api/chats', - expect.objectContaining({ - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - }), + '/api/chats', + expect.objectContaining({ method: 'POST' }), ); + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/chat/chat-123'); }); - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith(`/chat/${mockChatId}`); - }); - const stored = localStorage.getItem(`chat-${mockChatId}-firstMessage`); - expect(stored).toBeTruthy(); - const parsed = JSON.parse(stored!); - expect(parsed.text).toBe('First message'); - expect(parsed.contextEntities).toEqual([]); + + replaceStateSpy.mockRestore(); }); - it('stores contextEntities in localStorage when submitting with mentions', async () => { + it('stores contextEntities in message when submitting with mentions', async () => { vi.mocked(contactsApi.getContacts).mockResolvedValue({ contacts: [ { @@ -1018,16 +1023,11 @@ describe('NewChatPage', () => { companies: [], pagination: { page: 1, limit: 100, total: 0, totalPages: 0 }, }); - const user = userEvent.setup(); - const mockChatId = 'chat-456'; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ id: mockChatId }), - }) as any; + const user = userEvent.setup(); render(, { wrapper }); - const input = screen.getByPlaceholderText(/ask anything.*try typing @/i); + const input = screen.getByPlaceholderText(/ask anything/i); await user.click(input); await user.keyboard('@ali'); await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); @@ -1035,59 +1035,45 @@ describe('NewChatPage', () => { await user.click(screen.getByRole('button', { name: /submit/i })); await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith(`/chat/${mockChatId}`); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringMatching(/alice/i) }), + ); }); - const stored = localStorage.getItem(`chat-${mockChatId}-firstMessage`); - expect(stored).toBeTruthy(); - const parsed = JSON.parse(stored!); - expect(parsed.contextEntities).toEqual([{ type: 'contact', id: 1 }]); }); - it('does not redirect when create chat fetch fails', async () => { - const user = userEvent.setup(); - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) as any; + it('does not navigate when /api/chats POST fails', async () => { + const replaceStateSpy = vi.spyOn(window.history, 'replaceState'); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('network')) as any; + const user = userEvent.setup(); render(, { wrapper }); - const input = screen.getByPlaceholderText(/ask anything.*try typing @/i); + const input = screen.getByPlaceholderText(/ask anything/i); await user.type(input, 'Will fail'); await user.click(screen.getByRole('button', { name: /submit/i })); - await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalled(); - }); - expect(mockReplace).not.toHaveBeenCalled(); - }); + await waitFor(() => expect(mockSend).toHaveBeenCalled()); - it('disables ChatInput while submitting', async () => { - let resolveFetch: (value: unknown) => void; - const fetchPromise = new Promise((resolve) => { - resolveFetch = resolve; + act(() => { + capturedOnSessionChange?.({ sessionId: 'chat-fail', streamIndex: 1 }); }); - globalThis.fetch = vi - .fn() - .mockReturnValue(fetchPromise) as unknown as typeof fetch; - const user = userEvent.setup(); - render(, { wrapper }); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + expect(replaceStateSpy).not.toHaveBeenCalled(); - const input = screen.getByPlaceholderText(/ask anything.*try typing @/i); - await user.type(input, 'Slow'); - await user.click(screen.getByRole('button', { name: /submit/i })); + replaceStateSpy.mockRestore(); + }); - await waitFor(() => { - expect(input).toBeDisabled(); - }); + it('shows Stop button while agent is working', () => { + vi.mocked(useEveAgent).mockReturnValue({ + data: { messages: [] }, + status: 'submitted', + error: null, + send: mockSend, + } as any); - resolveFetch!({ - ok: true, - json: () => Promise.resolve({ id: 'slow-chat' }), - }); - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/chat/slow-chat'); - }); + render(, { wrapper }); + + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); }); }); From 1d76c280278482ff6b53b6a11edba351b66db94a Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 03:38:43 +0530 Subject: [PATCH 14/19] style(web): format chat test file for consistency - Reformat union type annotation for capturedOnSessionChange to break across multiple lines - Simplify expect statement by removing unnecessary line breaks in chat input render test - Break long waitFor assertion across multiple lines for improved readability - Improve code consistency with project's formatting standards Co-Authored-By: Claude Sonnet 4.6 --- apps/web/__tests__/chat.test.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/__tests__/chat.test.tsx b/apps/web/__tests__/chat.test.tsx index 1ee666f6..650b9461 100644 --- a/apps/web/__tests__/chat.test.tsx +++ b/apps/web/__tests__/chat.test.tsx @@ -925,11 +925,13 @@ describe('Tool component', () => { describe('NewChatPage', () => { const originalFetch = globalThis.fetch; - let capturedOnSessionChange: ((s: { - sessionId?: string; - continuationToken?: string; - streamIndex: number; - }) => void) | undefined; + let capturedOnSessionChange: + | ((s: { + sessionId?: string; + continuationToken?: string; + streamIndex: number; + }) => void) + | undefined; const mockSend = vi.fn(); beforeEach(() => { @@ -963,9 +965,7 @@ describe('NewChatPage', () => { it('renders ChatInput', () => { render(, { wrapper }); - expect( - screen.getByPlaceholderText(/ask anything/i), - ).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/ask anything/i)).toBeInTheDocument(); }); it('creates chat and navigates to /chat/:sessionId on session change', async () => { @@ -982,7 +982,9 @@ describe('NewChatPage', () => { await user.type(input, 'First message'); await user.click(screen.getByRole('button', { name: /submit/i })); - await waitFor(() => expect(mockSend).toHaveBeenCalledWith({ message: 'First message' })); + await waitFor(() => + expect(mockSend).toHaveBeenCalledWith({ message: 'First message' }), + ); act(() => { capturedOnSessionChange?.({ From dc39d66515ee855d0526ca5345c3d4f6e262543e Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 11:35:51 +0530 Subject: [PATCH 15/19] test(e2e): update chat tests to use /chat route instead of pre-created session IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old beforeEach created chats via ChatsController (numeric DB IDs), but ChatPage now treats the URL param as an eve sessionId → GET /api/eve-chats/:id returned 404 → "Session not found." for every test. Navigate to /chat (new session page) so ChatSurface renders immediately. Remove beforeEach/afterEach session setup — eve manages session lifecycle. Update auth-validation test to watch /api/eve/v1/ instead of /api/chat. Co-Authored-By: Claude Sonnet 4.6 --- apps/web-e2e/src/chat.spec.ts | 80 +++++++---------------------------- 1 file changed, 16 insertions(+), 64 deletions(-) diff --git a/apps/web-e2e/src/chat.spec.ts b/apps/web-e2e/src/chat.spec.ts index ed6144dc..2692865d 100644 --- a/apps/web-e2e/src/chat.spec.ts +++ b/apps/web-e2e/src/chat.spec.ts @@ -24,38 +24,8 @@ test.describe('Chat - Unauthenticated', () => { }); test.describe('Chat', () => { - let chatId: string; - - test.beforeEach(async ({ page }) => { - const response = await page.request.post( - 'http://localhost:3001/api/chats', - { - data: { - title: 'E2E Test Chat', - }, - }, - ); - - expect(response.ok()).toBeTruthy(); - const chat = await response.json(); - chatId = chat.id; - }); - - test.afterEach(async ({ page }) => { - if (chatId) { - await page.request - .delete(`http://localhost:3001/api/chats/${chatId}`) - .catch(() => { - // ignore - }); - } - }); - test('chat page displays correctly with empty state', async ({ page }) => { - await page.goto(`/chat/${chatId}`); - - // Verify we're on the correct chat page - await expect(page).toHaveURL(`/chat/${chatId}`); + await page.goto('/chat'); // Verify empty state is shown await expect(page.getByText('Start a conversation')).toBeVisible(); @@ -71,7 +41,7 @@ test.describe('Chat', () => { test('message text is sent to eve session when submitting', async ({ page, }) => { - await page.goto(`/chat/${chatId}`); + await page.goto('/chat'); // Set up request interception to capture the eve session API call let capturedRequestBody: any = null; @@ -104,7 +74,7 @@ test.describe('Chat', () => { }); test('message is sent and response is displayed', async ({ page }) => { - await page.goto(`/chat/${chatId}`); + await page.goto('/chat'); // Type and send a message const textarea = page.getByPlaceholder('Ask anything...'); @@ -117,7 +87,6 @@ test.describe('Chat', () => { await expect(page.getByText('What is 2+2?')).toBeVisible({ timeout: 5000 }); // Wait for AI response to start appearing (streaming) - // The response should appear within a reasonable time await page.waitForSelector('[data-slot="content"]', { timeout: 10000 }); // Verify the empty state is no longer visible @@ -125,7 +94,7 @@ test.describe('Chat', () => { }); test('multiple messages can be sent in sequence', async ({ page }) => { - await page.goto(`/chat/${chatId}`); + await page.goto('/chat'); const textarea = page.getByPlaceholder('Ask anything...'); const submitButton = page.locator('button[type="submit"]').last(); @@ -138,7 +107,6 @@ test.describe('Chat', () => { }); // Wait for first AI response before sending next message - // [data-slot="content"] is the AI response container (same selector used in the passing test) await page.waitForSelector('[data-slot="content"]', { timeout: 15000 }); // Send second message @@ -157,53 +125,37 @@ test.describe('Chat', () => { await expect(chatLog.getByText('Second message')).toBeVisible(); }); - test('backend receives correct chatId and validates participant', async ({ + test('no auth or participant errors when sending a message', async ({ page, }) => { - // Set up response interception to check for errors let hasAuthError = false; - let hasParticipantError = false; page.on('response', async (response) => { - if (response.url().includes('/api/chat')) { + if (response.url().includes('/api/eve/v1/')) { const status = response.status(); if (status === 401 || status === 403) { - const text = await response.text().catch(() => ''); - if (text.includes('Not a participant')) { - hasParticipantError = true; - console.error('❌ Participant validation error detected'); - } else { - hasAuthError = true; - console.error('❌ Authentication error detected'); - } + hasAuthError = true; } } }); - await page.goto(`/chat/${chatId}`); + await page.goto('/chat'); - // Send a message const textarea = page.getByPlaceholder('Ask anything...'); - await textarea.fill('Testing participant validation'); + await textarea.fill('Testing auth validation'); - // Wait for API response after submitting const submitButton = page.locator('button[type="submit"]').last(); - const responsePromise = page.waitForResponse( - (resp) => resp.url().includes('/api/chat'), - { - timeout: 10000, - }, - ); await submitButton.click(); - await responsePromise; - // Verify no authorization or participant errors occurred + await expect(page.getByText('Testing auth validation')).toBeVisible({ + timeout: 10000, + }); + expect(hasAuthError).toBe(false); - expect(hasParticipantError).toBe(false); }); test('input is cleared after sending message', async ({ page }) => { - await page.goto(`/chat/${chatId}`); + await page.goto('/chat'); const textarea = page.getByPlaceholder('Ask anything...'); await textarea.fill('Test message'); @@ -217,7 +169,7 @@ test.describe('Chat', () => { test.describe('with mentions', () => { test('can add a contact mention to a message', async ({ page }) => { - await page.goto(`/chat/${chatId}`); + await page.goto('/chat'); const textarea = page.getByPlaceholder('Ask anything...'); await textarea.click(); @@ -246,7 +198,7 @@ test.describe('Chat', () => { test('can add contact, company, and deal via Add attachments and submit', async ({ page, }) => { - await page.goto(`/chat/${chatId}`); + await page.goto('/chat'); // Open "Add attachments" menu await page.getByRole('button', { name: 'Add attachments' }).click(); From 6983d07d71f534de27a1bc01eda7e3c42e449609 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Thu, 16 Jul 2026 12:40:34 +0530 Subject: [PATCH 16/19] test(e2e): wait for agent idle before sending second message in chat test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After first AI response appears, status may still be 'streaming' — ChatSurface drops messages when working=true. Wait for Submit button to reappear (Stop→Submit transition) before sending the second message. Co-Authored-By: Claude Sonnet 4.6 --- apps/web-e2e/src/chat.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web-e2e/src/chat.spec.ts b/apps/web-e2e/src/chat.spec.ts index 2692865d..c7b17b36 100644 --- a/apps/web-e2e/src/chat.spec.ts +++ b/apps/web-e2e/src/chat.spec.ts @@ -106,8 +106,11 @@ test.describe('Chat', () => { timeout: 5000, }); - // Wait for first AI response before sending next message + // Wait for first AI response AND agent to finish (Submit button re-appears, not Stop) await page.waitForSelector('[data-slot="content"]', { timeout: 15000 }); + await expect(page.getByRole('button', { name: /submit/i })).toBeVisible({ + timeout: 30000, + }); // Send second message const textareaInChatPage = page.getByPlaceholder('Ask anything...'); From 0ad6d90424168f60f965b01a02ceac485a6a3eaf Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Fri, 17 Jul 2026 14:48:31 +0530 Subject: [PATCH 17/19] refactor(backend): move account DB queries to AccountsRepository Extracts all Prisma queries from AccountsService into a new AccountsRepository. Service now delegates to repo; no query logic remains in the service layer. Co-Authored-By: Claude Sonnet 4.6 --- .../src/accounts/accounts.repository.ts | 75 +++++++++++++++++++ apps/backend/src/accounts/accounts.service.ts | 64 ++-------------- apps/backend/src/eve/eve.module.ts | 2 + 3 files changed, 84 insertions(+), 57 deletions(-) create mode 100644 apps/backend/src/accounts/accounts.repository.ts diff --git a/apps/backend/src/accounts/accounts.repository.ts b/apps/backend/src/accounts/accounts.repository.ts new file mode 100644 index 00000000..a3b479ac --- /dev/null +++ b/apps/backend/src/accounts/accounts.repository.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import type { ClaudeAiOauth } from './dto/upsert-account-oauth.dto'; + +@Injectable() +export class AccountsRepository { + constructor(private readonly db: PrismaService) {} + + findClaudeAccount(userId: number) { + return this.db.account.findFirst({ + where: { userId, providerId: 'claude' }, + select: { + accessToken: true, + refreshToken: true, + accessTokenExpiresAt: true, + scope: true, + }, + orderBy: { id: 'desc' }, + }); + } + + findClaudeAccountWithId(userId: number) { + return this.db.account.findFirst({ + where: { userId, providerId: 'claude' }, + select: { + id: true, + accessToken: true, + refreshToken: true, + accessTokenExpiresAt: true, + scope: true, + }, + orderBy: { id: 'desc' }, + }); + } + + async upsertClaudeOauth(userId: number, creds: ClaudeAiOauth): Promise { + const data = { + userId, + providerId: 'claude', + accessToken: creds.accessToken, + refreshToken: creds.refreshToken, + accessTokenExpiresAt: new Date(creds.expiresAt), + scope: creds.scopes.join(' '), + }; + await this.db.$transaction(async (tx) => { + const existing = await tx.account.findFirst({ + where: { userId, providerId: 'claude' }, + select: { id: true }, + }); + if (existing) { + await tx.account.update({ where: { id: existing.id }, data }); + } else { + await tx.account.create({ + data: { ...data, accountId: `claude_${userId}` }, + }); + } + }); + } + + async updateClaudeTokens(id: number, oauth: ClaudeAiOauth): Promise { + await this.db.account.update({ + where: { id }, + data: { + accessToken: oauth.accessToken, + refreshToken: oauth.refreshToken, + accessTokenExpiresAt: new Date(oauth.expiresAt), + scope: oauth.scopes.join(' '), + }, + }); + } + + async deleteByProvider(userId: number, providerId: string): Promise { + await this.db.account.deleteMany({ where: { userId, providerId } }); + } +} diff --git a/apps/backend/src/accounts/accounts.service.ts b/apps/backend/src/accounts/accounts.service.ts index 33ff372b..85aafc3f 100644 --- a/apps/backend/src/accounts/accounts.service.ts +++ b/apps/backend/src/accounts/accounts.service.ts @@ -1,79 +1,29 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; import type { ClaudeAiOauth } from './dto/upsert-account-oauth.dto'; import { getValidClaudeOauthWithStore } from './get-valid-claude-oauth'; +import { AccountsRepository } from './accounts.repository'; @Injectable() export class AccountsService { - constructor(private readonly db: PrismaService) {} + constructor(private readonly repo: AccountsRepository) {} async getClaudeAccount(userId: number) { - return this.db.account.findFirst({ - where: { userId, providerId: 'claude' }, - select: { - accessToken: true, - refreshToken: true, - accessTokenExpiresAt: true, - scope: true, - }, - orderBy: { id: 'desc' }, - }); + return this.repo.findClaudeAccount(userId); } async upsertClaudeOauth(userId: number, creds: ClaudeAiOauth): Promise { - const data = { - userId, - providerId: 'claude', - accessToken: creds.accessToken, - refreshToken: creds.refreshToken, - accessTokenExpiresAt: new Date(creds.expiresAt), - scope: creds.scopes.join(' '), - }; - await this.db.$transaction(async (tx) => { - const existing = await tx.account.findFirst({ - where: { userId, providerId: 'claude' }, - select: { id: true }, - }); - if (existing) { - await tx.account.update({ where: { id: existing.id }, data }); - } else { - await tx.account.create({ - data: { ...data, accountId: `claude_${userId}` }, - }); - } - }); + return this.repo.upsertClaudeOauth(userId, creds); } async deleteByProvider(userId: number, providerId: string): Promise { - await this.db.account.deleteMany({ where: { userId, providerId } }); + return this.repo.deleteByProvider(userId, providerId); } async getValidClaudeOauth(userId: number): Promise { return getValidClaudeOauthWithStore( { - read: (uid) => - this.db.account.findFirst({ - where: { userId: uid, providerId: 'claude' }, - select: { - id: true, - accessToken: true, - refreshToken: true, - accessTokenExpiresAt: true, - scope: true, - }, - orderBy: { id: 'desc' }, - }), - persist: async (id, oauth) => { - await this.db.account.update({ - where: { id }, - data: { - accessToken: oauth.accessToken, - refreshToken: oauth.refreshToken, - accessTokenExpiresAt: new Date(oauth.expiresAt), - scope: oauth.scopes.join(' '), - }, - }); - }, + read: (uid) => this.repo.findClaudeAccountWithId(uid), + persist: (id, oauth) => this.repo.updateClaudeTokens(id, oauth), }, userId, ); diff --git a/apps/backend/src/eve/eve.module.ts b/apps/backend/src/eve/eve.module.ts index 4f5e0742..85d4490d 100644 --- a/apps/backend/src/eve/eve.module.ts +++ b/apps/backend/src/eve/eve.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; +import { AccountsRepository } from '../accounts/accounts.repository'; import { AccountsService } from '../accounts/accounts.service'; import { EveCredentialsController } from './eve-credentials.controller'; import { EveProxyController } from './eve-proxy.controller'; @@ -19,6 +20,7 @@ import { EveChatsRepository } from './eve-chats.repository'; EvePrincipalGuard, EveTargetService, EveChatsRepository, + AccountsRepository, AccountsService, ], }) From 62a830dc403761438497b3c5a18287b8a5d747cc Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Fri, 17 Jul 2026 14:49:56 +0530 Subject: [PATCH 18/19] refactor(backend): extract EveProxyService, keep controller lean Controller now only resolves session/orgId and delegates. EveProxyService owns org membership check, upstream fetch, header forwarding, and NDJSON streaming. recoverRequestBody moved to service. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/eve/eve-proxy.controller.ts | 175 +------------------ apps/backend/src/eve/eve-proxy.service.ts | 114 ++++++++++++ apps/backend/src/eve/eve.module.ts | 2 + 3 files changed, 123 insertions(+), 168 deletions(-) create mode 100644 apps/backend/src/eve/eve-proxy.service.ts diff --git a/apps/backend/src/eve/eve-proxy.controller.ts b/apps/backend/src/eve/eve-proxy.controller.ts index eb94ac35..803596b7 100644 --- a/apps/backend/src/eve/eve-proxy.controller.ts +++ b/apps/backend/src/eve/eve-proxy.controller.ts @@ -1,61 +1,21 @@ -import { - All, - Controller, - ForbiddenException, - Inject, - Logger, - Req, - Res, -} from '@nestjs/common'; +import { All, Controller, Inject, Req, Res } from '@nestjs/common'; import { AllowAnonymous } from '@thallesp/nestjs-better-auth'; import type { Request as ExpressRequest, Response as ExpressResponse, } from 'express'; import { fromNodeHeaders } from 'better-auth/node'; -import { PrismaService } from '../prisma/prisma.service'; -import { EveTargetService } from './eve-target.service'; +import { EveProxyService } from './eve-proxy.service'; import { resolveOrgId } from './resolve-org'; -import { buildUpstreamHeaders } from './eve-proxy-headers'; import { auth } from '../libs/better-auth/auth'; export { buildUpstreamHeaders } from './eve-proxy-headers'; -/** - * Catch-all reverse proxy for `/eve/v1/*`. - * - * Security invariants: - * - 401 if no Better Auth session (global AppAuthGuard would also fire, but - * we @AllowAnonymous() so we own the 401 response ourselves for clarity). - * - cookie / authorization NEVER forwarded to eve (stripped by buildUpstreamHeaders). - * - Identity forwarded as a short-lived HMAC-signed x-eve-principal token. - * - Org membership verified against DB before principal is signed (prevents - * cross-tenant IDOR: user cannot forge a principal for an org they don't - * belong to by supplying a different x-org-id header). - * - * Streaming: - * - Client disconnect → AbortController.abort() cancels the upstream fetch. - * - Upstream response body piped chunk-by-chunk without buffering (NDJSON - * `/stream` endpoints work live). - * - content-length dropped from upstream response so browsers don't complain - * about mismatches caused by hop-by-hop encoding differences. - * - set-cookie NEVER relayed from upstream to the browser (cross-tenant - * trust boundary — eve's cookies are not this client's to receive). - * - * Request body: - * - body-parser is bypassed for /eve/v1/* (see configureBodyParsing in - * body-parser.ts), so the Express req is still a raw readable stream. - * - The stream is buffered before the upstream fetch (see readRawBody) — - * bodies are small JSON because the eve channel disables uploads. - */ @AllowAnonymous() @Controller('eve/v1') export class EveProxyController { - private readonly logger = new Logger(EveProxyController.name); - constructor( - @Inject(EveTargetService) private readonly eve: EveTargetService, - @Inject(PrismaService) private readonly databaseService: PrismaService, + @Inject(EveProxyService) private readonly eveProxy: EveProxyService, ) {} @All('*') @@ -63,138 +23,17 @@ export class EveProxyController { @Req() req: ExpressRequest, @Res() res: ExpressResponse, ): Promise { - // --- Auth (Pattern-2) --- const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers), }); - if (!session?.user?.id) { - res.status(401).json({ error: 'unauthenticated' }); - return; - } - - const userId = Number(session.user.id); + const userId = Number(session?.user?.id); if (!Number.isFinite(userId)) { res.status(401).json({ error: 'unauthenticated' }); return; } - const orgId = resolveOrgId(req); - - // --- Org membership check (replicates OrganisationGuard — same table/query) --- - // Prevent cross-tenant IDOR: the authenticated user MUST be a member of - // the requested org before we sign it into x-eve-principal. - const member = await this.databaseService.member.findFirst({ - where: { userId, organizationId: orgId }, - }); - if (!member) { - throw new ForbiddenException('User does not belong to this organisation'); - } - - // --- Upstream fetch with AbortController for client-disconnect propagation --- - const upstreamPath = req.originalUrl.replace(/^\/api/, ''); - const url = `${this.eve.baseUrl}${upstreamPath}`; - const controller = new AbortController(); - res.on('close', () => controller.abort()); - - const hasBody = !['GET', 'HEAD'].includes(req.method.toUpperCase()); - - // Recover the request body as a Buffer/string, wherever it ended up. - // Our configureBodyParsing bypasses /eve/v1/*, but - // @thallesp/nestjs-better-auth registers its OWN global express.json() - // (enabled by default), which consumes the stream after our bypass — - // streaming req into fetch then fails under undici with "Response body - // object should not be disturbed or locked", and a raw re-read yields - // an empty body (both verified against a live eve dev server; the - // exploration branch missed this because its tests mock fetch). So: - // prefer the saved rawBody, then the parsed req.body (re-serialized — - // safe, eve verifies the principal header, not body bytes), and only - // fall back to reading the stream. Buffering is fine: the eve channel - // disables uploads, so bodies are small JSON. Response-side NDJSON - // streaming is unaffected. - const body = hasBody ? await recoverRequestBody(req) : undefined; - - let upstream: Response; - try { - upstream = await fetch(url, { - method: req.method, - headers: buildUpstreamHeaders( - req.headers as Record, - { userId, orgId }, - ), - body: body as Buffer | string | undefined, - signal: controller.signal, - }); - } catch (err) { - if (controller.signal.aborted) { - // Client disconnected before upstream responded — nothing to write. - return; - } - this.logger.error(`eve proxy fetch error: ${String(err)}`); - if (!res.headersSent) { - res.status(502).json({ error: 'eve upstream unavailable' }); - } - return; - } - // --- Forward response status + headers --- - res.status(upstream.status); - upstream.headers.forEach((value, key) => { - // Drop content-length: the streaming pipe may deliver different byte - // counts than what the upstream declared (e.g. gzip decompression). - if (key.toLowerCase() === 'content-length') return; - // Drop set-cookie: a cross-tenant proxy must never relay an upstream - // cookie to the browser (trust-boundary hardening). - if (key.toLowerCase() === 'set-cookie') return; - res.setHeader(key, value); - }); - - // --- Stream body to client --- - if (!upstream.body) { - res.end(); - return; - } - - const reader = upstream.body.getReader(); - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - res.write(Buffer.from(value)); - } - } catch (err) { - if (!controller.signal.aborted) { - this.logger.error(`eve proxy stream error: ${String(err)}`); - } - } finally { - res.end(); - } - } -} - -async function recoverRequestBody( - req: ExpressRequest, -): Promise { - const rawBody = (req as ExpressRequest & { rawBody?: Buffer }).rawBody; - if (rawBody?.length) return rawBody; - - const parsed = (req as ExpressRequest & { body?: unknown }).body; - if (Buffer.isBuffer(parsed) && parsed.length > 0) return parsed; - if (typeof parsed === 'string' && parsed.length > 0) return parsed; - if ( - parsed !== undefined && - parsed !== null && - typeof parsed === 'object' && - Object.keys(parsed).length > 0 - ) { - return JSON.stringify(parsed); - } - - // Stream untouched (no middleware consumed it) — read it ourselves. - if (!req.readableDidRead) { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(chunk as Buffer); - } - return Buffer.concat(chunks); + const orgId = resolveOrgId(req); + await this.eveProxy.verifyOrgMembership(userId, orgId); + await this.eveProxy.proxy(req, res, { userId, orgId }); } - return undefined; } diff --git a/apps/backend/src/eve/eve-proxy.service.ts b/apps/backend/src/eve/eve-proxy.service.ts new file mode 100644 index 00000000..2935d01d --- /dev/null +++ b/apps/backend/src/eve/eve-proxy.service.ts @@ -0,0 +1,114 @@ +import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; +import type { + Request as ExpressRequest, + Response as ExpressResponse, +} from 'express'; +import { PrismaService } from '../prisma/prisma.service'; +import { EveTargetService } from './eve-target.service'; +import { buildUpstreamHeaders } from './eve-proxy-headers'; + +@Injectable() +export class EveProxyService { + private readonly logger = new Logger(EveProxyService.name); + + constructor( + private readonly eve: EveTargetService, + private readonly db: PrismaService, + ) {} + + async verifyOrgMembership(userId: number, orgId: number): Promise { + const member = await this.db.member.findFirst({ + where: { userId, organizationId: orgId }, + }); + if (!member) { + throw new ForbiddenException('User does not belong to this organisation'); + } + } + + async proxy( + req: ExpressRequest, + res: ExpressResponse, + principal: { userId: number; orgId: number }, + ): Promise { + const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const url = `${this.eve.baseUrl}${upstreamPath}`; + const controller = new AbortController(); + res.on('close', () => controller.abort()); + + const hasBody = !['GET', 'HEAD'].includes(req.method.toUpperCase()); + const body = hasBody ? await recoverRequestBody(req) : undefined; + + let upstream: Response; + try { + upstream = await fetch(url, { + method: req.method, + headers: buildUpstreamHeaders( + req.headers as Record, + principal, + ), + body: body as Buffer | string | undefined, + signal: controller.signal, + }); + } catch (err) { + if (controller.signal.aborted) return; + this.logger.error(`eve proxy fetch error: ${String(err)}`); + if (!res.headersSent) res.status(502).json({ error: 'eve upstream unavailable' }); + return; + } + + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + if (key.toLowerCase() === 'content-length') return; + if (key.toLowerCase() === 'set-cookie') return; + res.setHeader(key, value); + }); + + if (!upstream.body) { + res.end(); + return; + } + + const reader = upstream.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + } catch (err) { + if (!controller.signal.aborted) { + this.logger.error(`eve proxy stream error: ${String(err)}`); + } + } finally { + res.end(); + } + } +} + +async function recoverRequestBody( + req: ExpressRequest, +): Promise { + const rawBody = (req as ExpressRequest & { rawBody?: Buffer }).rawBody; + if (rawBody?.length) return rawBody; + + const parsed = (req as ExpressRequest & { body?: unknown }).body; + if (Buffer.isBuffer(parsed) && parsed.length > 0) return parsed; + if (typeof parsed === 'string' && parsed.length > 0) return parsed; + if ( + parsed !== undefined && + parsed !== null && + typeof parsed === 'object' && + Object.keys(parsed).length > 0 + ) { + return JSON.stringify(parsed); + } + + if (!req.readableDidRead) { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks); + } + return undefined; +} diff --git a/apps/backend/src/eve/eve.module.ts b/apps/backend/src/eve/eve.module.ts index 85d4490d..1afc1152 100644 --- a/apps/backend/src/eve/eve.module.ts +++ b/apps/backend/src/eve/eve.module.ts @@ -8,6 +8,7 @@ import { EvePrincipalGuard } from './eve-principal.guard'; import { EveTargetService } from './eve-target.service'; import { EveChatsController } from './eve-chats.controller'; import { EveChatsRepository } from './eve-chats.repository'; +import { EveProxyService } from './eve-proxy.service'; @Module({ imports: [PrismaModule], @@ -19,6 +20,7 @@ import { EveChatsRepository } from './eve-chats.repository'; providers: [ EvePrincipalGuard, EveTargetService, + EveProxyService, EveChatsRepository, AccountsRepository, AccountsService, From b3e0a76b61fba3ec5be9c059c2ad7192f09800a0 Mon Sep 17 00:00:00 2001 From: Rishavraaj Date: Fri, 17 Jul 2026 14:50:39 +0530 Subject: [PATCH 19/19] style(backend): format EveProxyService Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/eve/eve-proxy.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/eve/eve-proxy.service.ts b/apps/backend/src/eve/eve-proxy.service.ts index 2935d01d..0c0efb20 100644 --- a/apps/backend/src/eve/eve-proxy.service.ts +++ b/apps/backend/src/eve/eve-proxy.service.ts @@ -52,7 +52,8 @@ export class EveProxyService { } catch (err) { if (controller.signal.aborted) return; this.logger.error(`eve proxy fetch error: ${String(err)}`); - if (!res.headersSent) res.status(502).json({ error: 'eve upstream unavailable' }); + if (!res.headersSent) + res.status(502).json({ error: 'eve upstream unavailable' }); return; }