const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { execFile, spawn } = require('child_process'); const { Transform } = require('stream'); const { pipeline } = require('stream/promises'); const { tempPathOf } = require('../scripts/temp-paths'); const { redactDeep, redactText } = require('./redact'); const memory = require('./memory'); const gemini = require('./gemini'); const readline = require('readline'); const SDK_MODULE = '@anthropic-ai/claude-agent-sdk'; const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']; const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'auto', 'bypassPermissions']; const MODEL_RE = /^[A-Za-z0-9._[\]-]{1,64}$/; const MAX_HISTORY = 3000; const MAX_TOOL_TEXT = 20000; const MAX_INPUT_TEXT = 20000; const MAX_PROMPT_CHARS = 100000; const MAX_ANSWER_CHARS = 2000; const IDLE_CLOSE_MS = 30 * 60 * 1000; const DRAFT_FLUSH_MS = 60; const STOP_KILL_MS = 8000; const MCP_STATUS_TIMEOUT_MS = 10000; const USAGE_FLUSH_MS = 400; // Signing in to remote MCP servers (OAuth) from the chat. const MCP_AUTH_TIMEOUT_MS = 45000; const MCP_CALLBACK_TIMEOUT_MS = 60000; // Removing MCP servers for good (Claude Code's own `claude mcp remove`) and // switching them off (claude.ai connectors cannot be removed from here). const MCP_REMOVE_TIMEOUT_MS = 60000; const MCP_TOGGLE_TIMEOUT_MS = 30000; const REMOVABLE_SCOPES = new Set(['user', 'local', 'project']); const OAUTH_FLOW_TTL_MS = 15 * 60 * 1000; const MAX_OAUTH_FLOWS = 50; const MAX_CALLBACK_URL = 8192; const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; const MAX_ATTACHMENTS = 10; const UPLOAD_TTL_MS = 24 * 60 * 60 * 1000; // The API rejects larger images; bigger ones are still saved as plain files. const MAX_INLINE_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_MEDIA_BYTES = 10 * 1024 * 1024; const MAX_MEDIA_FILES = 300; const MAX_IMAGES_PER_RESULT = 8; const SESSION_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/; const MEDIA_FILE_RE = /^[0-9a-f-]{36}\.(png|jpg|gif|webp)$/; const MEDIA_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }; // Files Claude hands to the user with Agent Web's own MCP tool (scripts/agentweb-mcp.js). const SHARE_TOOL = 'mcp__agentweb__share_file'; const FILE_ID_RE = /^[0-9a-f-]{36}$/; const MAX_SHARE_BYTES = 50 * 1024 * 1024; const MAX_SHARE_TOTAL = 100 * 1024 * 1024; const MAX_SHARED_FILES = 200; const MAX_SHARED_DISK = 1024 * 1024 * 1024; const MAX_TEXT_PREVIEW = 512 * 1024; const MAX_TOOL_NAMES = 500; // ---- token saving ---- // Anthropic's prompt cache lives about an hour (measured on this Space's own // history: gaps under ~56 min hit the cache, gaps over ~63 min rewrote the // whole conversation). Resuming a big conversation after that costs its full // size again, so the user is asked first. const CACHE_COLD_MS = 58 * 60 * 1000; const RESUME_WARN_TOKENS = 60000; // A whole-file Read of a big file is stopped once per conversation with the // file's map entry, so Claude reads just the part it needs (or asks again). const GUARD_MIN_LINES = 400; const GUARD_MIN_BYTES = 24 * 1024; const GUARD_MAX_SYMBOLS = 80; const GUARD_SKIP_EXT = /\.(png|jpe?g|gif|webp|bmp|pdf|ipynb|svg)$/i; const MAP_STALE_MS = 10 * 60 * 1000; const MAP_REFRESH_DELAY_MS = 3000; const CHANGING_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'write_file', 'replace', 'run_shell_command', 'run_command', 'write_to_file', 'replace_file_content', 'multi_replace_file_content', 'sed_file', 'notebook_edit']); const ENGINES = ['claude', 'gemini', 'mixed']; const CATCHUP_MAX_CHARS = 16000; const CATCHUP_ITEM_CHARS = 1500; const GEMINI_KILL_MS = 4000; const MAX_ARCHIVES = 200; const ARCHIVE_ID_RE = /^[0-9]{13}-[0-9a-f]{8}$/; const SUMMARY_MAX_CHARS = 4000; // The avatar's emotion tags at the start of a reply ([happy] ...). const EMOTIONS = ['neutral', 'happy', 'excited', 'laughs', 'sad', 'surprised', 'angry', 'thinking', 'confused', 'love', 'sleepy', 'proud', 'worried', 'shy', 'playful']; const EMOTION_TAGS_RE = new RegExp(`^\\s*((?:\\[(?:${EMOTIONS.join('|')})\\]\\s*)+)`, 'i'); const GEMINI_TOOL = 'mcp__agentweb__ask_gemini'; const NOTE_TOOL = 'mcp__agentweb__note_file'; const LOG_TOOL = 'mcp__agentweb__log_change'; // Look like regular files, but hold live process state (environment, memory). const SYSTEM_PATH_RE = /^\/(proc|sys|dev)(\/|$)/; // Harmless Agent Web tools (they only read, or hand a file to this same // user); asking every time would just be noise. const AUTO_ALLOW_TOOLS = new Set([SHARE_TOOL, NOTE_TOOL, LOG_TOOL]); const NO_XHIGH = ['low', 'medium', 'high', 'max']; // Anthropic's current model ids. Replaced by the CLI's own supportedModels() // list (which reflects what this account can actually use) once a session // has started at least once. const FALLBACK_MODELS = [ { value: '', displayName: 'Varsayılan', description: "Claude Code'un bu hesap için seçtiği model", supportedEffortLevels: EFFORT_LEVELS, group: 'main' }, { value: 'claude-opus-5-5', displayName: 'Opus 5.5', description: 'Zorlu işler için en yetenekli', supportedEffortLevels: EFFORT_LEVELS, group: 'main' }, { value: 'claude-sonnet-5-5', displayName: 'Sonnet 5.5', description: 'En yeni Sonnet: hızlı ve güçlü, günlük işler için', supportedEffortLevels: EFFORT_LEVELS, group: 'main', pinned: true }, { value: 'claude-sonnet-5', displayName: 'Sonnet 5', description: 'Günlük işler için verimli', supportedEffortLevels: EFFORT_LEVELS, group: 'main' }, { value: 'claude-fable-5-1', displayName: 'Fable 5.1', description: 'En zor problemler için (kullanım kredisi gerektirebilir)', supportedEffortLevels: EFFORT_LEVELS, group: 'main' }, { value: 'claude-haiku-5-5', displayName: 'Haiku 5.5', description: 'En yeni Haiku: çok hızlı ve ucuz, kısa işler için', supportedEffortLevels: EFFORT_LEVELS, group: 'main', pinned: true }, { value: 'claude-haiku-4-5', displayName: 'Haiku 4.5', description: 'Hızlı, kısa cevaplar için', supportedEffortLevels: [], supportsAdaptiveThinking: false, group: 'main' }, { value: 'claude-opus-5', displayName: 'Opus 5', description: '', supportedEffortLevels: EFFORT_LEVELS, group: 'other' }, { value: 'claude-fable-5', displayName: 'Fable 5', description: 'Kullanım kredisi gerektirebilir', supportedEffortLevels: EFFORT_LEVELS, group: 'other' }, { value: 'claude-opus-4-8', displayName: 'Opus 4.8', description: '', supportedEffortLevels: EFFORT_LEVELS, group: 'other' }, { value: 'claude-opus-4-7', displayName: 'Opus 4.7', description: '', supportedEffortLevels: EFFORT_LEVELS, group: 'other' }, { value: 'claude-opus-4-6', displayName: 'Opus 4.6', description: '', supportedEffortLevels: NO_XHIGH, group: 'other' }, { value: 'claude-sonnet-4-6', displayName: 'Sonnet 4.6', description: '', supportedEffortLevels: NO_XHIGH, group: 'other' }, ]; // The CLI only lists its own picker entries (often aliases such as "opus"); // model ids it does not list still work by name. Pinned ones (new releases) // join the main list, the older ones go under "Diğer modeller". function mergeFallbackModels(models) { const listed = new Set(models.map((m) => m.value)); const pinned = []; const older = []; for (const m of FALLBACK_MODELS) { if (!m.value || listed.has(m.value)) continue; if (m.pinned) pinned.push({ ...m, group: 'main' }); else if (m.group === 'other') older.push({ ...m }); } const main = models.filter((m) => m.group !== 'other'); const rest = models.filter((m) => m.group === 'other'); return [...main, ...pinned, ...rest, ...older]; } const ASSISTANT_ERRORS = { authentication_failed: 'Kimlik doğrulama başarısız. Terminal sekmesinde `claude` çalıştırıp giriş yapın ya da ANTHROPIC_API_KEY secret\'ı tanımlayın.', oauth_org_not_allowed: 'Bu hesabın organizasyonu bu kullanıma izin vermiyor.', account_on_hold: 'Hesap askıya alınmış görünüyor.', verification_required: 'Hesap doğrulaması gerekiyor.', billing_error: 'Faturalandırma hatası (kredi veya ödeme yöntemi gerekiyor olabilir).', rate_limit: 'Kullanım limitine ulaşıldı.', overloaded: 'Servis şu an çok yoğun, biraz sonra tekrar deneyin.', model_not_found: 'Seçilen model bu hesapta kullanılamıyor, başka bir model seçin.', max_output_tokens: 'Yanıt maksimum uzunluğa ulaştığı için kesildi.', }; function readJson(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } } function writeJson(file, data) { try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(data, null, 2)); } catch (err) { console.error('[chat] yazilamadi:', file, err.message); } } function errMessage(err) { return err && err.message ? err.message : String(err); } function truncate(text, max) { if (typeof text !== 'string') return ''; if (text.length <= max) return text; return `${text.slice(0, max)}\n… (${text.length - max} karakter kırpıldı)`; } // Tool inputs can carry whole files (Write/Edit); keep the stored history light. function clipText(text, max) { const t = String(text || ''); return t.length > max ? `${t.slice(0, max)}…` : t; } function capInput(input) { if (!input || typeof input !== 'object') return {}; const out = Array.isArray(input) ? [] : {}; for (const [key, value] of Object.entries(input)) { out[key] = typeof value === 'string' ? truncate(value, MAX_INPUT_TEXT) : value; } return out; } // Images in tool results are stored and shown separately (see extractImages). function toolResultText(content) { if (typeof content === 'string') return truncate(content, MAX_TOOL_TEXT); if (!Array.isArray(content)) return ''; const parts = content.map((block) => (block && block.type === 'text' ? block.text || '' : '')); return truncate(parts.filter(Boolean).join('\n'), MAX_TOOL_TEXT); } // Trust the bytes, not the declared type: only real raster images are shown // or sent to Claude as images (never SVG/HTML that merely claims to be one). function sniffImage(buf) { if (!buf || buf.length < 12) return null; if (buf[0] === 0x89 && buf.toString('ascii', 1, 4) === 'PNG') return 'image/png'; if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; const six = buf.toString('ascii', 0, 6); if (six === 'GIF87a' || six === 'GIF89a') return 'image/gif'; if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'; return null; } // Small UTF-8 files without NUL bytes can be previewed as text in the chat. function looksLikeText(buf) { if (!buf || !buf.length || buf.includes(0)) return false; try { new TextDecoder('utf-8', { fatal: true }).decode(buf); return true; } catch { return false; } } // Appended to Claude Code's own system prompt for chat sessions. Fixed for a // whole conversation (see ChatSession.conversationAppend). function webUiPrompt(storage = {}, prefs = {}, engine = 'claude', backend = '') { const lines = [ `You are running inside Agent Web, a self-hosted web app (chat and terminal) on a Linux container. The user reads your replies in the chat, often on a phone.`, engine === 'gemini' && backend === 'agy' ? '- To give the user a file (text, code, logs, CSV, documents, archives, images...), save it to disk and call the share_file tool of the "agentweb" MCP server (call_mcp_tool, ServerName "agentweb", ToolName "share_file", Arguments {"paths": []}): it shows up in the chat as a download card. Do this whenever the user asks you to send, share or give them a file; a file:// link alone cannot be opened by the user. Prefer it over pasting very long content.' : engine === 'gemini' ? '- To give the user a file, save it in the working directory and tell them its path (they can open it from the Agent Web file panel). Prefer that over pasting very long content.' : '- To give the user a file (text, code, logs, CSV, documents, archives, images...), save it to disk and call the mcp__agentweb__share_file tool with its path: it shows up in the chat as a download card. Do this whenever the user asks you to send, share or give them a file, and prefer it over pasting very long content.', storage.persistent ? `- Storage: ${storage.home} and the session folders in ${storage.workspaceRoot} are on the Space's Storage Bucket and survive restarts; everything else, /tmp included, is reset. The bucket does NOT keep symlinks, hard links or execute permissions: never install programs, virtualenvs, node_modules or MCP servers there by hand. Use \`agentweb-install\` (run \`agentweb-install help\`): it keeps the sources on the bucket and rebuilds the runnable part on local disk after every restart. Plain \`npm install -g\`, \`uv tool install\` and \`pip install --user\` are recorded and restored automatically.` : `- Storage: no Storage Bucket is attached, so everything here (${storage.home || 'HOME'} included) is lost when the Space restarts. If the user wants files, logins or MCP servers to last, tell them to attach a Storage Bucket at /data in the Space settings.`, '- Credentials: never put tokens or passwords into URLs, commands or files you write (e.g. https://user:TOKEN@host). git already authenticates to github.com and huggingface.co through credential helpers (GH_TOKEN/GITHUB_TOKEN, HF_TOKEN or `gh auth login` / `hf auth login`). Secrets in the chat history are masked automatically.', ]; if (prefs.language === 'tr') { lines.push('- Language: the user is Turkish. Write your replies in Turkish. When you use extended thinking, think in Turkish too, so the thinking summaries they see are Turkish. Never write thinking or reasoning tags (such as [thinking] or ) into the visible reply. Code, identifiers, commands and file contents stay as they should be (usually English).'); } if (prefs.avatar) { lines.push(`- Avatar: the chat shows an animated companion that mirrors your mood. Start every reply with exactly one emotion tag from [${EMOTIONS.join('], [')}] (e.g. "[happy] Tamamdır!"). The tag is hidden from the user; do not mention it.`); } if (prefs.gemini && engine !== 'gemini') { lines.push('- Gemini helper: the mcp__agentweb__ask_gemini tool asks Google Gemini (1M-token context, Google Search grounding, good at images, PDFs, audio and video). Delegate to it to save the user\'s Claude usage: web research, reading and summarising very large files or logs, first-pass overviews of big codebases, and analysing images/screenshots/PDFs/media (pass file paths). You stay responsible for planning, coding, editing and verifying; treat Gemini\'s answers as leads to check, not facts, and never send it secrets.'); } return lines.join('\n'); } function safeFileName(raw) { let name = String(raw || '').normalize('NFC'); name = name.split(/[\\/]/).pop(); name = name.replace(/[\u0000-\u001f\u007f:*?"<>|]/g, '_').trim().replace(/^\.+/, ''); if (!name) name = 'dosya'; if (name.length > 120) { const ext = path.extname(name).slice(0, 16); name = name.slice(0, 120 - ext.length) + ext; } return name; } function uniqueName(dir, name) { const ext = path.extname(name); const base = name.slice(0, name.length - ext.length); let candidate = name; for (let i = 2; fs.existsSync(path.join(dir, candidate)); i += 1) candidate = `${base} (${i})${ext}`; return candidate; } function formatBytes(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function httpError(status, message) { const err = new Error(message); err.status = status; return err; } function num(value) { return typeof value === 'number' && Number.isFinite(value) ? value : 0; } const USAGE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite', 'cost']; function tokenSum(u) { return u ? u.input + u.output + u.cacheRead + u.cacheWrite : 0; } // modelUsage is a running total per model for the whole query() call. function totalsFromModelUsage(modelUsage) { const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, models: {} }; if (!modelUsage || typeof modelUsage !== 'object') return totals; for (const [model, u] of Object.entries(modelUsage)) { if (!u || typeof u !== 'object') continue; const entry = { input: num(u.inputTokens), output: num(u.outputTokens), cacheRead: num(u.cacheReadInputTokens), cacheWrite: num(u.cacheCreationInputTokens), cost: num(u.costUSD), }; totals.models[model] = entry; for (const key of USAGE_KEYS) totals[key] += entry[key]; } return totals; } // This turn's share of a running total; null when the totals went backwards // (e.g. a /clear or compaction reset them), so the caller falls back. function usageDelta(current, base) { const delta = { models: [] }; for (const key of USAGE_KEYS) { delta[key] = current[key] - num(base[key]); if (delta[key] < -1e-9) return null; } const baseModels = base.models || {}; for (const [model, entry] of Object.entries(current.models)) { const before = baseModels[model] || {}; const beforeSum = num(before.input) + num(before.output) + num(before.cacheRead) + num(before.cacheWrite); if (tokenSum(entry) > beforeSum) delta.models.push(model); } return delta; } function serverHost(config) { if (!config || typeof config.url !== 'string') return ''; try { return new URL(config.url).hostname; } catch { return ''; } } function summarizeMcp(server) { const s = server || {}; return { name: String(s.name || ''), status: String(s.status || ''), error: s.error ? truncate(String(s.error), 600) : '', scope: String(s.scope || s.source || ''), tools: Array.isArray(s.tools) ? s.tools.length : null, host: serverHost(s.config), // Where the definition lives (user, local, project, claudeai, plugin...); // decides whether it can be removed from here. source: String(s.source || s.scope || ''), // Its files are in /tmp (gone after the next restart). tmpPath: truncate(tempPathOf(s.config), 300), }; } // Whether a server's files in /tmp are still there (checked when shown). function withTempState(server) { if (!server.tmpPath) return server; return { ...server, tmpGone: !fs.existsSync(server.tmpPath) }; } function validServerName(name) { return typeof name === 'string' && name.length > 0 && name.length <= 200 && !/[\u0000-\u001f\u007f]/.test(name); } // "https://host[:port]" for a browser origin, or '' for anything else. function normalizeOrigin(value) { if (typeof value !== 'string' || !value) return ''; try { const url = new URL(value); return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : ''; } catch { return ''; } } // The sign-in page comes from the MCP server's metadata: only ever hand the // browser a plain web address (never javascript: or data: URLs). function safeAuthUrl(value) { if (typeof value !== 'string' || value.length > MAX_CALLBACK_URL) return ''; try { const url = new URL(value); return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : ''; } catch { return ''; } } function hasAuthCode(value) { try { const url = new URL(value); return url.searchParams.has('code') || url.searchParams.has('error'); } catch { return false; } } function sendTo(ws, msg) { if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg)); } function withTimeout(promise, ms, message) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } // The tags at the very start of a reply; the rest is the visible text. function splitEmotion(text) { if (typeof text !== 'string') return { text, emotion: null }; const m = EMOTION_TAGS_RE.exec(text); if (!m) return { text, emotion: null }; const tags = m[1].match(/\[([a-z]+)\]/gi) || []; const emotion = tags.length ? tags[tags.length - 1].slice(1, -1).toLowerCase() : null; return { text: text.slice(m[0].length), emotion }; } function newChatId() { return `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; } // App-wide preferences (Settings screen); they apply to new conversations. function normalizePrefs(p) { const src = p && typeof p === 'object' ? p : {}; return { language: src.language === 'auto' ? 'auto' : 'tr', avatar: src.avatar === true, readGuard: src.readGuard !== false, gemini: src.gemini !== false, }; } function normalizeSettings(s) { const src = s || {}; return { model: typeof src.model === 'string' && MODEL_RE.test(src.model) ? src.model : '', effort: EFFORT_LEVELS.includes(src.effort) ? src.effort : '', permissionMode: PERMISSION_MODES.includes(src.permissionMode) ? src.permissionMode : 'default', engine: ENGINES.includes(src.engine) ? src.engine : 'claude', geminiModel: typeof src.geminiModel === 'string' && gemini.MODEL_RE.test(src.geminiModel) ? src.geminiModel : '', }; } // Only accept answers for the questions Claude actually asked, all of them non-empty. function cleanAnswers(answers, input) { if (!answers || typeof answers !== 'object') return null; const questions = Array.isArray(input && input.questions) ? input.questions : []; if (!questions.length) return null; const out = {}; for (const q of questions) { const value = answers[q.question]; if (typeof value !== 'string' || !value.trim()) return null; out[q.question] = value.trim().slice(0, MAX_ANSWER_CHARS); } return out; } // A conversation as Markdown (the "Dışa aktar" button). function eventsToMarkdown(events, title) { const out = [`# ${title || 'Sohbet'}`, '']; const when = (ts) => (ts ? new Date(ts).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : ''); for (const e of events) { if (e.parent) continue; if (e.t === 'user') { out.push(`## 🧑 Sen${e.ts ? ` · ${when(e.ts)}` : ''}`, '', e.text || ''); if (Array.isArray(e.attachments) && e.attachments.length) out.push('', `_Ekler: ${e.attachments.map((a) => a.name).join(', ')}_`); out.push(''); } else if (e.t === 'text') { out.push(`## 🤖 ${e.engine === 'gemini' ? 'Gemini' : 'Claude'}`, '', e.text || '', ''); } else if (e.t === 'tool') { const i = e.input || {}; const summary = i.command || i.file_path || i.pattern || i.url || i.query || i.description || ''; out.push(`> 🔧 **${e.name}**${summary ? `: \`${String(summary).replace(/`/g, "'").slice(0, 300)}\`` : ''}`, ''); } else if (e.t === 'error') { out.push(`> ⚠️ ${e.message}`, ''); } else if (e.t === 'notice') { out.push(`> ℹ️ ${e.text}`, ''); } else if (e.t === 'result' && e.usage) { const u = e.usage; out.push(`_— ${Math.round((e.durationMs || 0) / 1000)} sn · ${u.input + u.cacheRead + u.cacheWrite} giriş / ${u.output} çıkış token_`, ''); } } return out.join('\n'); } class InputQueue { constructor() { this.items = []; this.waiters = []; this.closed = false; } push(item) { if (this.closed) return false; const waiter = this.waiters.shift(); if (waiter) waiter({ value: item, done: false }); else this.items.push(item); return true; } close() { this.closed = true; for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true }); } [Symbol.asyncIterator]() { return { next: () => { if (this.items.length) return Promise.resolve({ value: this.items.shift(), done: false }); if (this.closed) return Promise.resolve({ value: undefined, done: true }); return new Promise((resolve) => this.waiters.push(resolve)); }, return: () => { this.close(); return Promise.resolve({ value: undefined, done: true }); }, }; } } class ChatSession { constructor(manager, name) { this.manager = manager; this.name = name; this.cwd = manager.workdirFor(name); this.dir = path.join(manager.chatsRoot, name); this.stateFile = path.join(this.dir, 'state.json'); this.eventsFile = path.join(this.dir, 'events.jsonl'); this.clients = new Set(); this.run = null; this.starting = null; this.running = false; this.stopRequested = false; this.unanswered = []; this.pending = new Map(); this.deferredPerms = new Map(); this.seenToolIds = new Set(); this.drafts = new Map(); this.dirtyDrafts = new Set(); this.draftTimer = null; this.currentMessageId = null; this.lastInit = null; // Where each MCP server is defined (and whether it runs from /tmp), from // the last live list: the list kept while Claude is stopped still says // what can be removed or needs a permanent install. this.mcpInfo = new Map(); // The newest MCP list seen (Claude's start or a live status), shown while // Claude is stopped. Claude Code reports its start only with a message, // so "start and show the list" leaves just the live list. this.mcpLast = null; // Removed while the running Claude still has them: kept out of mcpLast. this.mcpDropped = new Set(); this.idleTimer = null; this.mediaDir = path.join(this.dir, 'media'); this.filesDir = path.join(this.dir, 'files'); this.toolNames = new Map(); this.uploads = new Map(); // MCP server name -> the Claude process its sign-in was started in. this.authRuns = new Map(); // An MCP server was removed while Claude was answering. this.restartPending = false; this.turn = null; this.usageTimer = null; this.seq = 0; this.archiveDir = path.join(this.dir, 'archive'); // A message held back until the user decides how to go on (see send()). this.heldSend = null; // Archive + new chat once the running "write the handoff note" turn ends. this.newChatAfterTurn = false; this.mapTimer = null; // The Gemini CLI process of the running Gemini turn. this.gemRun = null; // Messages waiting for the running turn of the other engine to end. this.later = []; // 'claude' | 'gemini' while a turn runs. this.activeEngine = null; this.state = this.loadState(); this.events = this.loadEvents(); } loadState() { const saved = readJson(this.stateFile, null); const fresh = { sessionId: null, settings: { ...this.manager.defaults }, usageBase: null, chatId: newChatId(), createdAt: Date.now(), append: null, contextTokens: 0, lastActivity: 0, carry: null, title: '', resumeAt: null, geminiSessionId: null, // Which CLI owns geminiSessionId ('agy' | 'gemini'). geminiBackend: null, // Last event seq each engine was given (see catchUp). seen: { claude: 0, gemini: 0 }, }; if (!saved) return fresh; return { ...fresh, sessionId: typeof saved.sessionId === 'string' ? saved.sessionId : null, settings: this.manager.normalize(saved.settings), usageBase: saved.usageBase && typeof saved.usageBase === 'object' ? saved.usageBase : null, chatId: typeof saved.chatId === 'string' && ARCHIVE_ID_RE.test(saved.chatId) ? saved.chatId : fresh.chatId, createdAt: num(saved.createdAt) || fresh.createdAt, // The system prompt addition this conversation started with. append: typeof saved.append === 'string' ? saved.append : null, contextTokens: num(saved.contextTokens), lastActivity: num(saved.lastActivity), // A summary of the previous conversation, for the next one's prompt. carry: typeof saved.carry === 'string' ? saved.carry : null, title: typeof saved.title === 'string' ? saved.title : '', resumeAt: saved.resumeAt && typeof saved.resumeAt.at === 'string' ? saved.resumeAt : null, geminiSessionId: typeof saved.geminiSessionId === 'string' ? saved.geminiSessionId : null, geminiBackend: saved.geminiBackend === 'agy' ? 'agy' : 'gemini', seen: { claude: num(saved.seen && saved.seen.claude), gemini: num(saved.seen && saved.seen.gemini) }, }; } saveState() { writeJson(this.stateFile, this.state); } loadEvents() { let lines; try { lines = fs.readFileSync(this.eventsFile, 'utf8').split('\n').filter(Boolean); } catch { return []; } const events = []; const chain = new Map(); for (const line of lines.slice(-MAX_HISTORY * 2)) { let ev; try { ev = JSON.parse(line); } catch { continue; // yarim yazilmis satir, atla } // Marker lines carry the transcript uuid of an earlier event. if (ev && ev.t === 'chain') chain.set(ev.ref, ev.auuid); else if (ev) events.push(ev); } if (events.length > MAX_HISTORY) events.splice(0, events.length - MAX_HISTORY); for (const ev of events) if (chain.has(ev.seq)) ev.auuid = chain.get(ev.seq); if (lines.length > MAX_HISTORY * 3) { try { fs.writeFileSync(this.eventsFile, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); } catch (err) { console.error('[chat] gecmis sikistirilamadi:', err.message); } } this.seq = events.length ? events[events.length - 1].seq || events.length : 0; // A server restart kills the Claude process, so permission prompts left // open in the log can never be answered anymore. const done = new Set(events.filter((e) => e.t === 'perm_done').map((e) => e.id)); const stale = events.filter((e) => e.t === 'perm' && !done.has(e.id)); for (const e of stale) { const closed = { t: 'perm_done', id: e.id, decision: 'cancelled', seq: ++this.seq, ts: Date.now() }; events.push(closed); this.appendToDisk(closed); } // Same for "how to go on" questions about a held message. const answered = new Set(events.filter((e) => e.t === 'choice_done').map((e) => e.id)); for (const e of events.filter((ev) => ev.t === 'choice' && !answered.has(ev.id))) { const closed = { t: 'choice_done', id: e.id, choice: 'expired', seq: ++this.seq, ts: Date.now() }; events.push(closed); this.appendToDisk(closed); } return events; } appendToDisk(event) { try { fs.mkdirSync(this.dir, { recursive: true }); fs.appendFileSync(this.eventsFile, JSON.stringify(event) + '\n'); } catch (err) { console.error('[chat] olay yazilamadi:', err.message); } } addEvent(ev) { // Tokens and passwords never reach the history file or a browser. const event = { ...redactDeep(ev), seq: ++this.seq, ts: Date.now() }; this.events.push(event); if (this.events.length > MAX_HISTORY) this.events.splice(0, this.events.length - MAX_HISTORY); this.appendToDisk(event); this.broadcast({ type: 'event', event }); return event; } broadcast(msg) { const data = JSON.stringify(msg); for (const ws of this.clients) { if (ws.readyState === ws.OPEN) ws.send(data); } } status() { const prefs = this.manager.prefs; return { running: this.running, chatId: this.state.chatId, contextTokens: this.state.contextTokens, lastActivity: this.state.lastActivity, cacheColdMs: CACHE_COLD_MS, resumeWarnTokens: RESUME_WARN_TOKENS, prefs: { language: prefs.language, avatar: prefs.avatar, readGuard: prefs.readGuard }, conversationAvatar: this.conversationHas('avatar'), gemini: this.manager.geminiStatus(), memory: this.memoryStatus(), settings: this.state.settings, activeEngine: this.running ? this.activeEngine || 'claude' : null, queued: this.later.length, geminiModels: this.manager.geminiModels(), geminiInit: this.lastGeminiInit || null, hasSession: !!this.state.sessionId, models: this.manager.getModels(), modelsSource: this.manager.models ? 'cli' : 'fallback', init: this.lastInit, rateLimit: this.manager.rateLimit, canBypass: this.manager.canBypass, cwd: this.cwd, turn: this.running ? this.publicTurn() : null, now: Date.now(), }; } broadcastStatus() { this.broadcast({ type: 'status', status: this.status() }); } // origin: the page's origin as the browser reported it; OAuth sign-ins // redirect back to it. attach(ws, origin) { ws.agentwebOrigin = normalizeOrigin(origin); this.clients.add(ws); ws.send(JSON.stringify({ type: 'hello', history: this.events, status: this.status() })); for (const [key, draft] of this.drafts) { ws.send(JSON.stringify({ type: 'draft', key, kind: draft.kind, text: redactText(draft.text) })); } ws.agentwebVisible = true; ws.on('message', (raw) => this.handleClientMessage(raw, ws)); ws.on('close', () => this.clients.delete(ws)); ws.on('error', () => this.clients.delete(ws)); // An outdated map is refreshed in the background when a session opens. if (memory.mapAge(this.cwd) > MAP_STALE_MS) this.scheduleMapRefresh(0); this.runSetupOnce(); } // .agentweb/setup.sh rebuilds what a restart wiped (a virtualenv, a // toolchain in local disk ...): run once per container start, in the // background, the first time the session is opened. runSetupOnce() { if (this.manager.setupStarted.has(this.name)) return; this.manager.setupStarted.add(this.name); const script = path.join(memory.agentwebDir(this.cwd), 'setup.sh'); if (!fs.existsSync(script)) return; const logFile = path.join(memory.agentwebDir(this.cwd), 'setup.log'); this.addEvent({ t: 'notice', text: 'Proje ortamı hazırlanıyor (.agentweb/setup.sh, arka planda)…' }); let out; try { out = fs.openSync(logFile, 'w'); } catch { out = 'ignore'; } const started = Date.now(); const child = spawn('bash', [script], { cwd: this.cwd, env: this.manager.childEnv, stdio: ['ignore', out, out] }); const timer = setTimeout(() => child.kill('SIGKILL'), 30 * 60 * 1000); timer.unref(); child.on('error', () => {}); child.on('close', (code) => { clearTimeout(timer); if (typeof out === 'number') fs.closeSync(out); const secs = Math.round((Date.now() - started) / 1000); this.addEvent({ t: 'notice', text: code === 0 ? `Proje ortamı hazır (${secs} sn).` : `.agentweb/setup.sh hata verdi (çıkış ${code}); ayrıntı .agentweb/setup.log dosyasında.`, }); }); } // Whether this conversation's prompt was built with a feature (avatar // emotion tags, Turkish, Gemini) - fixed when the conversation started. conversationHas(feature) { const append = this.state.append; if (!append) return !this.state.sessionId && !!this.manager.prefs[feature]; const marks = { avatar: '- Avatar:', language: '- Language:', gemini: '- Gemini helper:' }; return append.includes(marks[feature]); } memoryStatus() { const stats = memory.mapStats(this.cwd); const { memory: mem, handoff } = memory.readMemory(this.cwd); return { files: stats ? stats.files : 0, lines: stats ? stats.lines : 0, memoryChars: mem.length, handoffChars: handoff.length }; } // Someone is looking at this chat (a visible tab): no push notification. hasVisibleClient() { for (const ws of this.clients) if (ws.readyState === ws.OPEN && ws.agentwebVisible !== false) return true; return false; } notify(kind, body) { if (this.hasVisibleClient()) return; this.manager.notify({ session: this.name, kind, body }).catch(() => {}); } handleClientMessage(raw, ws) { let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } if (!msg || typeof msg !== 'object') return; const fail = (err) => this.addEvent({ t: 'error', message: errMessage(err) }); switch (msg.type) { case 'send': this.send(typeof msg.text === 'string' ? msg.text : '', msg.attachments, { editOf: typeof msg.editOf === 'string' ? msg.editOf : null, target: msg.target === 'gemini' ? 'gemini' : msg.target === 'claude' ? 'claude' : null, }).catch(fail); break; case 'choice': this.answerChoice(String(msg.id || ''), String(msg.choice || '')).catch(fail); break; case 'handoff_new': this.handoffAndNewChat().catch(fail); break; case 'visibility': ws.agentwebVisible = msg.visible !== false; break; case 'commands': this.sendCommands(ws).catch(() => {}); break; case 'stop': this.stop().catch(fail); break; case 'perm': this.answerPermission(String(msg.id || ''), msg); break; case 'settings': this.applySettings(msg.settings || {}).catch(fail); break; case 'new_chat': this.newChat({ summary: msg.summary === true }); break; case 'mcp_status': this.sendMcpStatus(ws, null, msg.start === true).catch(() => {}); break; case 'mcp_reconnect': this.reconnectMcp(ws, msg.name).catch(() => {}); break; case 'mcp_auth': this.authenticateMcp(ws, msg.name).catch(() => {}); break; case 'mcp_auth_url': this.submitMcpCallback(msg.name, msg.url).catch(() => {}); break; case 'mcp_remove': this.removeMcp(ws, msg.name, msg.scope).catch(() => {}); break; case 'mcp_toggle': this.toggleMcp(ws, msg.name, msg.enabled).catch(() => {}); break; case 'restart': this.restart(ws); break; default: break; } } // Which engine answers a message: the session's engine, or in a mixed // chat the one the user picked for it. targetFor(requested) { const engine = this.state.settings.engine; if (engine === 'mixed') return requested === 'gemini' ? 'gemini' : 'claude'; return engine === 'gemini' ? 'gemini' : 'claude'; } async send(text, attachmentIds, opts = {}) { const prompt = text.trim(); if (prompt.length > MAX_PROMPT_CHARS) { this.addEvent({ t: 'error', message: 'Mesaj çok uzun.' }); return; } const target = this.targetFor(opts.target); if (target === 'gemini') { const g = this.manager.geminiStatus(); if (!g.ready) { this.addEvent({ t: 'error', message: g.installed ? 'Gemini hazır değil: Terminal sekmesinde `agy` çalıştırıp Google hesabınla giriş yap (ya da GEMINI_API_KEY secret\'ı ekle).' : 'Gemini için ne Antigravity CLI (agy) ne Gemini CLI kurulu.' }); return; } } if (opts.editOf && !this.running) { if (!this.prepareEdit(opts.editOf)) return; } // Resuming a big conversation whose prompt cache has expired rewrites it // whole (a large share of the usage limit): ask first. if (target === 'claude' && !opts.decided && !this.running && !this.heldSend && this.resumeIsExpensive()) { const files = this.takeUploads(attachmentIds); if (!prompt && !files.length) return; const id = crypto.randomUUID(); this.heldSend = { id, prompt, files, target }; const idleMin = Math.round((Date.now() - this.state.lastActivity) / 60000); this.addEvent({ t: 'choice', id, kind: 'resume', contextTokens: this.state.contextTokens, idleMin, preview: prompt.slice(0, 300), attachments: files.map((f) => f.name), }); this.notify('choice', 'Devam etmeden önce bir seçim bekleniyor.'); return; } const files = opts.files || this.takeUploads(attachmentIds); if (!prompt && !files.length) return; const uuid = crypto.randomUUID(); const event = { t: 'user', text: prompt, uuid }; if (opts.auto) event.auto = true; if (target === 'gemini' || this.state.settings.engine === 'mixed') event.target = target; if (files.length) { event.attachments = files.map((f) => ({ name: f.name, path: f.path, size: f.size, image: f.image, mediaId: f.mediaId })); } if (!this.events.some((e) => e.t === 'user')) { this.state.title = prompt.slice(0, 120) || (files[0] && files[0].name) || ''; this.saveState(); } this.addEvent(event); const job = { target, prompt, files, uuid }; // One turn at a time across engines; Claude's own follow-ups still go // straight into its running turn. if (this.running && (this.activeEngine === 'gemini' || target === 'gemini')) { this.later.push(job); this.broadcastStatus(); return; } await this.dispatch(job); } async dispatch(job) { if (job.target === 'gemini') { this.runGemini(job); return; } const catchup = this.catchUp('claude', job.uuid); const content = this.buildUserContent(catchup ? `${catchup}\n\n${job.prompt}` : job.prompt, job.files); const message = { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, uuid: job.uuid }; this.unanswered.push(message); if (!this.running) this.startTurn(job.prompt); this.running = true; this.activeEngine = 'claude'; this.clearIdle(); this.broadcastStatus(); try { await this.ensureQuery(); this.run.input.push(message); } catch (err) { this.unanswered = []; this.running = false; this.activeEngine = null; this.addEvent({ t: 'error', message: `Claude başlatılamadı: ${errMessage(err)}` }); this.broadcastStatus(); this.drainLater(); } } // The next message held back while the other engine was answering. drainLater() { if (this.running || !this.later.length) return; const job = this.later.shift(); this.dispatch(job).catch((err) => this.addEvent({ t: 'error', message: errMessage(err) })); } // What the other engine said since this one last took part: in a chat // shared by Claude and Gemini each sees the whole conversation. catchUp(engine, exceptUuid) { const since = num(this.state.seen && this.state.seen[engine]); const items = []; for (const e of this.events) { if (num(e.seq) <= since || e.parent) continue; if (e.t === 'user' && !e.auto && e.uuid !== exceptUuid && (e.target || 'claude') !== engine && e.text) { items.push(`Kullanıcı → ${e.target === 'gemini' ? 'Gemini' : 'Claude'}: ${clipText(e.text, CATCHUP_ITEM_CHARS)}`); } else if (e.t === 'text' && (e.engine || 'claude') !== engine && e.text) { items.push(`${e.engine === 'gemini' ? 'Gemini' : 'Claude'}: ${clipText(e.text, CATCHUP_ITEM_CHARS)}`); } } this.state.seen = { ...(this.state.seen || {}), [engine]: this.seq }; this.saveState(); if (!items.length) return ''; const kept = []; let total = 0; for (let i = items.length - 1; i >= 0; i -= 1) { total += items[i].length; if (total > CATCHUP_MAX_CHARS && kept.length) break; kept.unshift(items[i]); } const other = engine === 'gemini' ? 'Claude' : 'Gemini'; return [ `[Agent Web: this chat is shared with another assistant (${other}). What was said while you were not answering${kept.length < items.length ? ' (latest part)' : ''}:]`, ...kept, '[End of the shared part. The user\'s new message to you follows.]', ].join('\n'); } // ---- Gemini turns: one headless `gemini -p` process each ---- // Gemini's instructions for a fresh Gemini session (it has no system // prompt option): the Agent Web notes and the project memory. geminiPrimer() { const base = webUiPrompt(this.manager.storage, this.manager.prefs, 'gemini', this.state.geminiBackend); const primer = memory.buildPrimer(this.cwd, { previousChat: this.state.carry, engine: 'gemini' }); return `\n${base}\n${primer}\n`; } runGemini(job, retried = false) { const status = this.manager.geminiStatus(); const bin = status.bin; if (!bin) { this.addEvent({ t: 'error', message: 'Gemini için ne Antigravity CLI (agy) ne Gemini CLI kurulu.' }); this.drainLater(); return; } const backend = status.backend; const agy = backend === 'agy'; // A session of the other CLI (or an agy conversation that is gone) cannot // be continued: start fresh with the whole conversation as context. const lost = !!this.state.geminiSessionId && (this.state.geminiBackend !== backend || (agy && !gemini.agyConversationExists(this.manager.home, this.state.geminiSessionId))); if (lost) { this.state.geminiSessionId = null; this.state.seen = { ...(this.state.seen || {}), gemini: 0 }; } this.state.geminiBackend = backend; this.manager.ensureWorkspace(this.name); const settings = this.state.settings; const resume = !!this.state.geminiSessionId; const sessionId = this.state.geminiSessionId || crypto.randomUUID(); const parts = []; if (!resume) parts.push(this.geminiPrimer()); const catchup = this.catchUp('gemini', job.uuid); if (catchup) parts.push(catchup); parts.push(job.prompt || 'Ekteki dosyalara bak.'); const files = (job.files || []).filter((f) => f && f.path).map((f) => String(f.path)); if (files.length) parts.push(agy ? `Ekler (dosya yolları): ${files.join(', ')}` : `Ekler: ${files.map((p) => `@${p.replace(/ /g, '\\ ')}`).join(' ')}`); const models = new Set(this.manager.geminiModels().map((m) => m.value)); const model = models.has(settings.geminiModel) ? settings.geminiModel : ''; const args = agy ? gemini.agyArgs({ conversationId: resume ? sessionId : null, model, permissionMode: settings.permissionMode }) : gemini.buildArgs({ sessionId, resume, model, approval: gemini.approvalFor(settings.permissionMode) }); const child = spawn(bin, args, { cwd: this.cwd, env: { ...this.manager.childEnv, NO_COLOR: '1' }, stdio: ['pipe', 'pipe', 'pipe'] }); const run = { id: crypto.randomBytes(4).toString('hex'), child, job, backend, translate: agy ? gemini.agyTranslator() : null, shareIds: new Set(), resume, retried, stderr: [], part: 0, draftKey: null, result: null, stopped: false, closing: false, spawnError: null, killTimer: null }; if (lost) { this.addEvent({ t: 'notice', text: 'Önceki Gemini oturumu sürdürülemedi; sohbetin şimdiye kadarki kısmıyla yeni bir Gemini oturumu açıldı.' }); } this.gemRun = run; if (!this.running) this.startTurn(job.prompt); this.running = true; this.activeEngine = 'gemini'; this.clearIdle(); this.broadcastStatus(); if (memory.mapAge(this.cwd) > MAP_STALE_MS) this.scheduleMapRefresh(0); child.on('error', (err) => { run.spawnError = err; }); child.stdin.on('error', () => {}); const prompt = parts.join('\n\n'); child.stdin.end(agy ? gemini.agyInput(prompt) : prompt); readline.createInterface({ input: child.stdout }).on('line', (line) => { if (run.closing) return; const ev = gemini.parseLine(line); if (!ev) return; for (const e of run.translate ? run.translate(ev) : [ev]) this.handleGeminiEvent(run, e); }); child.stderr.on('data', (data) => { for (const line of String(data).split('\n')) if (line.trim()) run.stderr.push(line); if (run.stderr.length > 40) run.stderr.splice(0, run.stderr.length - 40); }); child.on('close', (code) => this.finishGemini(run, code)); } handleGeminiEvent(run, ev) { switch (ev.type) { case 'init': if (typeof ev.session_id === 'string' && ev.session_id !== this.state.geminiSessionId) { this.state.geminiSessionId = ev.session_id; this.saveState(); } this.lastGeminiInit = { model: String(ev.model || '') }; break; case 'message': if (ev.role === 'assistant' && typeof ev.content === 'string') this.geminiDelta(run, ev.content); break; case 'text_break': this.finishGeminiText(run); break; case 'tool_use': { this.finishGeminiText(run); const name = String(ev.tool_name || 'tool'); const id = String(ev.tool_id || crypto.randomUUID()); this.addEvent({ t: 'tool', id, name, input: capInput(ev.parameters || {}), parent: null, engine: 'gemini' }); if (this.turn) this.turn.tools.add(name); if (name === SHARE_TOOL) run.shareIds.add(id); break; } case 'tool_result': { const failed = ev.status === 'error'; const id = String(ev.tool_id || ''); const content = failed ? (ev.error && ev.error.message) || ev.output || 'Hata' : ev.output || 'Tamam'; const event = { t: 'tool_result', id, content: truncate(String(content), MAX_TOOL_TEXT), isError: failed, parent: null }; // agy gives the MCP result as plain text: the note, then the JSON list on its own line. if (run.shareIds.has(id) && !failed) { const shared = this.collectSharedFiles(String(ev.output || '').split('\n').map((line) => ({ type: 'text', text: line }))); event.content = [shared.text, ...shared.errors].filter(Boolean).join('\n'); if (shared.files.length) event.files = shared.files; } this.addEvent(event); break; } case 'error': this.finishGeminiText(run); if (ev.severity === 'warning') this.addEvent({ t: 'notice', text: `Gemini: ${String(ev.message || '')}` }); else this.addEvent({ t: 'error', message: gemini.friendlyError(ev.message) }); break; case 'result': this.finishGeminiText(run); run.result = ev; if (typeof ev.session_id === 'string' && ev.session_id && ev.session_id !== this.state.geminiSessionId) { this.state.geminiSessionId = ev.session_id; this.saveState(); } if (Array.isArray(ev.denied) && ev.denied.length) { this.addEvent({ t: 'notice', text: `Gemini şu işlemleri izin sorulamadığı için yapamadı: ${ev.denied.join(', ')}. Kabuk komutları için izin modunu “Otomatik” ya da “Tüm izinleri atla” yap.` }); } break; default: break; } } geminiDelta(run, content) { if (!run.draftKey) { run.part += 1; run.draftKey = `gem-${run.id}-${run.part}`; this.drafts.set(run.draftKey, { kind: 'text', text: '', messageId: run.draftKey }); this.broadcast({ type: 'draft', key: run.draftKey, kind: 'text', text: '' }); } const draft = this.drafts.get(run.draftKey); if (!draft) return; draft.text += content; if (this.turn) this.turn.output += Math.ceil(content.length / 4); this.dirtyDrafts.add(run.draftKey); if (!this.draftTimer) this.draftTimer = setTimeout(() => this.flushDrafts(), DRAFT_FLUSH_MS); } // The streamed text so far becomes a reply block (before a tool call or at the end). finishGeminiText(run) { const key = run.draftKey; if (!key) return; run.draftKey = null; const draft = this.drafts.get(key); this.drafts.delete(key); this.dirtyDrafts.delete(key); this.broadcast({ type: 'draft_done', key }); if (!draft || !draft.text.trim()) return; const split = splitEmotion(draft.text); const ev = { t: 'text', text: split.text, parent: null, draftKey: key, engine: 'gemini' }; if (split.emotion) ev.emotion = split.emotion; if (ev.text.trim() || ev.emotion) this.addEvent(ev); } finishGemini(run, code) { if (run.killTimer) clearTimeout(run.killTimer); if (this.gemRun === run) this.gemRun = null; if (run.closing) return; this.finishGeminiText(run); const res = run.result; const errText = res && res.status !== 'success' ? String((res.error && res.error.message) || res.status || '') : ''; const stderrText = run.stderr.join('\n'); if (!run.stopped && run.resume && !run.retried && gemini.isMissingSession(errText || (!res ? stderrText : ''))) { // Gemini's saved session is gone (e.g. ~/.gemini was reset): start a // new one that gets the conversation so far as shared context. this.state.geminiSessionId = null; this.state.seen = { ...(this.state.seen || {}), gemini: 0 }; this.saveState(); this.addEvent({ t: 'notice', text: 'Önceki Gemini oturumu bulunamadı; sohbetin şimdiye kadarki kısmıyla yeni bir Gemini oturumu açıldı.' }); this.runGemini(run.job, true); return; } const turn = this.turn; const event = { t: 'result', ok: !!res && res.status === 'success' && !run.stopped, subtype: run.stopped ? 'stopped' : res ? String(res.status) : 'error', engine: 'gemini' }; const info = res ? gemini.usageFromStats(res.stats) : null; event.durationMs = info && info.durationMs ? info.durationMs : turn ? Date.now() - turn.startedAt : 0; if (info && (info.usage.input || info.usage.output)) { event.usage = info.usage; if (info.models.length) event.models = info.models; } if (run.stopped) { event.stopped = true; } else if (!res) { const tail = run.stderr.filter((l) => !/^\s+at |\[STARTUP\]|256-color|^\s*[{}]\s*$/.test(l)).slice(-4).join('\n'); event.error = run.spawnError ? `Gemini başlatılamadı: ${errMessage(run.spawnError)}` : gemini.friendlyError(tail || `Gemini beklenmedik şekilde kapandı (çıkış ${code}).`); } else if (errText) { event.error = gemini.friendlyError(errText); } this.addEvent(event); this.state.lastActivity = Date.now(); this.saveState(); if (turn && [...turn.tools].some((name) => CHANGING_TOOLS.has(name))) this.scheduleMapRefresh(undefined, { request: turn.request, since: turn.startedAt }); this.running = false; this.activeEngine = null; this.turn = null; this.notify('done', event.stopped ? 'Yanıt durduruldu.' : event.ok ? 'Gemini yanıtını bitirdi.' : `Hata: ${event.error || 'bilinmeyen'}`); if (this.newChatAfterTurn) { this.newChatAfterTurn = false; this.newChat({ summary: true }); return; } this.broadcastStatus(); this.drainLater(); } stopGemini(run) { run.stopped = true; try { run.child.kill('SIGTERM'); } catch { // zaten kapanmis } run.killTimer = setTimeout(() => { try { run.child.kill('SIGKILL'); } catch { // zaten kapanmis } }, GEMINI_KILL_MS); run.killTimer.unref(); } // Ends a Gemini turn without a trace in the history (new chat, restore). killGemini() { const run = this.gemRun; if (!run) return; run.closing = true; this.gemRun = null; this.stopGemini(run); if (this.activeEngine === 'gemini') { this.running = false; this.activeEngine = null; this.turn = null; } } resumeIsExpensive() { if (!this.state.sessionId || this.run) return false; if (this.state.contextTokens < RESUME_WARN_TOKENS) return false; return Date.now() - this.state.lastActivity > CACHE_COLD_MS; } // The user's answer to the "how to go on" card of a held message. async answerChoice(id, choice) { const held = this.heldSend; if (!held || held.id !== id) return; if (choice !== 'continue' && choice !== 'fresh' && choice !== 'cancel') return; this.heldSend = null; this.addEvent({ t: 'choice_done', id, choice }); if (choice === 'cancel') { for (const f of held.files) this.uploads.set(f.id, f); this.broadcast({ type: 'restore_draft', text: held.prompt }); return; } if (choice === 'fresh') this.newChat({ summary: true }); await this.send(held.prompt, null, { decided: true, files: held.files, target: held.target }); } // Editing (or retrying) the last message: the conversation goes back to // just before it, in the history and in Claude's own transcript. prepareEdit(uuid) { let index = -1; for (let i = this.events.length - 1; i >= 0; i -= 1) { if (this.events[i].t === 'user') { index = this.events[i].uuid === uuid ? i : -1; break; } } if (index === -1) { this.addEvent({ t: 'error', message: 'Sadece son mesaj düzenlenebilir.' }); return false; } const editedTarget = this.events[index].target || 'claude'; const keptSeq = index > 0 ? num(this.events[index - 1].seq) : 0; const seen = this.state.seen || {}; this.state.seen = { claude: Math.min(num(seen.claude), keptSeq), gemini: Math.min(num(seen.gemini), keptSeq) }; if (editedTarget === 'gemini') { // Gemini's own transcript cannot be rewound: a fresh Gemini session // gets the conversation so far as shared context instead. this.killGemini(); this.state.geminiSessionId = null; this.state.seen.gemini = 0; const kept = this.events.slice(0, index); this.events = kept; try { fs.writeFileSync(this.eventsFile, kept.map((e) => JSON.stringify(e)).join('\n') + (kept.length ? '\n' : '')); } catch (err) { console.error('[chat] gecmis yazilamadi:', err.message); } this.saveState(); this.broadcast({ type: 'hello', history: this.events, status: this.status() }); return true; } let forkAt = null; for (let i = index - 1; i >= 0; i -= 1) { if (this.events[i].auuid) { forkAt = this.events[i].auuid; break; } } if (this.run) this.closeRun(this.run, true); this.cancelPendingPermissions(); this.clearDrafts(false); const kept = this.events.slice(0, index); this.events = kept; try { fs.writeFileSync(this.eventsFile, kept.map((e) => JSON.stringify(e)).join('\n') + (kept.length ? '\n' : '')); } catch (err) { console.error('[chat] gecmis yazilamadi:', err.message); } if (forkAt && this.state.sessionId) { this.state.resumeAt = { at: forkAt, drops: uuid }; } else { // The very first message: nothing to keep, a new conversation. this.state.sessionId = null; this.state.usageBase = null; this.state.resumeAt = null; } this.saveState(); this.broadcast({ type: 'hello', history: this.events, status: this.status() }); return true; } ensureQuery() { if (this.run) return Promise.resolve(); if (!this.starting) { this.starting = this.startQuery().finally(() => { this.starting = null; }); } return this.starting; } async startQuery() { const { query } = await this.manager.loadSdk(); this.manager.ensureWorkspace(this.name); const settings = this.state.settings; const run = { input: new InputQueue(), abortController: new AbortController(), query: null, resumedFrom: this.state.sessionId, sawInit: false, closing: false, stderr: [], }; run.guarded = new Set(); const options = { cwd: this.cwd, abortController: run.abortController, env: this.manager.childEnv, includePartialMessages: true, settingSources: ['user', 'project', 'local'], systemPrompt: { type: 'preset', preset: 'claude_code', snapshot: true, append: this.conversationAppend() }, permissionMode: settings.permissionMode, canUseTool: (toolName, toolInput, opts) => this.requestPermission(toolName, toolInput, opts), stderr: (data) => this.captureStderr(run, data), hooks: { PreToolUse: [{ matcher: 'Read', hooks: [async (input) => this.guardRead(input, run)] }], }, }; // Claude Code refuses to start as root with this flag; there bypass mode is simply unavailable. if (this.manager.canBypass) options.allowDangerouslySkipPermissions = true; if (settings.model) options.model = settings.model; if (settings.effort) options.effort = settings.effort; if (this.manager.supportsAdaptive(settings.model)) { options.thinking = { type: 'adaptive', display: 'summarized' }; } if (this.state.sessionId) options.resume = this.state.sessionId; if (this.state.sessionId && this.state.resumeAt) { // Editing the last message: Claude's transcript goes back to before it. options.resumeSessionAt = this.state.resumeAt.at; options.resumeDropsTurn = this.state.resumeAt.drops; run.resumeAt = this.state.resumeAt; this.state.resumeAt = null; this.saveState(); } if (memory.mapAge(this.cwd) > MAP_STALE_MS) this.scheduleMapRefresh(0); run.query = query({ prompt: run.input, options }); this.run = run; // A fresh process reads the current MCP configuration. this.restartPending = false; this.mcpDropped.clear(); this.consume(run); // Refreshed on every start: a newer Claude Code may list new models. run.query.supportedModels().then((models) => this.manager.setModels(models)).catch(() => {}); } // The system prompt addition of this conversation: built once when it // starts (project memory, handoff note, the previous chat's summary) and // reused on every resume, so the cached prompt prefix stays the same. conversationAppend() { if (this.state.sessionId && this.state.append) return this.state.append; if (this.state.sessionId) return this.manager.baseAppend(); const primer = memory.buildPrimer(this.cwd, { previousChat: this.state.carry }); this.state.append = `${this.manager.baseAppend()}\n${primer}`; this.state.carry = null; this.saveState(); return this.state.append; } // PreToolUse hook for Read: a whole-file read of a big text file is turned // back once (per file and conversation) with what the map knows about it, // so Claude reads just the lines it needs. Asking again reads it whole. async guardRead(input, run) { try { if (!this.manager.prefs.readGuard) return {}; const ti = (input && input.tool_input) || {}; const file = typeof ti.file_path === 'string' ? ti.file_path : ''; if (!file || ti.offset !== undefined || ti.limit !== undefined || GUARD_SKIP_EXT.test(file)) return {}; const abs = path.resolve(this.cwd, file); const root = path.resolve(this.cwd) + path.sep; if (!abs.startsWith(root)) return {}; const stat = fs.statSync(abs); if (!stat.isFile() || stat.size < GUARD_MIN_BYTES) return {}; const key = `${abs}:${Math.round(stat.mtimeMs)}`; if (run.guarded.has(key)) return {}; const rel = path.relative(this.cwd, abs); const entry = memory.mapEntry(this.cwd, rel); const lines = entry && entry.size === stat.size && typeof entry.lines === 'number' ? entry.lines : null; if (lines !== null && lines < GUARD_MIN_LINES) return {}; if (lines === null && stat.size < GUARD_MIN_BYTES * 2) return {}; run.guarded.add(key); const parts = [ `Agent Web: ${rel} is large (${lines !== null ? `${lines} lines, ` : ''}${formatBytes(stat.size)}). To save the user's usage limit, read only the part you need with Read offset/limit.`, ]; const note = memory.noteFor(this.cwd, rel); if (note) parts.push(`Your earlier note on this file${note.fresh ? '' : ' (the file changed since)'}: ${note.text}`); const symbols = entry && Array.isArray(entry.symbols) ? entry.symbols.filter(Array.isArray) : []; if (symbols.length) { const shown = symbols.slice(0, GUARD_MAX_SYMBOLS).map(([line, name]) => `${line}:${name}`); parts.push(`Its outline (line:symbol): ${shown.join(', ')}${symbols.length > shown.length ? ', …' : ''}`); } parts.push('If you really need the whole file, call Read again with the same arguments and it will be allowed.'); return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: parts.join('\n') }, }; } catch { return {}; } } // `change` ({ request, since }): after a turn that changed files, so the // map records which files changed (see memory.buildMap). scheduleMapRefresh(delay, change = null) { if (change) this.mapChange = memory.mergeChange(this.mapChange, change); if (this.mapTimer) return; this.mapTimer = setTimeout(() => { this.mapTimer = null; const pending = this.mapChange || null; this.mapChange = null; memory.refreshMap(this.cwd, pending).catch(() => {}); }, typeof delay === 'number' ? delay : MAP_REFRESH_DELAY_MS); this.mapTimer.unref(); } async sendCommands(ws) { let commands = this.manager.commands; const run = this.run; if (run && !run.closing) { try { const list = await withTimeout(run.query.supportedCommands(), MCP_STATUS_TIMEOUT_MS, 'zaman aşımı'); commands = this.manager.setCommands(list); } catch { // son bilinen liste } } sendTo(ws, { type: 'commands', commands: commands || [], live: !!(run && !run.closing) }); } captureStderr(run, data) { for (const line of String(data).split('\n')) { if (line.trim()) run.stderr.push(line); } if (run.stderr.length > 30) run.stderr.splice(0, run.stderr.length - 30); } async consume(run) { let failure = null; try { for await (const msg of run.query) { // After a new chat / forced stop the old process may still flush a // few buffered frames; they must not leak into the next conversation. if (run.closing) continue; if (msg && msg.type === 'system' && msg.subtype === 'init') run.sawInit = true; this.handleSdkMessage(msg, run); } } catch (err) { if (!run.closing) failure = err; } if (this.run === run) this.run = null; this.cancelPendingPermissions(); this.clearDrafts(!run.closing); // A resume that dies before the first init almost always means the old // transcript is gone (e.g. HOME was not persistent); retry as a fresh chat. if (!run.closing && !run.sawInit && run.resumedFrom && this.unanswered.length) { if (run.resumeAt) { // The edit could not be applied to Claude's transcript: go on with it as it is. this.addEvent({ t: 'notice', text: 'Düzenleme Claude\'un konuşma kaydına uygulanamadı; mesaj konuşmanın sonuna eklendi.' }); } else { this.state.sessionId = null; this.state.append = null; this.addEvent({ t: 'notice', text: 'Önceki konuşma devam ettirilemedi, yeni bir konuşma başlatıldı.' }); } this.saveState(); const retry = this.unanswered.splice(0); try { await this.ensureQuery(); this.unanswered.push(...retry); for (const message of retry) this.run.input.push(message); return; } catch (err) { failure = err; } } const claudeTurn = this.running && this.activeEngine !== 'gemini'; if (!run.closing && (failure || claudeTurn)) { const tail = run.stderr.slice(-5).join('\n'); const reason = failure ? errMessage(failure) : 'Claude süreci beklenmedik şekilde kapandı.'; this.addEvent({ t: 'error', message: tail ? `${reason}\n${tail}` : reason }); } this.unanswered = []; if (claudeTurn) { this.running = false; this.activeEngine = null; this.turn = null; this.broadcastStatus(); this.drainLater(); } } handleSdkMessage(msg, run) { if (!msg || typeof msg !== 'object') return; switch (msg.type) { case 'system': this.handleSystem(msg); break; case 'stream_event': this.handleStreamEvent(msg); break; case 'assistant': this.handleAssistant(msg); break; case 'user': this.handleUser(msg); break; case 'result': this.handleResult(msg, run); break; case 'rate_limit_event': this.manager.setRateLimit(msg.rate_limit_info || null); break; case 'auth_status': if (msg.error) this.addEvent({ t: 'error', message: `Kimlik doğrulama: ${msg.error}` }); break; default: break; } } handleSystem(msg) { if (msg.subtype === 'init') { if (msg.session_id && msg.session_id !== this.state.sessionId) { this.state.sessionId = msg.session_id; this.saveState(); } this.lastInit = { model: msg.model || '', permissionMode: msg.permissionMode || '', effort: msg.effort === undefined ? undefined : msg.effort, mcp: Array.isArray(msg.mcp_servers) ? msg.mcp_servers.map((s) => ({ name: s.name, status: s.status })) : [], version: msg.claude_code_version || '', auth: msg.apiKeySource || '', }; this.mcpLast = this.lastInit.mcp.filter((s) => !this.mcpDropped.has(s.name)).map((s) => ({ ...this.mcpInfo.get(s.name), ...s })); this.syncPermissionMode(msg.permissionMode); this.broadcastStatus(); } else if (msg.subtype === 'status') { this.syncPermissionMode(msg.permissionMode); } else if (msg.subtype === 'compact_boundary') { this.addEvent({ t: 'notice', text: 'Konuşma bağlamı sıkıştırıldı.' }); } else if (msg.subtype === 'api_retry') { this.broadcast({ type: 'live', text: `API hatası, yeniden deneniyor (${msg.attempt}/${msg.max_retries})…`, }); } } syncPermissionMode(mode) { if (!PERMISSION_MODES.includes(mode) || mode === this.state.settings.permissionMode) return; this.state.settings = { ...this.state.settings, permissionMode: mode }; this.saveState(); this.broadcastStatus(); } handleStreamEvent(msg) { this.trackUsage(msg); if (msg.parent_tool_use_id) return; const ev = msg.event; if (!ev) return; if (ev.type === 'message_start') { this.currentMessageId = ev.message && ev.message.id; // How big the conversation is now: what a resume would have to re-send. const u = (ev.message && ev.message.usage) || {}; const context = num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens); if (context > 0) this.state.contextTokens = context; } else if (ev.type === 'content_block_start') { const block = ev.content_block || {}; if (block.type !== 'text' && block.type !== 'thinking') return; const key = `${this.currentMessageId}:${ev.index}`; this.drafts.set(key, { kind: block.type, text: '', messageId: this.currentMessageId }); this.broadcast({ type: 'draft', key, kind: block.type, text: '' }); } else if (ev.type === 'content_block_delta') { const key = `${this.currentMessageId}:${ev.index}`; const draft = this.drafts.get(key); if (!draft || !ev.delta) return; if (ev.delta.type === 'text_delta') draft.text += ev.delta.text || ''; else if (ev.delta.type === 'thinking_delta') draft.text += ev.delta.thinking || ''; else return; this.dirtyDrafts.add(key); if (!this.draftTimer) this.draftTimer = setTimeout(() => this.flushDrafts(), DRAFT_FLUSH_MS); } } flushDrafts() { this.draftTimer = null; for (const key of this.dirtyDrafts) { const draft = this.drafts.get(key); if (draft) this.broadcast({ type: 'draft', key, kind: draft.kind, text: redactText(draft.text) }); } this.dirtyDrafts.clear(); } // The CLI emits one assistant message per completed block, in block order, // so the oldest open draft of the same kind is the one that just finished. // Returns its key so the client can swap that exact draft for the final block. finishDraft(messageId, kind) { for (const [key, draft] of this.drafts) { if (draft.messageId === messageId && draft.kind === kind) { this.drafts.delete(key); this.dirtyDrafts.delete(key); this.broadcast({ type: 'draft_done', key }); return key; } } return null; } // Blocks still streaming when a turn ends (stop, crash) never get a final // assistant message; with keepText what was already shown stays in history. clearDrafts(keepText) { if (this.draftTimer) clearTimeout(this.draftTimer); this.draftTimer = null; this.dirtyDrafts.clear(); const open = [...this.drafts]; this.drafts.clear(); for (const [key, draft] of open) { this.broadcast({ type: 'draft_done', key }); if (keepText && draft.text.trim()) { this.addEvent({ t: draft.kind, text: draft.text, parent: null, partial: true, draftKey: key }); } } } handleAssistant(msg) { const message = msg.message || {}; const parent = msg.parent_tool_use_id || null; // For API-level failures the CLI's text block is a synthetic English error // ("Invalid API key · Please run /login"); the Turkish explanation replaces it. // A max_output_tokens message still carries real (truncated) content. const replaced = !!(msg.error && ASSISTANT_ERRORS[msg.error]) && msg.error !== 'max_output_tokens'; if (msg.error && ASSISTANT_ERRORS[msg.error]) { this.addEvent({ t: 'error', code: msg.error, message: ASSISTANT_ERRORS[msg.error] }); } let last = null; for (const block of Array.isArray(message.content) ? message.content : []) { if (!block) continue; if (block.type === 'text') { const draftKey = parent ? null : this.finishDraft(message.id, 'text'); if (block.text && !replaced) { const ev = { t: 'text', text: block.text, parent, draftKey }; if (!parent && this.conversationHas('avatar')) { const split = splitEmotion(block.text); ev.text = split.text; if (split.emotion) ev.emotion = split.emotion; } if (ev.text.trim() || ev.emotion) last = this.addEvent(ev); } } else if (block.type === 'thinking') { const draftKey = parent ? null : this.finishDraft(message.id, 'thinking'); if (block.thinking) last = this.addEvent({ t: 'thinking', text: block.thinking, parent, draftKey }); } else if (block.type === 'tool_use' || block.type === 'server_tool_use' || block.type === 'mcp_tool_use') { last = this.addEvent({ t: 'tool', id: block.id, name: block.name, input: capInput(block.input), parent }); if (!parent && this.turn) this.turn.tools.add(block.name); this.seenToolIds.add(block.id); this.rememberToolName(block.id, block.name); this.releaseDeferredPerm(block.id); } else if (typeof block.type === 'string' && block.type.endsWith('_tool_result') && block.tool_use_id) { const content = typeof block.content === 'object' ? JSON.stringify(block.content) : block.content; last = this.addEvent({ t: 'tool_result', id: block.tool_use_id, content: truncate(String(content || ''), MAX_TOOL_TEXT), isError: false, parent }); } } // Claude's transcript entry for this message: where an edit of the next // user message rewinds to. if (last && !parent && typeof msg.uuid === 'string') this.markChainEntry(last, msg.uuid); } markChainEntry(event, uuid) { event.auuid = uuid; // The event is already on disk without it; a small marker line follows. this.appendToDisk({ t: 'chain', ref: event.seq, auuid: uuid }); } handleUser(msg) { if (msg.isReplay) return; const content = msg.message && msg.message.content; if (!Array.isArray(content)) return; let last = null; for (const block of content) { if (block && block.type === 'tool_result') { if (!msg.parent_tool_use_id && this.turn && this.toolNames.get(block.tool_use_id) === 'Read' && !block.is_error) { // How much this turn read from files (shown under the reply). this.turn.reads += 1; this.turn.readChars += typeof block.content === 'string' ? block.content.length : toolResultText(block.content).length; } const event = { t: 'tool_result', id: block.tool_use_id, content: toolResultText(block.content), isError: !!block.is_error, parent: msg.parent_tool_use_id || null, }; const images = this.extractImages(block.content); if (images.length) event.images = images; if (this.toolNames.get(block.tool_use_id) === SHARE_TOOL && !block.is_error) { const shared = this.collectSharedFiles(block.content); event.content = shared.text; if (shared.files.length) event.files = shared.files; if (shared.errors.length) event.content = [event.content, ...shared.errors].filter(Boolean).join('\n'); } // The big-read guard's note is information, not a failure. if (event.isError && /^(?:PreToolUse:Read hook error: )?Agent Web: /.test(event.content)) { event.content = event.content.replace(/^PreToolUse:Read hook error: /, ''); event.isError = false; event.guard = true; } last = this.addEvent(event); } } if (last && !msg.parent_tool_use_id && typeof msg.uuid === 'string') this.markChainEntry(last, msg.uuid); } rememberToolName(id, name) { if (typeof id !== 'string' || typeof name !== 'string') return; this.toolNames.set(id, name); if (this.toolNames.size > MAX_TOOL_NAMES) this.toolNames.delete(this.toolNames.keys().next().value); } // ---- files Claude shares with the user (share_file) ---- // The MCP tool validated the paths and lists them in a JSON text block; // copies are kept with the chat, so later edits or deletes in the working // directory do not change what was shared. collectSharedFiles(content) { const blocks = Array.isArray(content) ? content : typeof content === 'string' ? [{ type: 'text', text: content }] : []; const notes = []; let list = null; for (const block of blocks) { if (!block || block.type !== 'text' || typeof block.text !== 'string') continue; const raw = block.text.trim(); if (raw.startsWith('{"agentwebShare"')) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed.agentwebShare)) list = parsed.agentwebShare; } catch { // bozuk liste; asagida hata olarak raporlanir } } else if (raw) { notes.push(raw); } } const result = { text: truncate(notes.join('\n'), MAX_TOOL_TEXT), files: [], errors: [] }; if (!list) { result.errors.push('Paylaşılan dosya listesi okunamadı.'); return result; } let total = 0; for (const item of list.slice(0, MAX_ATTACHMENTS)) { const src = item && typeof item.path === 'string' ? item.path : ''; const label = safeFileName(item && item.name ? item.name : path.basename(src)); try { if (!path.isAbsolute(src) || src.includes('\0')) throw new Error('geçersiz yol'); const stat = fs.statSync(src); if (!stat.isFile()) throw new Error('normal bir dosya değil'); if (SYSTEM_PATH_RE.test(fs.realpathSync(src))) throw new Error('sistem dosyası'); if (stat.size > MAX_SHARE_BYTES) throw new Error('50 MB sınırını aşıyor'); total += stat.size; if (total > MAX_SHARE_TOTAL) throw new Error('toplam 100 MB sınırı aşıldı'); result.files.push(this.storeSharedFile(src, label, stat.size)); } catch (err) { result.errors.push(`${label} paylaşılamadı: ${errMessage(err)}`); } } this.pruneSharedFiles(); return result; } storeSharedFile(src, name, size) { const id = crypto.randomUUID(); const dir = path.join(this.filesDir, id); fs.mkdirSync(dir, { recursive: true }); const dest = path.join(dir, 'content'); try { fs.copyFileSync(src, dest); const head = Buffer.alloc(16); const fd = fs.openSync(dest, 'r'); try { fs.readSync(fd, head, 0, 16, 0); } finally { fs.closeSync(fd); } const imageType = sniffImage(head); let mediaId = null; if (imageType && size <= MAX_MEDIA_BYTES) mediaId = this.saveMedia(fs.readFileSync(dest), imageType); const text = !imageType && size <= MAX_TEXT_PREVIEW && looksLikeText(fs.readFileSync(dest)); const meta = { id, name, size, image: !!imageType, mediaId, text, createdAt: Date.now() }; fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta)); return { id, name, size, image: meta.image, mediaId, text }; } catch (err) { fs.rmSync(dir, { recursive: true, force: true }); throw err; } } pruneSharedFiles() { let entries; try { entries = fs.readdirSync(this.filesDir).filter((f) => FILE_ID_RE.test(f)); } catch { return; } const metas = entries .map((id) => readJson(path.join(this.filesDir, id, 'meta.json'), null) || { id, size: 0, createdAt: 0 }) .sort((a, b) => num(a.createdAt) - num(b.createdAt)); let bytes = metas.reduce((sum, m) => sum + num(m.size), 0); let count = metas.length; for (const meta of metas) { if (count <= MAX_SHARED_FILES && bytes <= MAX_SHARED_DISK) break; fs.rmSync(path.join(this.filesDir, meta.id), { recursive: true, force: true }); count -= 1; bytes -= num(meta.size); } } handleResult(msg, run) { this.clearDrafts(true); const ok = msg.subtype === 'success' && !msg.is_error; const errorText = Array.isArray(msg.errors) ? msg.errors.join('\n') : ''; if (!ok && run && run.resumeAt && /Resume rejected by --resume-drops-turn/.test(errorText)) { // The edit could not rewind Claude's transcript; resume it as it is. this.retryPlainResume(run); return; } const event = { t: 'result', ok, subtype: msg.subtype, durationMs: msg.duration_ms, numTurns: msg.num_turns, }; const usage = this.turnUsage(msg, run); // The cost estimate only means money when an API key pays for the calls. if (this.lastInit && this.lastInit.auth && this.lastInit.auth !== 'none') event.apiKey = true; if (usage) { event.usage = { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite }; if (usage.cost > 0) event.cost = Math.round(usage.cost * 1e6) / 1e6; if (usage.models && usage.models.length) event.models = usage.models; } const turn = this.turn; if (turn && turn.reads) event.reads = { files: turn.reads, tokens: Math.round(turn.readChars / 4) }; if (this.state.contextTokens) event.context = this.state.contextTokens; if (this.stopRequested) { event.stopped = true; this.stopRequested = false; } else if (!ok) { const errors = Array.isArray(msg.errors) ? msg.errors.join('\n') : ''; event.error = errors || (msg.subtype === 'success' ? '' : msg.subtype); if (msg.api_error_status) event.apiErrorStatus = msg.api_error_status; } this.addEvent(event); this.state.lastActivity = Date.now(); this.saveState(); // Files may have changed: the map follows a moment later. if (turn && [...turn.tools].some((name) => CHANGING_TOOLS.has(name))) this.scheduleMapRefresh(undefined, { request: turn.request, since: turn.startedAt }); if (!msg.queued_turn_count) { this.running = false; this.activeEngine = null; this.turn = null; this.unanswered = []; this.seenToolIds.clear(); this.scheduleIdle(); this.notify('done', event.stopped ? 'Yanıt durduruldu.' : ok ? 'Claude yanıtını bitirdi.' : `Hata: ${event.error || 'bilinmeyen'}`); if (this.newChatAfterTurn) { this.newChatAfterTurn = false; this.newChat({ summary: true }); return; } // An MCP server was removed during the turn: the next message starts // a Claude without it. if (this.restartPending && this.run && this.pending.size === 0) { this.restartPending = false; this.closeRun(this.run, false); this.authRuns.clear(); this.broadcastMcpStatus().catch(() => {}); } this.broadcastStatus(); this.drainLater(); return; } else { this.startTurn(turn && turn.request); } this.broadcastStatus(); } retryPlainResume(run) { this.closeRun(run, true); const retry = this.unanswered.splice(0); this.addEvent({ t: 'notice', text: 'Düzenleme Claude\'un konuşma kaydına uygulanamadı; mesaj konuşmanın sonuna eklendi.' }); this.ensureQuery() .then(() => { this.unanswered.push(...retry); for (const message of retry) this.run.input.push(message); }) .catch((err) => { this.running = false; this.turn = null; this.addEvent({ t: 'error', message: `Claude başlatılamadı: ${errMessage(err)}` }); this.broadcastStatus(); }); } // "Summarise and start fresh": while the prompt cache is still warm Claude // updates the handoff note and memory cheaply, then a new conversation // starts from them. With a cold cache (or nothing to resume) that would // re-send the whole conversation, so the new chat starts right away from // the automatic summary instead. async handoffAndNewChat() { if (this.running) { this.broadcast({ type: 'live', text: 'Claude çalışırken yapılamaz; yanıtın bitmesini bekle.' }); return; } const target = this.targetFor(null); const warm = target === 'gemini' ? !!this.state.geminiSessionId : this.state.sessionId && Date.now() - this.state.lastActivity < CACHE_COLD_MS; if (!warm) { this.newChat({ summary: true }); return; } this.newChatAfterTurn = true; await this.send( 'Bu konuşmayı kapatıp yeni bir konuşmaya geçeceğim. Önce .agentweb/HANDOFF.md dosyasını (yapılanlar, şu anki durum, sıradaki adımlar, açık sorular; en fazla ~40 satır) ve gerekiyorsa .agentweb/MEMORY.md dosyasını kısa ve öz biçimde güncelle. Başka hiçbir şey yapma, dosya okumaya gerek yok; sadece bu konuşmada bildiklerinle yaz ve tek cümleyle bitir.', null, { decided: true, auto: true, target } ); } // A summary of the conversation made from the history alone (no model): // the first request, the last requests, the files changed, the last reply // and the task list. Carried into the next conversation's prompt. autoSummary() { const users = this.events.filter((e) => e.t === 'user' && !e.auto && e.text); if (!users.length) return ''; const changed = []; let todos = null; let lastText = ''; for (const e of this.events) { if (e.t === 'tool' && !e.parent && e.input) { const file = e.input.file_path || e.input.notebook_path || e.input.absolute_path; if (file && ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'write_file', 'replace'].includes(e.name)) { const rel = path.isAbsolute(file) ? path.relative(this.cwd, file) : file; const i = changed.indexOf(rel); if (i !== -1) changed.splice(i, 1); changed.push(rel); } if (e.name === 'TodoWrite' && Array.isArray(e.input.todos)) todos = e.input.todos; } else if (e.t === 'text' && !e.parent && e.text) { lastText = e.text; } } const clip = (t, n) => (t.length > n ? `${t.slice(0, n)}…` : t); const parts = [`The previous conversation in this folder (summarised automatically from its history, ${users.length} user messages):`]; parts.push(`- First request: ${clip(users[0].text.replace(/\s+/g, ' '), 400)}`); const recent = users.slice(1).slice(-5); if (recent.length) parts.push('- Latest requests:', ...recent.map((u) => ` - ${clip(u.text.replace(/\s+/g, ' '), 300)}`)); if (changed.length) parts.push(`- Files changed: ${changed.slice(-30).join(', ')}`); if (todos && todos.length) { parts.push('- Task list at the end:', ...todos.slice(0, 15).map((t) => ` - [${t.status === 'completed' ? 'x' : t.status === 'in_progress' ? '~' : ' '}] ${clip(String(t.content || ''), 160)}`)); } if (lastText) parts.push(`- Your last reply (excerpt): ${clip(lastText.replace(/\s+/g, ' '), 1200)}`); return clip(parts.join('\n'), SUMMARY_MAX_CHARS); } // ---- per-turn time and token accounting ---- startTurn(request = '') { this.turn = { request: String(request || '').split('\n').find((line) => line.trim()) || '', tools: new Set(), reads: 0, readChars: 0, startedAt: Date.now(), input: 0, cacheRead: 0, cacheWrite: 0, output: 0, outputs: new Map(), current: new Map(), chars: new Map(), exact: new Set(), }; } publicTurn() { const t = this.turn; if (!t) return null; return { startedAt: t.startedAt, input: t.input, cacheRead: t.cacheRead, cacheWrite: t.cacheWrite, output: t.output }; } // Live counter from the stream: every API call starts with its input usage // (message_start) and ends with its exact output count (message_delta); in // between the output is estimated from the streamed text (~4 chars a token). trackUsage(msg) { const t = this.turn; const ev = msg.event; if (!t || !ev) return; const stream = msg.parent_tool_use_id || 'main'; if (ev.type === 'message_start' && ev.message) { const u = ev.message.usage || {}; t.input += num(u.input_tokens); t.cacheRead += num(u.cache_read_input_tokens); t.cacheWrite += num(u.cache_creation_input_tokens); const id = ev.message.id || `${stream}:${t.outputs.size}`; t.current.set(stream, id); t.outputs.set(id, num(u.output_tokens)); } else if (ev.type === 'content_block_delta' && ev.delta) { const id = t.current.get(stream); if (id === undefined || t.exact.has(id)) return; const d = ev.delta; const chars = String(d.text || d.thinking || d.partial_json || '').length; if (!chars) return; t.chars.set(id, (t.chars.get(id) || 0) + chars); t.outputs.set(id, Math.max(t.outputs.get(id) || 0, Math.ceil(t.chars.get(id) / 4))); } else if (ev.type === 'message_delta' && ev.usage && typeof ev.usage.output_tokens === 'number') { const id = t.current.get(stream); if (id === undefined) return; t.outputs.set(id, ev.usage.output_tokens); t.exact.add(id); } else { return; } let output = 0; for (const value of t.outputs.values()) output += value; t.output = output; if (!this.usageTimer) { this.usageTimer = setTimeout(() => { this.usageTimer = null; if (this.turn) this.broadcast({ type: 'usage', turn: this.publicTurn(), now: Date.now() }); }, USAGE_FLUSH_MS); } } // Final numbers for a turn: the change in the SDK's running per-model totals // (covers subagents too). A resumed session starts from the totals saved in // its transcript, so the last totals we saw are kept in state.json as the // baseline. Falls back to the main-loop usage, then to the live counter. turnUsage(msg, run) { const cumulative = totalsFromModelUsage(msg.modelUsage); let turn = null; if (tokenSum(cumulative) > 0) { let base = run ? run.usageBase : undefined; if (base === undefined) base = run && run.resumedFrom ? this.state.usageBase : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, models: {} }; if (base) turn = usageDelta(cumulative, base); if (run) run.usageBase = cumulative; this.state.usageBase = cumulative; this.saveState(); } if (tokenSum(turn) > 0) return turn; const main = msg.usage || {}; const fromMain = { input: num(main.input_tokens), output: num(main.output_tokens), cacheRead: num(main.cache_read_input_tokens), cacheWrite: num(main.cache_creation_input_tokens), cost: 0, models: [], }; if (tokenSum(fromMain) > 0) return fromMain; const live = this.publicTurn(); return tokenSum(live) > 0 ? { ...live, cost: 0, models: [] } : null; } // ---- images and uploads ---- saveMedia(buffer, mediaType) { const ext = MEDIA_EXT[mediaType]; if (!ext) return null; try { fs.mkdirSync(this.mediaDir, { recursive: true }); const id = `${crypto.randomUUID()}.${ext}`; fs.writeFileSync(path.join(this.mediaDir, id), buffer); this.pruneMedia(); return id; } catch (err) { console.error('[chat] gorsel kaydedilemedi:', err.message); return null; } } pruneMedia() { let files; try { files = fs.readdirSync(this.mediaDir); } catch { return; } if (files.length <= MAX_MEDIA_FILES) return; const dated = files.map((f) => { try { return { f, t: fs.statSync(path.join(this.mediaDir, f)).mtimeMs }; } catch { return { f, t: 0 }; } }); dated.sort((a, b) => a.t - b.t); for (const { f } of dated.slice(0, dated.length - MAX_MEDIA_FILES)) { fs.rmSync(path.join(this.mediaDir, f), { force: true }); } } // Screenshots (e.g. Playwright's browser_take_screenshot) and images read // by tools arrive as base64 blocks; keep them as files, not in the history. extractImages(content) { const images = []; if (!Array.isArray(content)) return images; for (const block of content) { if (images.length >= MAX_IMAGES_PER_RESULT) break; if (!block || block.type !== 'image' || !block.source || block.source.type !== 'base64') continue; const data = block.source.data; if (typeof data !== 'string') continue; if (data.length > Math.ceil((MAX_MEDIA_BYTES * 4) / 3) + 4) { images.push({ error: 'too-large' }); continue; } const buffer = Buffer.from(data, 'base64'); const type = sniffImage(buffer); if (!type) continue; const id = this.saveMedia(buffer, type); if (id) images.push({ id, type }); } return images; } async saveUpload(req, fileName, declaredType) { const declared = Number(req.headers['content-length']); if (declared > MAX_UPLOAD_BYTES) throw httpError(413, 'Dosya çok büyük (en fazla 50 MB).'); this.manager.ensureWorkspace(this.name); const dir = path.join(this.cwd, 'uploads'); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, `.yukleniyor-${crypto.randomUUID()}`); let size = 0; let head = Buffer.alloc(0); const meter = new Transform({ transform(chunk, _encoding, callback) { size += chunk.length; if (size > MAX_UPLOAD_BYTES) { callback(httpError(413, 'Dosya çok büyük (en fazla 50 MB).')); return; } if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]); callback(null, chunk); }, }); try { await pipeline(req, meter, fs.createWriteStream(tmp, { flags: 'wx' })); } catch (err) { fs.rmSync(tmp, { force: true }); throw err.status ? err : httpError(400, 'Yükleme yarıda kesildi.'); } if (!size) { fs.rmSync(tmp, { force: true }); throw httpError(400, 'Dosya boş.'); } const name = uniqueName(dir, safeFileName(fileName)); const abs = path.join(dir, name); fs.renameSync(tmp, abs); const imageType = sniffImage(head); let mediaId = null; if (imageType && size <= MAX_MEDIA_BYTES) { try { mediaId = this.saveMedia(fs.readFileSync(abs), imageType); } catch { mediaId = null; } } const typeOk = typeof declaredType === 'string' && /^[\w.+-]+\/[\w.+-]+$/.test(declaredType); const info = { id: crypto.randomUUID(), name, path: `uploads/${name}`, size, mediaType: imageType || (typeOk ? declaredType : 'application/octet-stream'), image: !!imageType, mediaId, }; this.pruneUploads(); this.uploads.set(info.id, { ...info, abs, createdAt: Date.now() }); return info; } pruneUploads() { const cutoff = Date.now() - UPLOAD_TTL_MS; for (const [id, upload] of this.uploads) { if (upload.createdAt < cutoff) this.uploads.delete(id); } } // Removing a chip before sending also removes the file it uploaded. deleteUpload(id) { const upload = this.uploads.get(id); if (!upload) return false; this.uploads.delete(id); fs.rmSync(upload.abs, { force: true }); if (upload.mediaId) fs.rmSync(path.join(this.mediaDir, upload.mediaId), { force: true }); return true; } takeUploads(ids) { const files = []; if (!Array.isArray(ids)) return files; for (const id of ids.slice(0, MAX_ATTACHMENTS)) { const upload = typeof id === 'string' ? this.uploads.get(id) : null; if (!upload) continue; this.uploads.delete(id); files.push(upload); } return files; } // Images go to Claude as image blocks; every file is also in the working // directory, and the note tells Claude where, so tools can open it. buildUserContent(prompt, files) { if (!files.length) return prompt; const blocks = []; const notes = []; for (const file of files) { let inline = false; if (file.image && file.size <= MAX_INLINE_IMAGE_BYTES) { try { const data = fs.readFileSync(file.abs).toString('base64'); blocks.push({ type: 'image', source: { type: 'base64', media_type: file.mediaType, data } }); inline = true; } catch { inline = false; } } notes.push(`${file.path} (${inline ? 'image, attached above' : formatBytes(file.size)})`); } const note = `[Attached files, saved in the working directory: ${notes.join(', ')}]`; blocks.push({ type: 'text', text: prompt ? `${prompt}\n\n${note}` : note }); return blocks; } requestPermission(toolName, toolInput, opts) { const options = opts || {}; if (AUTO_ALLOW_TOOLS.has(toolName)) { return Promise.resolve({ behavior: 'allow', updatedInput: toolInput || {} }); } return new Promise((resolve) => { const id = crypto.randomUUID(); const kind = toolName === 'AskUserQuestion' ? 'question' : toolName === 'ExitPlanMode' ? 'plan' : 'tool'; const suggestions = Array.isArray(options.suggestions) ? options.suggestions : []; const canAlways = kind === 'tool' && suggestions.length > 0 && !options.suppressAlwaysAllowRule; this.pending.set(id, { resolve, kind, input: toolInput || {}, suggestions, canAlways }); const event = { t: 'perm', id, kind, toolName, toolUseId: options.toolUseID || null, input: capInput(toolInput), title: options.title || '', displayName: options.displayName || '', description: options.description || '', reason: options.decisionReason || '', blockedPath: options.blockedPath || '', mcpServer: options.mcpServer ? options.mcpServer.name : '', canAlways, defaultToNo: !!options.defaultToNo, }; // The SDK dispatches control requests as soon as they are read, while the // assistant/tool_use frames before them may still sit in its message // queue. Hold the prompt until its tool card exists so history reads in order. const toolUseId = options.toolUseID; if (toolUseId && !this.seenToolIds.has(toolUseId)) { const timer = setTimeout(() => this.releaseDeferredPerm(toolUseId), 500); this.deferredPerms.set(toolUseId, { event, timer }); } else { this.addEvent(event); } this.notify(kind === 'question' ? 'question' : 'perm', kind === 'question' ? 'Claude bir soru soruyor.' : kind === 'plan' ? 'Claude planını onaylamanı bekliyor.' : `Claude izin istiyor: ${toolName}`); const signal = options.signal; if (signal) { const onAbort = () => this.settlePermission(id, { behavior: 'deny', message: 'İstek iptal edildi.' }, 'cancelled'); if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); } }); } releaseDeferredPerm(toolUseId) { const deferred = this.deferredPerms.get(toolUseId); if (!deferred) return; clearTimeout(deferred.timer); this.deferredPerms.delete(toolUseId); this.addEvent(deferred.event); } settlePermission(id, result, decision, extra) { const pending = this.pending.get(id); if (!pending) return false; this.pending.delete(id); pending.resolve(result); for (const [toolUseId, deferred] of this.deferredPerms) { if (deferred.event.id === id) { // Settled (cancelled) before the prompt was ever shown: nothing to close in the UI. clearTimeout(deferred.timer); this.deferredPerms.delete(toolUseId); return true; } } this.addEvent({ t: 'perm_done', id, decision, ...(extra || {}) }); return true; } answerPermission(id, msg) { const pending = this.pending.get(id); if (!pending) return; if (msg.decision === 'deny') { this.settlePermission(id, { behavior: 'deny', message: 'Kullanıcı bu işlemi reddetti.' }, 'deny'); return; } if (pending.kind === 'question') { const answers = cleanAnswers(msg.answers, pending.input); if (!answers) return; this.settlePermission(id, { behavior: 'allow', updatedInput: { ...pending.input, answers } }, 'allow', { answers }); return; } if (pending.kind === 'plan') { const mode = msg.planMode === 'acceptEdits' ? 'acceptEdits' : 'default'; const settled = this.settlePermission( id, { behavior: 'allow', updatedInput: pending.input, updatedPermissions: [{ type: 'setMode', mode, destination: 'session' }] }, 'allow', { planMode: mode } ); if (settled) this.syncPermissionMode(mode); return; } const always = msg.decision === 'always' && pending.canAlways; const result = { behavior: 'allow', updatedInput: pending.input }; if (always) result.updatedPermissions = pending.suggestions; this.settlePermission(id, result, always ? 'always' : 'allow'); } cancelPendingPermissions() { for (const id of [...this.pending.keys()]) { this.settlePermission(id, { behavior: 'deny', message: 'Oturum sona erdi.' }, 'cancelled'); } } async applySettings(patch) { const before = this.state.settings; const next = this.manager.normalize({ ...before, ...patch }); this.state.settings = next; this.saveState(); this.manager.rememberDefaults(next); this.broadcastStatus(); const run = this.run; if (!run) return; if (next.model !== before.model) await run.query.setModel(next.model || undefined); if (next.effort !== before.effort) await run.query.applyFlagSettings({ effortLevel: next.effort || null }); if (next.permissionMode !== before.permissionMode) await run.query.setPermissionMode(next.permissionMode); } async stop() { this.later = []; if (this.gemRun && this.running) { this.stopGemini(this.gemRun); return; } const run = this.run; if (!run || !this.running) return; this.stopRequested = true; const kill = () => { this.closeRun(run, true); this.clearDrafts(true); const event = { t: 'result', ok: false, stopped: true, subtype: 'stopped' }; const live = this.publicTurn(); if (live) { event.durationMs = Date.now() - live.startedAt; if (tokenSum(live) > 0) event.usage = { input: live.input, output: live.output, cacheRead: live.cacheRead, cacheWrite: live.cacheWrite }; } this.running = false; this.activeEngine = null; this.turn = null; this.unanswered = []; this.addEvent(event); this.broadcastStatus(); }; try { await run.query.interrupt(); } catch { kill(); return; } setTimeout(() => { if (this.run === run && this.running) kill(); }, STOP_KILL_MS).unref(); } closeRun(run, force) { run.closing = true; run.input.close(); if (force) { run.abortController.abort(); } else { setTimeout(() => run.abortController.abort(), 5000).unref(); } if (this.run === run) this.run = null; } // Live MCP status needs a running Claude process; otherwise the list from the // last start is all there is (start: launch Claude to get the live list). async mcpStatusReply(start) { let startError = ''; if (start && !this.run) { try { await this.ensureLiveRun(); } catch (err) { startError = `Claude başlatılamadı: ${errMessage(err)}`; } } const run = this.run; const reply = { type: 'mcp', live: false, servers: (this.mcpLast || []).map(withTempState) }; if (startError) reply.error = startError; if (run && !run.closing) { try { const list = await withTimeout(run.query.mcpServerStatus(), MCP_STATUS_TIMEOUT_MS, 'MCP durumu alınamadı (zaman aşımı).'); reply.live = true; const servers = Array.isArray(list) ? list.map(summarizeMcp) : []; this.mcpInfo = new Map(servers.map((s) => [s.name, { scope: s.scope, source: s.source, tmpPath: s.tmpPath }])); this.mcpLast = servers.filter((s) => !this.mcpDropped.has(s.name)); reply.servers = servers.map(withTempState); } catch (err) { reply.error = errMessage(err); } } return reply; } async sendMcpStatus(ws, extra, start) { const reply = await this.mcpStatusReply(start); sendTo(ws, { ...reply, ...(extra || {}) }); } async broadcastMcpStatus() { this.broadcast(await this.mcpStatusReply(false)); } async reconnectMcp(ws, name) { const run = this.run; if (!run || run.closing || !validServerName(name)) { await this.sendMcpStatus(ws); return; } let error = null; try { await withTimeout(run.query.reconnectMcpServer(name), MCP_STATUS_TIMEOUT_MS * 3, 'Yeniden bağlanma zaman aşımına uğradı.'); } catch (err) { error = errMessage(err); } await this.sendMcpStatus(ws, error ? { error } : null); } // A Claude process for control requests (MCP status, sign-in) even when no // message is being answered; an idle one closes itself like after a turn. async ensureLiveRun() { await this.ensureQuery(); const run = this.run; if (!run || run.closing) throw new Error('Claude süreci hazır değil.'); if (!this.running) this.scheduleIdle(); return run; } // Remote MCP servers that need a login (OAuth). Claude Code's own flow // redirects to http://localhost:/callback, which a phone cannot // reach; here the provider sends the browser back to this app's public // /oauth/callback instead, and Claude Code finishes the sign-in. Servers // that only accept a localhost address (or a fixed client id) still work: // the user pastes the address the browser could not open. async authenticateMcp(ws, name) { if (!validServerName(name)) return; const reply = (extra) => sendTo(ws, { type: 'mcp_auth', name, ...extra }); let run; try { run = await this.ensureLiveRun(); } catch (err) { reply({ error: `Claude başlatılamadı: ${errMessage(err)}` }); return; } const redirectUri = this.manager.oauthRedirectUri(ws.agentwebOrigin); let res; try { res = await withTimeout( run.query.mcpAuthenticate(name, redirectUri || undefined), MCP_AUTH_TIMEOUT_MS, 'Giriş başlatılamadı (zaman aşımı).' ); } catch (err) { reply({ error: errMessage(err) }); return; } const r = res || {}; if (!r.requiresUserAction) { // Stored credentials still work; a reconnect picks them up. await this.reconnectQuietly(run, name); reply({ done: true }); await this.broadcastMcpStatus(); return; } const authUrl = safeAuthUrl(r.authUrl); if (!authUrl) { reply({ error: 'Sunucu geçerli bir giriş adresi vermedi.' }); return; } let mode = 'paste'; if (r.callbackExpected === false) { mode = 'external'; } else if (r.redirectScheme === 'custom' && redirectUri && typeof r.state === 'string' && r.state) { mode = 'redirect'; this.manager.addOAuthFlow(r.state, { session: this, run, server: name, redirectUri }); } this.authRuns.set(name, run); reply({ authUrl, mode }); } // The address the browser landed on after signing in, pasted by the user. async submitMcpCallback(name, rawUrl) { if (!validServerName(name)) return; const url = typeof rawUrl === 'string' ? rawUrl.trim() : ''; if (!url || url.length > MAX_CALLBACK_URL || !hasAuthCode(url)) { this.broadcast({ type: 'mcp_auth_done', name, ok: false, error: 'Bu adreste giriş kodu yok. Girişten sonra açılan sayfanın adresinin tamamını yapıştır.', }); return; } await this.finishOAuth(name, url, this.authRuns.get(name)); } // Shared by the paste box and the /oauth/callback route. async finishOAuth(name, url, run) { let result; if (!run || run !== this.run || run.closing) { result = { ok: false, error: 'Bu giriş denemesi artık geçerli değil (Claude yeniden başladı). "Giriş yap"a yeniden dokun.' }; } else { try { await withTimeout(run.query.mcpSubmitOAuthCallbackUrl(name, url), MCP_CALLBACK_TIMEOUT_MS, 'Giriş tamamlanamadı (zaman aşımı).'); result = { ok: true }; } catch (err) { result = { ok: false, error: errMessage(err) }; } } if (result.ok) { if (this.authRuns.get(name) === run) this.authRuns.delete(name); // Claude Code leaves reconnecting to the client when the callback // arrives this way. await this.reconnectQuietly(run, name); } this.broadcast({ type: 'mcp_auth_done', name, ...result }); if (result.ok) await this.broadcastMcpStatus(); return result; } // Removes a server from Claude Code's configuration for good (user scope: // every session; local or project scope: this session's folder). Running // Claude processes read their MCP list at start: an idle one is restarted // right away (the conversation is kept), a busy one after its turn. async removeMcp(ws, name, scope) { if (!validServerName(name)) return; const reply = (extra) => sendTo(ws, { type: 'mcp_removed', name, ...extra }); if (!REMOVABLE_SCOPES.has(scope)) { reply({ ok: false, error: 'Bu sunucu buradan kaldırılamaz.' }); return; } this.manager.ensureWorkspace(this.name); const result = await this.manager.removeMcpServer(name, scope, this.cwd); // Already gone from the config (removed elsewhere) while Claude still // lists it: only the restart is left to do. const already = !result.ok && result.notFound && this.listsMcp(name); if (!result.ok && !already) { reply({ ok: false, error: result.error }); return; } const hadRun = !!this.run; const sessions = scope === 'user' ? this.manager.allSessions() : [this]; let pending = false; for (const session of sessions) { const deferred = session.dropMcpServer(name); if (session === this) pending = deferred; else if (session.clients.size) session.broadcastMcpStatus().catch(() => {}); } reply({ ok: true, pending, ...(already ? { already: true } : {}) }); if (hadRun && !pending) { // Restarted: show the live list without the removed server. await this.ensureLiveRun().catch(() => {}); } await this.broadcastMcpStatus(); } listsMcp(name) { return this.mcpInfo.has(name) || !!(this.mcpLast && this.mcpLast.some((s) => s.name === name)); } // Forgets a removed MCP server; returns true when the Claude process is // busy and restarts only after its turn. dropMcpServer(name) { this.mcpInfo.delete(name); this.mcpDropped.add(name); if (this.lastInit && Array.isArray(this.lastInit.mcp)) { this.lastInit = { ...this.lastInit, mcp: this.lastInit.mcp.filter((s) => s.name !== name) }; } if (this.mcpLast) this.mcpLast = this.mcpLast.filter((s) => s.name !== name); if (!this.run) return false; if (this.running || this.pending.size) { this.restartPending = true; return true; } this.closeRun(this.run, true); this.authRuns.clear(); return false; } // claude.ai connectors (and any server) switched off or back on for this // session's folder; Claude Code keeps the choice in its project settings. async toggleMcp(ws, name, enabled) { if (!validServerName(name) || typeof enabled !== 'boolean') return; const reply = (extra) => sendTo(ws, { type: 'mcp_toggled', name, enabled, ...extra }); let run; try { run = await this.ensureLiveRun(); } catch (err) { reply({ ok: false, error: `Claude başlatılamadı: ${errMessage(err)}` }); return; } try { await withTimeout(run.query.toggleMcpServer(name, enabled), MCP_TOGGLE_TIMEOUT_MS, 'İşlem zaman aşımına uğradı.'); } catch (err) { reply({ ok: false, error: errMessage(err) }); return; } reply({ ok: true }); await this.broadcastMcpStatus(); } async reconnectQuietly(run, name) { try { await withTimeout(run.query.reconnectMcpServer(name), MCP_STATUS_TIMEOUT_MS * 3, 'Yeniden bağlanma zaman aşımına uğradı.'); } catch { // the status list shows what went wrong } } // MCP servers and settings are read when the Claude process starts. Closing // an idle process makes the next message start a fresh one that resumes the // same conversation with the current ~/.claude.json and .mcp.json. restart(ws) { if (this.running || this.pending.size) { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'live', text: 'Claude çalışırken yeniden başlatılamaz; yanıtın bitmesini bekle ya da durdur.' })); } return; } if (this.run) this.closeRun(this.run, true); this.clearIdle(); this.authRuns.clear(); this.restartPending = false; this.lastInit = null; this.mcpLast = null; this.mcpDropped.clear(); this.addEvent({ t: 'notice', text: 'Claude yeniden başlatıldı; MCP ve ayar değişiklikleri bir sonraki mesajla yüklenecek.' }); this.broadcastStatus(); } // Starts a new conversation. The current one is archived (history, media // and Claude's session id stay), so it can be read or continued later. // summary: the next conversation starts with an automatic summary of it. newChat({ summary = false } = {}) { const carry = summary ? this.autoSummary() : ''; if (this.run) this.closeRun(this.run, true); this.killGemini(); this.later = []; this.activeEngine = null; this.cancelPendingPermissions(); this.clearDrafts(false); this.clearIdle(); this.running = false; this.turn = null; this.stopRequested = false; this.unanswered = []; this.seenToolIds.clear(); this.toolNames.clear(); this.uploads.clear(); this.authRuns.clear(); this.restartPending = false; this.heldSend = null; this.newChatAfterTurn = false; this.archiveCurrent(); this.state = { ...this.state, sessionId: null, usageBase: null, chatId: newChatId(), createdAt: Date.now(), append: null, contextTokens: 0, lastActivity: 0, carry: carry || null, title: '', resumeAt: null, geminiSessionId: null, geminiBackend: null, seen: { claude: 0, gemini: 0 }, }; this.saveState(); this.events = []; this.seq = 0; try { fs.unlinkSync(this.eventsFile); } catch { // dosya yoksa sorun degil } this.broadcast({ type: 'reset' }); if (carry) this.addEvent({ t: 'notice', text: 'Yeni sohbet: önceki konuşmanın özeti, proje hafızası ve devir notu Claude\'a verilecek; proje baştan okunmayacak.' }); this.broadcastStatus(); } // ---- archived conversations ---- archiveCurrent() { const users = this.events.filter((e) => e.t === 'user'); if (!users.length) return null; const id = this.state.chatId && ARCHIVE_ID_RE.test(this.state.chatId) ? this.state.chatId : newChatId(); const dir = path.join(this.archiveDir, id); try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'events.jsonl'), this.events.map((e) => JSON.stringify(e)).join('\n') + '\n'); const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; for (const e of this.events) { if (e.t === 'result' && e.usage) for (const k of Object.keys(usage)) usage[k] += num(e.usage[k]); } const meta = { id, title: this.state.title || (users[0].text || '').slice(0, 120) || 'Sohbet', createdAt: this.state.createdAt || users[0].ts || Date.now(), endedAt: Date.now(), messages: users.length, sessionId: this.state.sessionId, geminiSessionId: this.state.geminiSessionId, geminiBackend: this.state.geminiBackend, seen: this.state.seen, append: this.state.append, contextTokens: this.state.contextTokens, lastActivity: this.state.lastActivity, usageBase: this.state.usageBase, usage, }; fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta)); this.pruneArchives(); return id; } catch (err) { console.error('[chat] arsivlenemedi:', err.message); return null; } } pruneArchives() { const list = this.listArchives(); for (const meta of list.slice(MAX_ARCHIVES)) fs.rmSync(path.join(this.archiveDir, meta.id), { recursive: true, force: true }); } listArchives() { let ids; try { ids = fs.readdirSync(this.archiveDir).filter((id) => ARCHIVE_ID_RE.test(id)); } catch { return []; } const list = []; for (const id of ids) { const meta = readJson(path.join(this.archiveDir, id, 'meta.json'), null); if (!meta) continue; list.push({ id, title: String(meta.title || 'Sohbet'), createdAt: num(meta.createdAt), endedAt: num(meta.endedAt), messages: num(meta.messages), canResume: !!meta.sessionId, usage: meta.usage || null, }); } return list.sort((a, b) => b.endedAt - a.endedAt); } readArchive(id) { if (!ARCHIVE_ID_RE.test(String(id))) return null; const dir = path.join(this.archiveDir, id); const meta = readJson(path.join(dir, 'meta.json'), null); if (!meta) return null; let events = []; try { events = fs .readFileSync(path.join(dir, 'events.jsonl'), 'utf8') .split('\n') .filter(Boolean) .map((line) => { try { return JSON.parse(line); } catch { return null; } }) .filter(Boolean); } catch { events = []; } return { meta, events }; } deleteArchive(id) { if (!ARCHIVE_ID_RE.test(String(id))) return false; const dir = path.join(this.archiveDir, id); if (!fs.existsSync(dir)) return false; fs.rmSync(dir, { recursive: true, force: true }); return true; } // Makes an archived conversation the current one again (the current one // goes to the archive); the next message resumes its Claude session. restoreArchive(id) { if (this.running) throw httpError(409, 'Claude çalışırken yapılamaz.'); const archived = this.readArchive(id); if (!archived) throw httpError(404, 'Sohbet bulunamadı.'); if (this.run) this.closeRun(this.run, true); this.killGemini(); this.later = []; this.cancelPendingPermissions(); this.clearDrafts(false); this.heldSend = null; this.archiveCurrent(); const { meta, events } = archived; this.deleteArchive(id); this.events = events; this.seq = events.length ? num(events[events.length - 1].seq) || events.length : 0; try { fs.mkdirSync(this.dir, { recursive: true }); fs.writeFileSync(this.eventsFile, events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '')); } catch (err) { console.error('[chat] gecmis yazilamadi:', err.message); } this.state = { ...this.state, sessionId: typeof meta.sessionId === 'string' ? meta.sessionId : null, usageBase: meta.usageBase || null, chatId: id, createdAt: num(meta.createdAt) || Date.now(), append: typeof meta.append === 'string' ? meta.append : null, contextTokens: num(meta.contextTokens), lastActivity: num(meta.lastActivity), carry: null, title: String(meta.title || ''), resumeAt: null, geminiSessionId: typeof meta.geminiSessionId === 'string' ? meta.geminiSessionId : null, geminiBackend: meta.geminiBackend === 'agy' ? 'agy' : 'gemini', seen: { claude: num(meta.seen && meta.seen.claude), gemini: num(meta.seen && meta.seen.gemini) }, }; this.saveState(); this.broadcast({ type: 'hello', history: this.events, status: this.status() }); return true; } scheduleIdle() { this.clearIdle(); this.idleTimer = setTimeout(() => { if (this.run && !this.running && this.pending.size === 0) this.closeRun(this.run, false); }, IDLE_CLOSE_MS); this.idleTimer.unref(); } clearIdle() { if (this.idleTimer) clearTimeout(this.idleTimer); this.idleTimer = null; } shutdown() { this.clearIdle(); if (this.run) this.closeRun(this.run, true); this.killGemini(); this.cancelPendingPermissions(); for (const ws of this.clients) ws.close(); this.clients.clear(); } } class ChatManager { constructor({ home, workspaceRoot, childEnv, ensureWorkspace, persistent, publicUrl, spaceHost, push }) { this.workspaceRoot = workspaceRoot; this.childEnv = childEnv; this.ensureWorkspace = ensureWorkspace; this.home = home; this.storage = { persistent: !!persistent, home, workspaceRoot }; this.push = push || null; // Where OAuth providers send the browser back to: an explicit public URL, // else the origin the page was opened from, else the Space's own host. this.publicUrl = normalizeOrigin(publicUrl); this.spaceHost = typeof spaceHost === 'string' && /^[A-Za-z0-9.-]+$/.test(spaceHost) ? spaceHost : ''; // OAuth state -> sign-in waiting for its redirect to /oauth/callback. this.oauthFlows = new Map(); this.root = path.join(home, '.cc-web'); this.chatsRoot = path.join(this.root, 'chats'); this.modelsFile = path.join(this.root, 'models.json'); this.defaultsFile = path.join(this.root, 'defaults.json'); this.prefsFile = path.join(this.root, 'prefs.json'); this.prefs = normalizePrefs(readJson(this.prefsFile, {})); this.commands = readJson(path.join(this.root, 'commands.json'), null); // Sessions whose .agentweb/setup.sh already ran since this start. this.setupStarted = new Set(); this.sessions = new Map(); this.canBypass = !(typeof process.getuid === 'function' && process.getuid() === 0); this.models = readJson(this.modelsFile, null); const saved = readJson(this.defaultsFile, {}) || {}; this.defaults = this.normalize({ model: saved.model, effort: saved.effort }); this.rateLimit = null; this.sdkPromise = null; } // The prompt addition every new conversation starts with (before its // project memory). baseAppend() { const gemini = this.geminiStatus(); return webUiPrompt(this.storage, { ...this.prefs, gemini: this.prefs.gemini && gemini.ready }); } setPrefs(patch) { this.prefs = normalizePrefs({ ...this.prefs, ...(patch || {}) }); writeJson(this.prefsFile, this.prefs); for (const session of this.sessions.values()) session.broadcastStatus(); return this.prefs; } // The Gemini engine runs on Antigravity CLI (agy) once it is signed in // (Terminal: agy; Google account / AI Pro), otherwise on Gemini CLI with a // GEMINI_API_KEY secret. AGENTWEB_GEMINI_BACKEND=agy|gemini forces one. geminiStatus() { const now = Date.now(); if (this.geminiCache && now - this.geminiCache.at < 30000) return this.geminiCache.value; const envPath = this.childEnv && this.childEnv.PATH; const agyBin = gemini.findBin(envPath, 'agy'); const gemBin = gemini.findBin(envPath, 'gemini'); const agyIn = !!agyBin && gemini.agySignedIn(this.home); const key = !!gemBin && !!(this.childEnv && (this.childEnv.GEMINI_API_KEY || this.childEnv.GOOGLE_API_KEY)); const forced = String(process.env.AGENTWEB_GEMINI_BACKEND || '').toLowerCase(); let backend = null; if (forced === 'agy' && agyBin) backend = 'agy'; else if (forced === 'gemini' && gemBin) backend = 'gemini'; else if (agyIn) backend = 'agy'; else if (key) backend = 'gemini'; else if (agyBin) backend = 'agy'; else if (gemBin) backend = 'gemini'; const signedIn = backend === 'agy' ? agyIn : key; const value = { installed: !!backend, backend, bin: backend === 'agy' ? agyBin : backend === 'gemini' ? gemBin : null, signedIn, method: !signedIn ? '' : backend === 'agy' ? 'antigravity' : 'api-key', ready: !!backend && signedIn, enabled: !!this.prefs.gemini, }; this.geminiCache = { at: now, value }; if (backend === 'agy' && agyIn) this.refreshAgyModels(agyBin); return value; } geminiBin() { return this.geminiStatus().bin; } geminiModels() { return this.geminiStatus().backend === 'agy' ? this.agyModelList || gemini.AGY_MODELS : gemini.MODELS; } // `agy models` is the account's live list; read now and then in the background. refreshAgyModels(bin) { const now = Date.now(); if (this.agyModelsAt && now - this.agyModelsAt < 10 * 60 * 1000) return; this.agyModelsAt = now; execFile(bin, ['models'], { cwd: this.home, env: this.childEnv, timeout: 60000 }, (err, stdout) => { if (err) return; const list = gemini.parseAgyModels(stdout); if (!list) return; this.agyModelList = list; for (const session of this.sessions.values()) session.broadcastStatus(); }); } setCommands(list) { if (!Array.isArray(list)) return this.commands; const commands = list .filter((c) => c && typeof c.name === 'string') .slice(0, 400) .map((c) => ({ name: c.name, description: String(c.description || '').slice(0, 200), argumentHint: String(c.argumentHint || ''), builtin: !!c.builtin })); this.commands = commands; writeJson(path.join(this.root, 'commands.json'), commands); return commands; } async notify(payload) { if (this.push) await this.push.send(payload); } normalize(settings) { const s = normalizeSettings(settings); if (s.permissionMode === 'bypassPermissions' && !this.canBypass) s.permissionMode = 'default'; return s; } loadSdk() { if (!this.sdkPromise) { this.sdkPromise = import(SDK_MODULE).catch((err) => { this.sdkPromise = null; throw err; }); } return this.sdkPromise; } workdirFor(name) { return path.join(this.workspaceRoot, name); } get(name) { let session = this.sessions.get(name); if (!session) { session = new ChatSession(this, name); this.sessions.set(name, session); } return session; } attach(ws, name, origin) { this.get(name).attach(ws, origin); } oauthRedirectUri(origin) { const base = this.publicUrl || normalizeOrigin(origin) || (this.spaceHost ? `https://${this.spaceHost}` : ''); return base ? `${base}/oauth/callback` : null; } addOAuthFlow(state, flow) { this.pruneOAuthFlows(); while (this.oauthFlows.size >= MAX_OAUTH_FLOWS) this.oauthFlows.delete(this.oauthFlows.keys().next().value); this.oauthFlows.set(state, { ...flow, createdAt: Date.now() }); } pruneOAuthFlows() { const cutoff = Date.now() - OAUTH_FLOW_TTL_MS; for (const [state, flow] of this.oauthFlows) { if (flow.createdAt < cutoff) this.oauthFlows.delete(state); } } // GET /oauth/callback. The request is not logged in (the auth cookie is // SameSite=Strict, so a redirect from another site does not carry it); the // one-time state of a sign-in started from the app is what authorizes it. // search: the raw query string ("?code=...&state=..."), passed on as is. async completeOAuth(query, search) { this.pruneOAuthFlows(); const state = query && typeof query.state === 'string' ? query.state : ''; const flow = state ? this.oauthFlows.get(state) : null; if (!flow || typeof search !== 'string' || search.length > MAX_CALLBACK_URL) { return { ok: false, unknown: true, error: 'Bu giriş bağlantısı tanınmadı ya da süresi doldu.', }; } this.oauthFlows.delete(state); const result = await flow.session.finishOAuth(flow.server, `${flow.redirectUri}${search}`, flow.run); return { ...result, server: flow.server }; } upload(name, req, fileName, declaredType) { return this.get(name).saveUpload(req, fileName, declaredType); } deleteUpload(name, id) { const session = this.sessions.get(name); return !!(session && session.deleteUpload(String(id))); } // Absolute path of a stored image, or null for anything that is not one. mediaPath(name, file) { if (!SESSION_NAME_RE.test(String(name)) || !MEDIA_FILE_RE.test(String(file))) return null; const abs = path.join(this.chatsRoot, name, 'media', file); return fs.existsSync(abs) ? abs : null; } // A file shared with share_file: its stored copy and metadata, or null. sharedFile(name, id) { if (!SESSION_NAME_RE.test(String(name)) || !FILE_ID_RE.test(String(id))) return null; const dir = path.join(this.chatsRoot, name, 'files', id); const meta = readJson(path.join(dir, 'meta.json'), null); const abs = path.join(dir, 'content'); if (!meta || !fs.existsSync(abs)) return null; return { abs, name: safeFileName(meta.name), size: num(meta.size), text: meta.text === true }; } allSessions() { return [...this.sessions.values()]; } // Claude Code's CLI: the one bundled with the Agent SDK (the same version // the chat runs), else `claude` on PATH. claudeBin() { if (this.claudeBinPath === undefined) { this.claudeBinPath = null; for (const pkg of [`claude-agent-sdk-${process.platform}-${process.arch}`, `claude-agent-sdk-${process.platform}-${process.arch}-musl`]) { try { this.claudeBinPath = require.resolve(`@anthropic-ai/${pkg}/claude`); break; } catch { // bu platform paketi yok } } if (!this.claudeBinPath) { for (const dir of String((this.childEnv && this.childEnv.PATH) || process.env.PATH || '').split(':')) { const candidate = dir ? path.join(dir, 'claude') : ''; if (!candidate) continue; try { fs.accessSync(candidate, fs.constants.X_OK); this.claudeBinPath = candidate; break; } catch { // bu dizinde yok } } } } return this.claudeBinPath; } // `claude mcp remove`: Claude Code edits its own configuration (it keeps // other Claude processes' writes to ~/.claude.json consistent). // One at a time: two removals tapped in a row edit the same file. removeMcpServer(name, scope, cwd) { const job = (this.mcpRemoveQueue || Promise.resolve()).then(() => this.runMcpRemove(name, scope, cwd)); this.mcpRemoveQueue = job; return job; } runMcpRemove(name, scope, cwd) { const bin = this.claudeBin(); if (!bin) return Promise.resolve({ ok: false, error: 'Claude Code komutu bulunamadı.' }); const env = { ...this.childEnv, DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }; return new Promise((resolve) => { execFile( bin, ['mcp', 'remove', '--scope', scope, '--', name], { cwd, env, timeout: MCP_REMOVE_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => { const output = `${stdout || ''}\n${stderr || ''}`.trim(); if (err) { const reason = output || (err.killed ? 'Zaman aşımı.' : errMessage(err)); resolve({ ok: false, notFound: /No MCP server (named|found)/i.test(output), error: truncate(reason, 1000) }); } else { console.log(`[mcp] "${name}" kaldirildi (${scope})`); resolve({ ok: true, output: truncate(output, 1000) }); } } ); }); } archives(name) { return this.get(name).listArchives(); } archive(name, id) { return this.get(name).readArchive(id); } deleteArchive(name, id) { return this.get(name).deleteArchive(id); } restoreArchive(name, id) { return this.get(name).restoreArchive(id); } // `claude mcp add --transport http` for every session; idle Claude // processes restart so the next message (or the MCP screen) sees it. addRemoteMcp(name, url) { const bin = this.claudeBin(); if (!bin) return Promise.resolve({ ok: false, error: 'Claude Code komutu bulunamadı.' }); const env = { ...this.childEnv, DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }; return new Promise((resolve) => { execFile(bin, ['mcp', 'add', '--scope', 'user', '--transport', 'http', name, url], { cwd: this.home, env, timeout: MCP_REMOVE_TIMEOUT_MS }, (err, stdout, stderr) => { const output = `${stdout || ''}\n${stderr || ''}`.trim(); if (err && !/already exists/i.test(output)) { resolve({ ok: false, error: truncate(output || errMessage(err), 600) }); return; } for (const session of this.sessions.values()) { if (session.run && !session.running && session.pending.size === 0) session.closeRun(session.run, false); } resolve({ ok: true, output: truncate(output, 600) }); }); }); } isRunning(name) { const session = this.sessions.get(name); return !!(session && session.running); } dispose(name) { const session = this.sessions.get(name); if (!session) return; session.shutdown(); this.sessions.delete(name); } getModels() { return this.models ? mergeFallbackModels(this.models) : FALLBACK_MODELS; } setModels(list) { if (!Array.isArray(list) || !list.length) return; const models = list .filter((m) => m && typeof m.value === 'string' && (m.value === 'default' || MODEL_RE.test(m.value))) .map((m) => ({ value: m.value === 'default' ? '' : m.value, displayName: String(m.displayName || m.value), description: String(m.description || ''), supportedEffortLevels: Array.isArray(m.supportedEffortLevels) ? m.supportedEffortLevels.filter((e) => EFFORT_LEVELS.includes(e)) : m.supportsEffort ? EFFORT_LEVELS : [], supportsAdaptiveThinking: m.supportsAdaptiveThinking, supportsAutoMode: !!m.supportsAutoMode, group: 'main', })); if (!models.length) return; if (!models.some((m) => m.value === '')) { models.unshift({ ...FALLBACK_MODELS[0] }); } if (this.models && JSON.stringify(this.models) === JSON.stringify(models)) return; this.models = models; writeJson(this.modelsFile, models); for (const session of this.sessions.values()) session.broadcastStatus(); } supportsAdaptive(model) { // Haiku 4.5 and older have no adaptive thinking; the "haiku" alias is 4.5 here. if (/^haiku$|haiku-[34]/i.test(model || '')) return false; const info = this.getModels().find((m) => m.value === (model || '')); return !(info && info.supportsAdaptiveThinking === false); } setRateLimit(info) { this.rateLimit = info; for (const session of this.sessions.values()) session.broadcast({ type: 'rate', info }); } // New sessions reuse the last model/effort, but always start in the default // permission mode: a bypass or auto-accept choice should never carry over silently. rememberDefaults(settings) { this.defaults = this.normalize({ model: settings.model, effort: settings.effort }); writeJson(this.defaultsFile, this.defaults); } } module.exports = { ChatManager, EFFORT_LEVELS, PERMISSION_MODES, splitEmotion, webUiPrompt, normalizePrefs, eventsToMarkdown };