From 9a44a64f11c47c83c7eb2aa5cf60e0138e24d877 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:32:00 +0800 Subject: [PATCH 01/23] fix: enforce runtime safety and cancellation --- apps/desktop/src-tauri/Cargo.lock | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/lib.rs | 6 +- apps/desktop/src-tauri/src/tools.rs | 166 +++++++++++++++++++++-- apps/desktop/src/lib/mac-agent.ts | 2 +- apps/desktop/src/lib/mac-tools.ts | 42 ++++-- apps/lsp/src/handler.test.ts | 22 ++- apps/lsp/src/handler.ts | 21 ++- apps/vscode/src/extension.ts | 4 + packages/core/src/agent.test.ts | 107 ++++++++++++++- packages/core/src/agent.ts | 120 ++++++++++------ packages/core/src/index.ts | 8 ++ packages/core/src/runtime/index.ts | 6 + packages/core/src/runtime/policy.ts | 43 ++++++ packages/core/src/tools/bash.test.ts | 19 ++- packages/core/src/tools/bash.ts | 85 +++++++++--- packages/core/src/worktree/index.test.ts | 16 +++ packages/core/src/worktree/index.ts | 26 ++-- 18 files changed, 590 insertions(+), 105 deletions(-) create mode 100644 packages/core/src/runtime/index.ts create mode 100644 packages/core/src/runtime/policy.ts diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d25f9f5..ce392f9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -675,6 +675,7 @@ name = "deepcode_desktop" version = "0.1.6" dependencies = [ "dirs 5.0.1", + "libc", "serde", "serde_json", "sha2", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 703e41d..7a0e4f0 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ sha2 = "0.10" thiserror = "1" tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "sync", "time", "process"] } dirs = "5" +libc = "0.2" [profile.release] panic = "abort" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 866d119..ed42f40 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -24,7 +24,9 @@ use commands::{ }; use snapshots::session_snapshots; use tauri::Manager; -use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; +use tools::{ + tool_bash, tool_bash_cancel, tool_edit, tool_glob, tool_grep, tool_read, tool_write, BashState, +}; use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -37,6 +39,7 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(VoiceState::default()) + .manage(BashState::default()) .invoke_handler(tauri::generate_handler![ get_app_info, read_credentials, @@ -62,6 +65,7 @@ pub fn run() { tool_write, tool_edit, tool_bash, + tool_bash_cancel, tool_glob, tool_grep, session_snapshots, diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs index 3f43c07..f2eee17 100644 --- a/apps/desktop/src-tauri/src/tools.rs +++ b/apps/desktop/src-tauri/src/tools.rs @@ -5,10 +5,12 @@ use crate::snapshots; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::Path; use std::process::Stdio; use tokio::io::AsyncReadExt; use tokio::process::Command; +use tokio::sync::{oneshot, Mutex}; // ────────────────────────────────────────────────────────────────────────── // Snapshot capture @@ -240,10 +242,42 @@ pub struct BashOk { pub stderr: String, pub exit_code: i32, pub timed_out: bool, + pub cancelled: bool, } +#[derive(Default)] +pub struct BashState { + // `Some(sender)` is running; `None` records an abort that raced ahead of + // command registration so the process never escapes cancellation. + active: Mutex>>>, +} + +#[cfg(unix)] +fn kill_process_group(pid: u32) { + // The shell is placed in its own process group below, so a negative PID + // terminates the shell and every descendant it spawned. + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } +} + +#[cfg(not(unix))] +fn kill_process_group(_pid: u32) {} + #[tauri::command] -pub async fn tool_bash(input: BashInput) -> Result { +pub async fn tool_bash( + input: BashInput, + command_id: String, + state: tauri::State<'_, BashState>, +) -> Result { + run_bash(input, command_id, &state).await +} + +async fn run_bash( + input: BashInput, + command_id: String, + state: &BashState, +) -> Result { let timeout = std::time::Duration::from_millis(input.timeout_ms.unwrap_or(120_000)); let mut cmd = Command::new("/bin/sh"); cmd.arg("-c").arg(&input.command); @@ -251,8 +285,24 @@ pub async fn tool_bash(input: BashInput) -> Result { cmd.current_dir(cwd); } cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.as_std_mut().process_group(0); + } let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?; + let pid = child.id().ok_or("spawned process has no pid")?; + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + { + let mut active = state.active.lock().await; + if matches!(active.get(&command_id), Some(None)) { + active.remove(&command_id); + drop(cancel_tx); + } else { + active.insert(command_id.clone(), Some(cancel_tx)); + } + } let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?; let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?; @@ -268,31 +318,77 @@ pub async fn tool_bash(input: BashInput) -> Result { s }); - let mut timed_out = false; - let exit_status = match tokio::time::timeout(timeout, child.wait()).await { - Ok(s) => s.map_err(|e| format!("wait: {e}"))?, - Err(_) => { - timed_out = true; + enum Finish { + Exited(std::io::Result), + TimedOut, + Cancelled, + } + let finish = tokio::select! { + status = child.wait() => Finish::Exited(status), + _ = tokio::time::sleep(timeout) => Finish::TimedOut, + _ = &mut cancel_rx => Finish::Cancelled, + }; + state.active.lock().await.remove(&command_id); + + let (exit_code, timed_out, cancelled) = match finish { + Finish::Exited(status) => ( + status + .map_err(|e| format!("wait: {e}"))? + .code() + .unwrap_or(-1), + false, + false, + ), + Finish::TimedOut => { + kill_process_group(pid); + let _ = child.start_kill(); + let _ = child.wait().await; + (124, true, false) + } + Finish::Cancelled => { + kill_process_group(pid); let _ = child.start_kill(); let _ = child.wait().await; - return Ok(BashOk { - stdout: String::new(), - stderr: format!("timeout after {}ms", timeout.as_millis()), - exit_code: 124, - timed_out, - }); + (130, false, true) } }; let stdout = stdout_task.await.unwrap_or_default(); - let stderr = stderr_task.await.unwrap_or_default(); + let mut stderr = stderr_task.await.unwrap_or_default(); + if timed_out { + stderr.push_str(&format!("\ntimeout after {}ms", timeout.as_millis())); + } + if cancelled { + stderr.push_str("\naborted by user"); + } Ok(BashOk { stdout, stderr, - exit_code: exit_status.code().unwrap_or(-1), + exit_code, timed_out, + cancelled, }) } +#[tauri::command] +pub async fn tool_bash_cancel( + command_id: String, + state: tauri::State<'_, BashState>, +) -> Result { + Ok(cancel_bash(command_id, &state).await) +} + +async fn cancel_bash(command_id: String, state: &BashState) -> bool { + let mut active = state.active.lock().await; + match active.remove(&command_id) { + Some(Some(cancel)) => cancel.send(()).is_ok(), + Some(None) => true, + None => { + active.insert(command_id, None); + true + } + } +} + // ────────────────────────────────────────────────────────────────────────── // Glob (filesystem pattern match) // ────────────────────────────────────────────────────────────────────────── @@ -454,17 +550,59 @@ mod casing_tests { stderr: String::new(), exit_code: 0, timed_out: false, + cancelled: false, }) .unwrap(); let k = keys(&v); // The exit-code badge bug: renderer compares r.exitCode !== 0. assert!(k.contains(&"exitCode".to_string()), "got {k:?}"); assert!(k.contains(&"timedOut".to_string()), "got {k:?}"); + assert!(k.contains(&"cancelled".to_string()), "got {k:?}"); assert!( !k.contains(&"exit_code".to_string()), "snake_case leaked: {k:?}" ); } + + #[cfg(unix)] + #[tokio::test] + async fn bash_cancel_kills_descendants() { + use std::sync::Arc; + + let root = std::env::temp_dir().join(format!( + "dc-rust-bash-cancel-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let marker = root.join("orphan-marker.txt"); + let command = format!("(sleep 0.4; echo orphan > '{}') & wait", marker.display()); + let state = Arc::new(BashState::default()); + let run_state = state.clone(); + let task = tokio::spawn(async move { + run_bash( + BashInput { + command, + cwd: Some(root.to_string_lossy().to_string()), + timeout_ms: Some(5_000), + }, + "cancel-test".to_string(), + &run_state, + ) + .await + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!(cancel_bash("cancel-test".to_string(), &state).await); + let result = task.await.unwrap().unwrap(); + assert!(result.cancelled); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert!(!marker.exists(), "descendant survived cancellation"); + let _ = std::fs::remove_dir_all(marker.parent().unwrap()); + } } // ── snapshot capture path ─────────────────────────────────────────────── diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts index a0c6cda..034e286 100644 --- a/apps/desktop/src/lib/mac-agent.ts +++ b/apps/desktop/src/lib/mac-agent.ts @@ -203,7 +203,7 @@ export async function startAgentTurn(args: StartTurnArgs): Promise): Promise { + async execute(input: Record, ctx): Promise { try { const command = pickStr(input, 'command', 'cmd'); if (!command) { return { content: 'Error: missing command', isError: true }; } - const r = (await invoke('tool_bash', { - input: { - command, - cwd: pickStr(input, 'cwd', 'working_dir'), - timeout_ms: pickNum(input, 'timeout_ms', 'timeoutMs', 'timeout'), - }, - })) as { stdout: string; stderr: string; exitCode: number; timedOut: boolean }; + if (ctx.signal?.aborted) { + return { content: 'aborted by user', isError: true }; + } + const commandId = `bash-${Date.now().toString(36)}-${bashCommandSeq++}`; + const onAbort = (): void => { + void invoke('tool_bash_cancel', { commandId }); + }; + ctx.signal?.addEventListener('abort', onAbort, { once: true }); + let r: { + stdout: string; + stderr: string; + exitCode: number; + timedOut: boolean; + cancelled: boolean; + }; + try { + r = (await invoke('tool_bash', { + commandId, + input: { + command, + cwd: pickStr(input, 'cwd', 'working_dir'), + timeout_ms: pickNum(input, 'timeout_ms', 'timeoutMs', 'timeout'), + }, + })) as typeof r; + } finally { + ctx.signal?.removeEventListener('abort', onAbort); + } const combined = (r.stdout || '') + (r.stderr ? `\n[stderr]\n${r.stderr}` : ''); return { content: combined || `(no output, exit ${r.exitCode})`, - data: { exitCode: r.exitCode, timedOut: r.timedOut }, - isError: r.exitCode !== 0, + data: { exitCode: r.exitCode, timedOut: r.timedOut, cancelled: r.cancelled }, + isError: r.exitCode !== 0 || r.cancelled, }; } catch (err) { return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index 6c2b404..d365019 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { handleMessage, type LspMessage } from './handler.js'; +import { __test, handleMessage, type LspMessage } from './handler.js'; describe('handleMessage — initialize', () => { it('returns capabilities + serverInfo + supported commands', async () => { @@ -102,6 +102,26 @@ describe('handleMessage — executeCommand', () => { expect((out[0]!.result as { aborted: boolean }).aborted).toBe(false); }); + it('deepcode.abort aborts the active turn controller', async () => { + const controller = new AbortController(); + __test.state.activeTurns.set('active-turn', controller); + const out: LspMessage[] = []; + + await handleMessage( + { + jsonrpc: '2.0', + id: 41, + method: 'workspace/executeCommand', + params: { command: 'deepcode.abort', arguments: [{ turnId: 'active-turn' }] }, + }, + (m) => out.push(m), + ); + + expect((out[0]!.result as { aborted: boolean }).aborted).toBe(true); + expect(controller.signal.aborted).toBe(true); + __test.state.activeTurns.delete('active-turn'); + }); + it('errors on unknown command', async () => { const out: LspMessage[] = []; await handleMessage( diff --git a/apps/lsp/src/handler.ts b/apps/lsp/src/handler.ts index 94dee15..1651cb4 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -16,13 +16,13 @@ interface ServerState { initialized: boolean; /** Workspace root URI from initialize. */ rootUri?: string; - /** In-flight turn IDs so /abort can cancel them. */ - activeTurns: Set; + /** In-flight turn controllers so /abort cancels provider and tools. */ + activeTurns: Map; } const state: ServerState = { initialized: false, - activeTurns: new Set(), + activeTurns: new Map(), }; const SERVER_INFO = { @@ -116,7 +116,8 @@ async function handleRunAgent( ): Promise<{ turnId: string }> { if (!args.prompt) throw new Error('prompt is required'); const turnId = `lsp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; - state.activeTurns.add(turnId); + const abortController = new AbortController(); + state.activeTurns.set(turnId, abortController); // Stream events back via JSON-RPC notifications. // Wired to the real agent loop — same code that drives the CLI / Mac client. @@ -133,7 +134,7 @@ async function handleRunAgent( const [ { runAgent }, { DeepSeekProvider }, - { ToolRegistry, BUILTIN_TOOLS }, + { ToolRegistry, BUILTIN_TOOLS, SAFE_READONLY_TOOLS }, { resolveCredentials, CredentialsStore }, ] = await Promise.all([ import('@deepcode/core').then((m) => ({ runAgent: m.runAgent })), @@ -141,6 +142,7 @@ async function handleRunAgent( import('@deepcode/core').then((m) => ({ ToolRegistry: m.ToolRegistry, BUILTIN_TOOLS: m.BUILTIN_TOOLS, + SAFE_READONLY_TOOLS: m.SAFE_READONLY_TOOLS, })), import('@deepcode/core').then((m) => ({ resolveCredentials: m.resolveCredentials, @@ -168,6 +170,9 @@ async function handleRunAgent( userMessage: args.prompt!, model: args.model ?? 'deepseek-chat', cwd: state.rootUri ? new URL(state.rootUri).pathname : process.cwd(), + signal: abortController.signal, + mode: 'default', + permissions: { allow: [...SAFE_READONLY_TOOLS] }, onEvent: (e) => { send({ jsonrpc: '2.0', @@ -207,8 +212,10 @@ async function handleRunAgent( function handleAbort(args: { turnId?: string }): { aborted: boolean } { if (!args.turnId) throw new Error('turnId is required'); - const had = state.activeTurns.delete(args.turnId); - return { aborted: had }; + const controller = state.activeTurns.get(args.turnId); + if (!controller) return { aborted: false }; + controller.abort(); + return { aborted: true }; } async function handleListSkills(): Promise<{ skills: unknown[] }> { diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 9fc5f16..3ccd089 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -96,6 +96,8 @@ async function runAgent( userMessage, model: 'deepseek-chat', cwd, + mode: 'default', + permissions: { allow: [...core.SAFE_READONLY_TOOLS] }, onEvent: (e) => { if (e.type === 'text_delta') out.append(e.text); else if (e.type === 'tool_use') out.appendLine(`\n[${e.name}] ${formatInput(e.input)}`); @@ -162,6 +164,8 @@ class ChatViewProvider implements vscode.WebviewViewProvider { userMessage: msg.text, model: 'deepseek-chat', cwd: this.vscodeMod.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), + mode: 'default', + permissions: { allow: [...core.SAFE_READONLY_TOOLS] }, onEvent: (e) => { if (e.type === 'text_delta') { buffer += e.text; diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index bca01a4..81f24af 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runAgent } from './agent.js'; +import { runAgent as runAgentCore, type RunAgentOptions } from './agent.js'; import { HookDispatcher } from './hooks/index.js'; import { SessionManager } from './sessions/index.js'; import { ToolRegistry } from './tools/registry.js'; @@ -16,6 +16,13 @@ import type { } from './types.js'; import type { Provider, ProviderResult, ProviderRunOpts } from './providers/types.js'; +type TestRunAgentOptions = Omit & { mode?: RunAgentOptions['mode'] }; + +/** Most loop tests predate policy dispatch and focus on orchestration behavior. */ +function runAgent(opts: TestRunAgentOptions) { + return runAgentCore({ mode: 'bypassPermissions', ...opts }); +} + /** * MockProvider — pulls scripted responses from a queue, allowing fully deterministic * agent loop tests with no real API calls. @@ -190,6 +197,104 @@ describe('runAgent', () => { expect(result.turnsUsed).toBe(0); }); + it('classifies a provider AbortError as an aborted run', async () => { + const ac = new AbortController(); + let markEntered!: () => void; + const entered = new Promise((resolve) => { + markEntered = resolve; + }); + const provider: Provider = { + name: 'abortable', + runTurn: async () => { + markEntered(); + await new Promise((_resolve, reject) => { + ac.signal.addEventListener( + 'abort', + () => reject(Object.assign(new Error('cancelled'), { name: 'AbortError' })), + { once: true }, + ); + }); + throw new Error('unreachable'); + }, + }; + const pending = runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + signal: ac.signal, + }); + await entered; + ac.abort(); + await expect(pending).resolves.toMatchObject({ stopReason: 'aborted', turnsUsed: 1 }); + }); + + it('fails safe for a legacy caller that omits mode and permissions', async () => { + const provider = new MockProvider([ + toolUse('writing', { + type: 'tool_use', + id: 'write-1', + name: 'Write', + input: { file_path: 'blocked.txt', content: 'must not exist' }, + }), + endTurn('done'), + ]); + + const result = await runAgentCore({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'write a file', + model: 'deepseek-chat', + cwd, + } as RunAgentOptions); + + expect(result.stopReason).toBe('end_turn'); + await expect(fs.access(join(cwd, 'blocked.txt'))).rejects.toThrow(); + const toolResult = result.history + .flatMap((message) => message.content) + .find((block) => block.type === 'tool_result'); + expect(toolResult).toMatchObject({ is_error: true }); + }); + + it('aborts while an approval prompt is pending', async () => { + const ac = new AbortController(); + let approvalStarted!: () => void; + const started = new Promise((resolve) => { + approvalStarted = resolve; + }); + const provider = new MockProvider([ + toolUse('writing', { + type: 'tool_use', + id: 'write-pending', + name: 'Write', + input: { file_path: 'pending.txt', content: 'must not exist' }, + }), + ]); + + const pending = runAgentCore({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'write a file', + model: 'deepseek-chat', + cwd, + signal: ac.signal, + mode: 'default', + approval: async () => { + approvalStarted(); + return new Promise(() => {}); + }, + }); + await started; + ac.abort(); + + await expect(pending).resolves.toMatchObject({ stopReason: 'aborted', turnsUsed: 1 }); + await expect(fs.access(join(cwd, 'pending.txt'))).rejects.toThrow(); + }); + it('persists messages and captures snapshots when session is provided', async () => { await fs.writeFile(join(cwd, 'edit-me.txt'), 'before'); const sessionMgr = new SessionManager({ root: sessionsRoot }); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 9d81cc6..6e24d82 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -8,6 +8,7 @@ import { TaskManager, type TaskRunner } from './tasks/manager.js'; import type { HookDispatcher } from './hooks/index.js'; import type { Mode } from './types.js'; import type { Provider } from './providers/types.js'; +import { resolveRuntimePolicy } from './runtime/index.js'; // NOTE: reminders + sessions are lazy-loaded inside the loop so a browser // build (Tauri renderer) that doesn't use them avoids pulling node:fs at // module-load time. See `loadRemindersIfEnabled` and `appendSessionIfSet`. @@ -60,9 +61,8 @@ export interface RunAgentOptions { session?: { manager: SessionManager; id: string }; /** Optional: snapshot files before/after Edit/Write tool calls. */ enableSnapshots?: boolean; - /** M3: dispatch gates (mode + permissions + hooks). When set, every tool call - * goes through the gate. When unset, all tool calls are allowed (M1 behavior). */ - mode?: Mode; + /** Required dispatch mode. Every tool call goes through the central gate. */ + mode: Mode; permissions?: PermissionRules; hooks?: HookDispatcher; approval?: ApprovalCallback; @@ -141,6 +141,35 @@ export interface RunAgentResult { const DEFAULT_MAX_TURNS = 16; +async function waitForApproval( + approval: ApprovalCallback, + toolName: string, + toolInput: Record, + verdict: DispatchVerdict, + signal?: AbortSignal, +): Promise { + if (!signal) return approval(toolName, toolInput, verdict); + if (signal.aborted) return false; + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (decision: ApprovalDecision): void => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + resolve(decision); + }; + const onAbort = (): void => finish(false); + signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve(approval(toolName, toolInput, verdict)).then(finish, (error: unknown) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + reject(error); + }); + }); +} + /** * Tools with no side effects whose results don't depend on each other — safe to * execute concurrently within a single turn. Everything else (Edit/Write/Bash/ @@ -155,6 +184,7 @@ const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch' */ export async function runAgent(opts: RunAgentOptions): Promise { const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS; + const runtimePolicy = resolveRuntimePolicy(opts); let history: StoredMessage[] = [...(opts.history ?? [])]; let snapshotSeq = (await opts.session?.manager.snapshots(opts.session.id))?.length ?? 0; @@ -291,8 +321,8 @@ export async function runAgent(opts: RunAgentOptions): Promise { // A background task passes its own signal so TaskStop can cancel just // that task; foreground sub-agents inherit the main run's signal. signal: signal ?? opts.signal, - mode: opts.mode, - permissions: opts.permissions, + mode: runtimePolicy.mode, + permissions: runtimePolicy.permissions, hooks: opts.hooks, sandboxConfig: opts.sandboxConfig, autoMode: opts.autoMode, @@ -450,6 +480,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { }, }); } catch (err) { + if (opts.signal?.aborted || (err as { name?: string }).name === 'AbortError') { + return { history, turnsUsed, usage: totalUsage, stopReason: 'aborted', modeSignal }; + } const message = (err as Error).message ?? 'unknown'; opts.onEvent?.({ type: 'error', error: message }); return { history, turnsUsed, usage: totalUsage, stopReason: 'error', modeSignal }; @@ -522,42 +555,49 @@ export async function runAgent(opts: RunAgentOptions): Promise { continue; } - // M3: dispatch gate (mode + permissions + PreToolUse hook) - if (opts.mode) { - const verdict = await dispatchToolCall({ - tool: toolUse.name, - input: toolUse.input, - mode: opts.mode, - rules: opts.permissions, - hooks: opts.hooks, - cwd: opts.cwd, - autoMode: opts.autoMode, - autoModeProvider: opts.provider, - }); - let allowed = verdict.decision === 'allow'; - if (verdict.decision === 'ask' && opts.approval) { - const decision = await opts.approval(toolUse.name, toolUse.input, verdict); - // 'always' = host has (or will) persist a matcher; treat as allow-this-call. - allowed = decision === true || decision === 'always'; - } - if (!allowed) { - resultsById.set(toolUse.id, { - type: 'tool_result', - tool_use_id: toolUse.id, - content: `Tool call blocked: ${verdict.reason}`, - is_error: true, - }); - opts.onEvent?.({ - type: 'tool_result', - id: toolUse.id, - result: { - content: verdict.reason, - isError: true, - data: { dispatchSource: verdict.source, decision: verdict.decision }, - }, - }); - continue; + // Every call goes through the central mode + permissions + hook gate. + const verdict = await dispatchToolCall({ + tool: toolUse.name, + input: toolUse.input, + mode: runtimePolicy.mode, + rules: runtimePolicy.permissions, + hooks: opts.hooks, + cwd: opts.cwd, + autoMode: opts.autoMode, + autoModeProvider: opts.provider, + }); + let allowed = verdict.decision === 'allow'; + if (verdict.decision === 'ask' && opts.approval) { + const decision = await waitForApproval( + opts.approval, + toolUse.name, + toolUse.input, + verdict, + opts.signal, + ); + if (opts.signal?.aborted) { + return { history, turnsUsed, usage: totalUsage, stopReason: 'aborted', modeSignal }; } + // 'always' = host has (or will) persist a matcher; treat as allow-this-call. + allowed = decision === true || decision === 'always'; + } + if (!allowed) { + resultsById.set(toolUse.id, { + type: 'tool_result', + tool_use_id: toolUse.id, + content: `Tool call blocked: ${verdict.reason}`, + is_error: true, + }); + opts.onEvent?.({ + type: 'tool_result', + id: toolUse.id, + result: { + content: verdict.reason, + isError: true, + data: { dispatchSource: verdict.source, decision: verdict.decision }, + }, + }); + continue; } ready.push({ toolUse, handler }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a685970..b93eb43 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -172,6 +172,14 @@ export { // Agent loop's approval callback type (M3b) export type { ApprovalCallback, ApprovalDecision } from './agent.js'; +// Runtime safety policy shared by non-interactive hosts. +export { + SAFE_DEFAULT_PERMISSIONS, + SAFE_READONLY_TOOLS, + resolveRuntimePolicy, + type RuntimePolicyInput, +} from './runtime/index.js'; + // Skills (M4 — SKILL.md frontmatter loading + system-prompt builder; M5 — Skill tool) export { loadSkills, diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts new file mode 100644 index 0000000..2fc289a --- /dev/null +++ b/packages/core/src/runtime/index.ts @@ -0,0 +1,6 @@ +export { + SAFE_DEFAULT_PERMISSIONS, + SAFE_READONLY_TOOLS, + resolveRuntimePolicy, + type RuntimePolicyInput, +} from './policy.js'; diff --git a/packages/core/src/runtime/policy.ts b/packages/core/src/runtime/policy.ts new file mode 100644 index 0000000..7ce3403 --- /dev/null +++ b/packages/core/src/runtime/policy.ts @@ -0,0 +1,43 @@ +import type { PermissionRules } from '../config/types.js'; +import type { Mode } from '../types.js'; + +/** + * Tools that a host without an approval UI may safely expose by default. + * Unknown, write-capable, and extension-provided tools intentionally do not + * appear here, so they resolve to `ask` and are blocked when no approval + * callback is installed. + */ +export const SAFE_READONLY_TOOLS = Object.freeze([ + 'Read', + 'Grep', + 'Glob', + 'WebFetch', + 'WebSearch', + 'AskUserQuestion', + 'ExitPlanMode', + 'ToolSearch', +] as const); + +export const SAFE_DEFAULT_PERMISSIONS: Readonly = Object.freeze({ + allow: [...SAFE_READONLY_TOOLS], +}); + +export interface RuntimePolicyInput { + mode?: Mode; + permissions?: PermissionRules; +} + +/** + * Runtime fallback for untyped/legacy callers. Typed callers must still pass + * `mode`, but JavaScript and stale integrations fail safe instead of silently + * bypassing the dispatcher. + */ +export function resolveRuntimePolicy(input: RuntimePolicyInput): { + mode: Mode; + permissions: PermissionRules; +} { + return { + mode: input.mode ?? 'default', + permissions: input.permissions ?? { allow: [...SAFE_READONLY_TOOLS] }, + }; +} diff --git a/packages/core/src/tools/bash.test.ts b/packages/core/src/tools/bash.test.ts index b52ae08..71b33bc 100644 --- a/packages/core/src/tools/bash.test.ts +++ b/packages/core/src/tools/bash.test.ts @@ -1,5 +1,5 @@ import type { ChildProcess } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; @@ -48,6 +48,23 @@ describe('BashTool', () => { expect(r.content).toMatch(/killed by timeout/i); }, 5000); + it('aborts the foreground process tree', async () => { + if (process.platform === 'win32') return; + const marker = join(tmp, 'orphan-marker.txt'); + const ac = new AbortController(); + const pending = BashTool.execute( + { command: `(sleep 0.4; echo orphan > "${marker}") & wait`, timeout: 5_000 }, + { cwd: tmp, signal: ac.signal }, + ); + setTimeout(() => ac.abort(), 50); + + const result = await pending; + expect(result.isError).toBe(true); + expect(result.content).toMatch(/aborted by user/i); + await new Promise((resolve) => setTimeout(resolve, 500)); + await expect(access(marker)).rejects.toThrow(); + }, 5000); + it('run_in_background returns immediately with a log path that fills in', async () => { const r = await BashTool.execute( { command: 'echo bg-output-here', run_in_background: true }, diff --git a/packages/core/src/tools/bash.ts b/packages/core/src/tools/bash.ts index 36d3313..3b8c5dd 100644 --- a/packages/core/src/tools/bash.ts +++ b/packages/core/src/tools/bash.ts @@ -6,7 +6,7 @@ // that can't be set up (e.g. can't bind the DNS proxy on :53), fail CLOSED to // deny-all-net rather than running unrestricted. -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -39,6 +39,7 @@ type SandboxCtx = ToolContext & { const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes const MAX_OUTPUT_BYTES = 30_000; +type TerminationReason = 'timeout' | 'aborted'; // Monotonic suffix so two background spawns in the same millisecond from the // same pid don't collide on a log filename. @@ -54,7 +55,7 @@ function capStream(s: string, label: string): string { function summarize( stdout: string, stderr: string, - killed: boolean, + terminationReason: TerminationReason | undefined, code: number | null, timeoutMs: number, note?: string, @@ -63,15 +64,39 @@ function summarize( if (note) parts.push(note); if (stdout) parts.push(`\n${stdout}\n`); if (stderr) parts.push(`\n${stderr}\n`); - if (killed) parts.push(`[killed by timeout after ${timeoutMs}ms]`); + if (terminationReason === 'timeout') parts.push(`[killed by timeout after ${timeoutMs}ms]`); + if (terminationReason === 'aborted') parts.push('[aborted by user]'); parts.push(`exit: ${code ?? 'unknown'}`); return { content: parts.join('\n'), - data: { exitCode: code, killed, stdoutBytes: stdout.length, stderrBytes: stderr.length }, - isError: killed || (code !== null && code !== 0), + data: { + exitCode: code, + killed: terminationReason !== undefined, + terminationReason, + stdoutBytes: stdout.length, + stderrBytes: stderr.length, + }, + isError: terminationReason !== undefined || (code !== null && code !== 0), }; } +/** Kill the whole foreground process group on POSIX, not just its shell. */ +function killProcessTree(child: ChildProcess, signal: NodeJS.Signals): void { + if (process.platform !== 'win32' && child.pid !== undefined) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // The group may already have exited; fall back to the direct child. + } + } + try { + child.kill(signal); + } catch { + // Process already exited. + } +} + /** * Foreground run under the slirp4netns selective-network sandbox. Rejects with * NetworkSandboxUnavailable if setup fails (caller falls back to deny-all-net). @@ -87,7 +112,7 @@ async function runForegroundNet( return new Promise((resolve) => { let stdout = ''; let stderr = ''; - let killed = false; + let terminationReason: TerminationReason | undefined; let settled = false; const finish = (r: ToolResult): void => { if (!settled) { @@ -96,11 +121,11 @@ async function runForegroundNet( } }; const timer = setTimeout(() => { - killed = true; + terminationReason = 'timeout'; void handle.close(); }, timeoutMs); const onAbort = (): void => { - killed = true; + terminationReason = 'aborted'; void handle.close(); }; ctx.signal?.addEventListener('abort', onAbort, { once: true }); @@ -114,7 +139,7 @@ async function runForegroundNet( .then((code) => { clearTimeout(timer); ctx.signal?.removeEventListener('abort', onAbort); - finish(summarize(stdout, stderr, killed, code, timeoutMs)); + finish(summarize(stdout, stderr, terminationReason, code, timeoutMs)); }) .catch((err: unknown) => { clearTimeout(timer); @@ -153,6 +178,13 @@ export const BashTool: ToolHandler = { if (!input?.command || typeof input.command !== 'string') { return { content: 'Error: command is required (string).', isError: true }; } + if (ctx.signal?.aborted) { + return { + content: '[aborted by user]', + isError: true, + data: { terminationReason: 'aborted' }, + }; + } const timeoutMs = Math.max(1_000, input.timeout ?? DEFAULT_TIMEOUT_MS); // M3.5: wrap under platform sandbox if configured. ctx.sandboxConfig is @@ -239,20 +271,33 @@ export const BashTool: ToolHandler = { return new Promise((resolvePromise) => { const child = spawn(wrapped.command, wrapped.args, { cwd: ctx.cwd, - signal: ctx.signal, + detached: process.platform !== 'win32', }); let stdout = ''; let stderr = ''; - let killed = false; - const timer = setTimeout(() => { - killed = true; - // SIGKILL + destroy pipes — on Ubuntu CI, dash leaves orphaned children - // whose inherited stdout/stderr fds keep `close` from firing on the - // parent. Destroying the pipes forces close. - child.kill('SIGKILL'); + let terminationReason: TerminationReason | undefined; + let settled = false; + const finish = (result: ToolResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + ctx.signal?.removeEventListener('abort', onAbort); + resolvePromise(result); + }; + const terminate = (reason: TerminationReason): void => { + if (terminationReason) return; + terminationReason = reason; + killProcessTree(child, 'SIGKILL'); + // Descendants can inherit these descriptors; destroying them also + // prevents an orphan from keeping the Promise open indefinitely. child.stdout?.destroy(); child.stderr?.destroy(); + }; + const timer = setTimeout(() => { + terminate('timeout'); }, timeoutMs); + const onAbort = (): void => terminate('aborted'); + ctx.signal?.addEventListener('abort', onAbort, { once: true }); child.stdout.on('data', (chunk: Buffer) => { stdout = capStream(stdout + chunk.toString('utf8'), 'stdout'); @@ -262,16 +307,14 @@ export const BashTool: ToolHandler = { }); child.on('error', (err) => { - clearTimeout(timer); - resolvePromise({ + finish({ content: `Error spawning command: ${err.message}`, isError: true, }); }); child.on('close', (code) => { - clearTimeout(timer); - resolvePromise(summarize(stdout, stderr, killed, code, timeoutMs, failNote)); + finish(summarize(stdout, stderr, terminationReason, code, timeoutMs, failNote)); }); }); }, diff --git a/packages/core/src/worktree/index.test.ts b/packages/core/src/worktree/index.test.ts index c20edbf..f47e52f 100644 --- a/packages/core/src/worktree/index.test.ts +++ b/packages/core/src/worktree/index.test.ts @@ -73,6 +73,22 @@ describe('createWorktree / removeWorktree', () => { expect(await fs.readFile(join(h.path, 'a.txt'), 'utf8')).toBe('A'); await removeWorktree(h); await expect(fs.access(h.path)).rejects.toThrow(); + const branch = spawnSync('git', ['-C', src, 'rev-parse', '--verify', h.branch], { + encoding: 'utf8', + env: cleanGitEnv(), + }); + expect(branch.status).toBe(0); + }); + + it('refuses to remove a dirty worktree', async () => { + const h = await createWorktree({ source: src, parentDir: parent }); + const changed = join(h.path, 'a.txt'); + await fs.writeFile(changed, 'unsaved work'); + + await expect(removeWorktree(h)).rejects.toThrow(/worktree remove/); + expect(await fs.readFile(changed, 'utf8')).toBe('unsaved work'); + + runOrFail('git', ['worktree', 'remove', '--force', h.path], src); }); it('honors baseRef from config', async () => { diff --git a/packages/core/src/worktree/index.ts b/packages/core/src/worktree/index.ts index ba24fec..ab1483f 100644 --- a/packages/core/src/worktree/index.ts +++ b/packages/core/src/worktree/index.ts @@ -20,6 +20,8 @@ export interface WorktreeHandle { branch: string; /** Source repo path. */ source: string; + /** Untracked symlinks created by DeepCode and safe to unlink on removal. */ + managedSymlinks?: string[]; } export interface CreateWorktreeOpts { @@ -57,6 +59,7 @@ export async function createWorktree(opts: CreateWorktreeOpts): Promise { try { @@ -86,12 +91,17 @@ export async function removeWorktree(handle: WorktreeHandle): Promise { } catch { return; } - runGit(handle.source, ['worktree', 'remove', '--force', handle.path]); - // Delete the branch (best-effort) - spawnSync('git', ['-C', handle.source, 'branch', '-D', handle.branch], { - stdio: 'pipe', - env: gitSpawnEnv(), - }); + // These are the only untracked paths DeepCode itself creates. Remove them + // only if they are still symlinks; a user-replaced directory/file is data and + // must make the subsequent clean-worktree check fail. + for (const path of handle.managedSymlinks ?? []) { + try { + if ((await fs.lstat(path)).isSymbolicLink()) await fs.unlink(path); + } catch { + // Missing or unreadable managed link: let Git perform the final check. + } + } + runGit(handle.source, ['worktree', 'remove', handle.path]); } function runGit(cwd: string, args: string[]): void { From 1bd5562620aa49e4a2bdf578fd773f09cad5045e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:35:21 +0800 Subject: [PATCH 02/23] feat: read legacy session formats safely --- apps/desktop/src-tauri/src/commands.rs | 47 +++++- packages/core/src/index.ts | 5 + packages/core/src/sessions/index.ts | 5 + packages/core/src/sessions/storage.test.ts | 86 +++++++++- packages/core/src/sessions/storage.ts | 187 +++++++++++++++++++-- 5 files changed, 309 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index a839406..97da891 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -189,14 +189,26 @@ pub fn session_read(id: String) -> Result, String> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(format!("read {}: {}", path.display(), e)), }; + parse_session_messages(&text) +} + +fn parse_session_messages(text: &str) -> Result, String> { + let lines: Vec<&str> = text.split('\n').collect(); + let last_content = lines.iter().rposition(|line| !line.trim().is_empty()); let mut out = Vec::new(); - for line in text.lines() { + for (index, line) in lines.iter().enumerate() { let line = line.trim(); if line.is_empty() { continue; } - let Ok(v) = serde_json::from_str::(line) else { - continue; // tolerate a partial trailing line + let v = match serde_json::from_str::(line) { + Ok(value) => value, + Err(_) if Some(index) == last_content && !text.ends_with('\n') => { + continue; // recover an interrupted final append only + } + Err(error) => { + return Err(format!("corrupt session at line {}: {}", index + 1, error)); + } }; // Desktop sessions tag messages with type:"message"; CLI/headless sessions // write bare {role, content} lines with no type. Accept both, skip meta. @@ -206,6 +218,12 @@ pub fn session_read(id: String) -> Result, String> { Some("user") | Some("assistant") ); if t == Some("message") || (t.is_none() && is_role_msg) { + if !v.get("content").is_some_and(|content| content.is_array()) { + return Err(format!( + "corrupt session at line {}: message content must be an array", + index + 1 + )); + } out.push(v); } } @@ -773,6 +791,29 @@ mod contract_tests { assert!(name.is_none() && desc.is_none()); } + #[test] + fn session_parser_accepts_both_legacy_formats_and_truncated_tail() { + let text = concat!( + "{\"type\":\"session_meta\",\"id\":\"x\"}\n", + "{\"type\":\"message\",\"role\":\"user\",\"content\":[]}\n", + "{\"role\":\"assistant\",\"content\":[]}\n", + "{\"role\":\"assistant\"" + ); + let messages = parse_session_messages(text).unwrap(); + assert_eq!(messages.len(), 2); + } + + #[test] + fn session_parser_rejects_middle_corruption() { + let text = concat!( + "{\"role\":\"user\",\"content\":[]}\n", + "{not-json}\n", + "{\"role\":\"assistant\",\"content\":[]}\n" + ); + let error = parse_session_messages(text).unwrap_err(); + assert!(error.contains("line 2"), "got {error}"); + } + #[test] fn skill_info_serializes_camel_case() { let v = serde_json::to_value(SkillInfo { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b93eb43..f0028e7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,8 @@ export { SessionManager, defaultSessionsDir, newSessionId, + readSessionRecords, + SessionCorruptionError, captureSnapshot, captureGitCheckpoint, listSnapshots, @@ -65,6 +67,9 @@ export { type SessionMeta, type SessionFiles, type SessionManagerOpts, + type SessionDiagnostic, + type SessionFormat, + type SessionReadResult, type Snapshot, } from './sessions/index.js'; diff --git a/packages/core/src/sessions/index.ts b/packages/core/src/sessions/index.ts index bd70931..db140c4 100644 --- a/packages/core/src/sessions/index.ts +++ b/packages/core/src/sessions/index.ts @@ -7,8 +7,13 @@ export type { SessionManagerOpts } from './manager.js'; export { defaultSessionsDir, newSessionId, + readSessionRecords, + SessionCorruptionError, type SessionMeta, type SessionFiles, + type SessionDiagnostic, + type SessionFormat, + type SessionReadResult, } from './storage.js'; export { captureSnapshot, diff --git a/packages/core/src/sessions/storage.test.ts b/packages/core/src/sessions/storage.test.ts index a903716..f02d0bb 100644 --- a/packages/core/src/sessions/storage.test.ts +++ b/packages/core/src/sessions/storage.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -8,6 +8,8 @@ import { newSessionId, readMessages, readMeta, + readSessionRecords, + SessionCorruptionError, sessionFiles, writeMeta, } from './storage.js'; @@ -67,6 +69,72 @@ describe('session storage', () => { expect(await readMessages(root, 'nope')).toEqual([]); }); + it('reads desktop header + typed message JSONL without changing its bytes', async () => { + const id = 'desktop-old'; + const path = sessionFiles(root, id).jsonlPath; + const original = [ + JSON.stringify({ + type: 'session_meta', + id, + cwd: '/desktop', + created_at: 1_767_225_600, + title: 'Legacy desktop', + }), + JSON.stringify({ + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'hello from desktop' }], + timestamp: '2026-01-01T00:00:01.000Z', + }), + '', + ].join('\n'); + await writeFile(path, original, 'utf8'); + + const parsed = await readSessionRecords(root, id); + expect(parsed.format).toBe('desktop-v0'); + expect(parsed.meta).toMatchObject({ id, cwd: '/desktop', title: 'Legacy desktop' }); + expect(parsed.messages).toHaveLength(1); + expect(await readFile(path, 'utf8')).toBe(original); + await expect(readMeta(root, id)).resolves.toMatchObject({ id, cwd: '/desktop' }); + }); + + it('tolerates only an incomplete final JSONL record', async () => { + const id = 'truncated-tail'; + await writeFile( + sessionFiles(root, id).jsonlPath, + `${JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'complete' }] })}\n{"role":"assistant"`, + 'utf8', + ); + + const parsed = await readSessionRecords(root, id); + expect(parsed.messages).toHaveLength(1); + expect(parsed.diagnostics).toEqual([ + expect.objectContaining({ line: 2, code: 'truncated_tail', fatal: false }), + ]); + await expect(readMessages(root, id)).resolves.toHaveLength(1); + }); + + it('reports middle corruption instead of silently dropping history', async () => { + const id = 'middle-corrupt'; + await writeFile( + sessionFiles(root, id).jsonlPath, + [ + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'before' }] }), + '{not-json}', + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'after' }] }), + '', + ].join('\n'), + 'utf8', + ); + + const parsed = await readSessionRecords(root, id); + expect(parsed.messages).toHaveLength(2); + expect(parsed.diagnostics).toEqual([ + expect.objectContaining({ line: 2, code: 'invalid_json', fatal: true }), + ]); + await expect(readMessages(root, id)).rejects.toBeInstanceOf(SessionCorruptionError); + }); + it('listSessions sorts newest first', async () => { await writeMeta(root, { id: 'a', @@ -84,6 +152,22 @@ describe('session storage', () => { expect(list.map((s) => s.id)).toEqual(['b', 'a']); }); + it('listSessions includes desktop-only JSONL sessions', async () => { + await writeFile( + sessionFiles(root, 'desktop-list').jsonlPath, + `${JSON.stringify({ + type: 'session_meta', + id: 'desktop-list', + cwd: '/desktop', + created_at: 1_767_225_600, + })}\n`, + 'utf8', + ); + await expect(listSessions(root)).resolves.toEqual([ + expect.objectContaining({ id: 'desktop-list', cwd: '/desktop' }), + ]); + }); + it('sessionFiles returns sensible paths', () => { const f = sessionFiles('/root', 'abc'); expect(f.metaPath).toBe('/root/abc.meta.json'); diff --git a/packages/core/src/sessions/storage.ts b/packages/core/src/sessions/storage.ts index 6e1faf1..18330fc 100644 --- a/packages/core/src/sessions/storage.ts +++ b/packages/core/src/sessions/storage.ts @@ -2,12 +2,42 @@ // Each line is one StoredMessage envelope. // Spec: docs/DEVELOPMENT_PLAN.md §3.5 -import { promises as fs, createReadStream } from 'node:fs'; +import { promises as fs } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -import { createInterface } from 'node:readline'; import type { StoredMessage } from '../types.js'; +export type SessionFormat = 'core-v0' | 'desktop-v0' | 'empty'; + +export interface SessionDiagnostic { + line: number; + code: 'truncated_tail' | 'invalid_json' | 'invalid_message'; + message: string; + fatal: boolean; +} + +export interface SessionReadResult { + format: SessionFormat; + meta: SessionMeta | null; + messages: StoredMessage[]; + diagnostics: SessionDiagnostic[]; +} + +export class SessionCorruptionError extends Error { + constructor( + readonly sessionId: string, + readonly diagnostics: SessionDiagnostic[], + ) { + super( + `Session ${sessionId} is corrupted at ${diagnostics + .filter((d) => d.fatal) + .map((d) => `line ${d.line}: ${d.message}`) + .join('; ')}`, + ); + this.name = 'SessionCorruptionError'; + } +} + export interface SessionMeta { id: string; cwd: string; @@ -47,7 +77,9 @@ export async function readMeta(root: string, sessionId: string): Promise { + const result = await readSessionRecords(root, sessionId); + const fatal = result.diagnostics.filter((diagnostic) => diagnostic.fatal); + if (fatal.length > 0) throw new SessionCorruptionError(sessionId, fatal); + return result.messages; +} + +function isStoredMessage(value: unknown): value is StoredMessage { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return (record.role === 'user' || record.role === 'assistant') && Array.isArray(record.content); +} + +function desktopMeta(value: Record, updatedAt: string): SessionMeta | null { + if (value.type !== 'session_meta' || typeof value.id !== 'string') return null; + const createdAt = + typeof value.created_at === 'number' + ? new Date(value.created_at * 1000).toISOString() + : typeof value.created_at === 'string' + ? value.created_at + : updatedAt; + return { + id: value.id, + cwd: typeof value.cwd === 'string' ? value.cwd : '', + createdAt, + updatedAt, + model: typeof value.model === 'string' ? value.model : undefined, + title: typeof value.title === 'string' ? value.title : undefined, + }; +} + +/** Parse both historical JSONL layouts without modifying either one. */ +export async function readSessionRecords( + root: string, + sessionId: string, +): Promise { const files = sessionFiles(root, sessionId); + let raw: string; + let updatedAt: string; try { - await fs.access(files.jsonlPath); - } catch { - return []; + const [text, stat] = await Promise.all([ + fs.readFile(files.jsonlPath, 'utf8'), + fs.stat(files.jsonlPath), + ]); + raw = text; + updatedAt = stat.mtime.toISOString(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { format: 'empty', meta: null, messages: [], diagnostics: [] }; + } + throw error; } - const out: StoredMessage[] = []; - const rl = createInterface({ input: createReadStream(files.jsonlPath, { encoding: 'utf8' }) }); - for await (const line of rl) { + + const lines = raw.split('\n'); + let lastContentIndex = -1; + for (let index = lines.length - 1; index >= 0; index--) { + if (lines[index]!.trim().length > 0) { + lastContentIndex = index; + break; + } + } + const messages: StoredMessage[] = []; + const diagnostics: SessionDiagnostic[] = []; + let meta: SessionMeta | null = null; + let format: SessionFormat = 'empty'; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]!; if (!line.trim()) continue; + let value: unknown; try { - out.push(JSON.parse(line) as StoredMessage); - } catch { - // skip malformed lines (forward-compat) + value = JSON.parse(line); + } catch (error) { + const isTruncatedTail = index === lastContentIndex && !raw.endsWith('\n'); + diagnostics.push({ + line: index + 1, + code: isTruncatedTail ? 'truncated_tail' : 'invalid_json', + message: isTruncatedTail + ? 'ignored an incomplete final JSONL record' + : `invalid JSON: ${(error as Error).message}`, + fatal: !isTruncatedTail, + }); + continue; + } + if (!value || typeof value !== 'object') { + diagnostics.push({ + line: index + 1, + code: 'invalid_message', + message: 'record must be a JSON object', + fatal: true, + }); + continue; } + + const record = value as Record; + if (record.type === 'session_meta') { + format = 'desktop-v0'; + meta ??= desktopMeta(record, updatedAt); + continue; + } + if (record.type === 'message') { + format = 'desktop-v0'; + if (isStoredMessage(record)) { + messages.push({ + role: record.role, + content: record.content, + timestamp: typeof record.timestamp === 'string' ? record.timestamp : undefined, + }); + } else { + diagnostics.push({ + line: index + 1, + code: 'invalid_message', + message: 'message record has an invalid role or content array', + fatal: true, + }); + } + continue; + } + if (record.type === undefined) { + format = 'core-v0'; + if (isStoredMessage(record)) messages.push(record); + else { + diagnostics.push({ + line: index + 1, + code: 'invalid_message', + message: 'bare record has an invalid role or content array', + fatal: true, + }); + } + continue; + } + // Unknown typed records are reserved for forward-compatible lifecycle + // items. They are not messages and are intentionally ignored. } - return out; + + return { format, meta, messages, diagnostics }; } export async function listSessions(root: string): Promise { @@ -89,12 +239,15 @@ export async function listSessions(root: string): Promise { return []; } const entries = await fs.readdir(root); - const metaFiles = entries.filter((f) => f.endsWith('.meta.json')); + const ids = new Set(); + for (const entry of entries) { + if (entry.endsWith('.meta.json')) ids.add(entry.slice(0, -'.meta.json'.length)); + else if (entry.endsWith('.jsonl')) ids.add(entry.slice(0, -'.jsonl'.length)); + } const metas = await Promise.all( - metaFiles.map(async (f) => { + [...ids].map(async (id) => { try { - const raw = await fs.readFile(join(root, f), 'utf8'); - return JSON.parse(raw) as SessionMeta; + return await readMeta(root, id); } catch { return null; } From 502a16e0a21ddd9356629ed9ba18d453965cec5a Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:37:13 +0800 Subject: [PATCH 03/23] feat: distinguish model steps from user turns --- apps/cli/src/headless.ts | 1 + apps/cli/src/repl.ts | 1 + packages/core/src/agent.test.ts | 6 ++++++ packages/core/src/agent.ts | 29 ++++++++++++++--------------- packages/core/src/ipc/protocol.ts | 2 +- packages/core/src/types.ts | 9 ++++++++- 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index c24213b..a13c056 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -441,6 +441,7 @@ function formatEventText(out: Writable, e: AgentEvent): void { return; case 'usage': case 'thinking_delta': + case 'model_step_complete': case 'turn_complete': return; } diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 150854d..f6192fa 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -762,6 +762,7 @@ function formatEvent(out: Writable, e: AgentEvent): void { else out.write(` ✓ ${truncate(e.result.content, 200)}\n`); return; case 'usage': + case 'model_step_complete': return; case 'error': out.write(`\n ✕ ${e.error}\n`); diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 81f24af..6e24040 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -126,6 +126,12 @@ describe('runAgent', () => { expect(toolEvents).toHaveLength(1); const resultEvents = events.filter((e) => e.type === 'tool_result'); expect(resultEvents).toHaveLength(1); + const steps = events.filter((e) => e.type === 'model_step_complete'); + expect(steps).toHaveLength(2); + expect(steps.map((event) => event.step)).toEqual([1, 2]); + const completed = events.filter((e) => e.type === 'turn_complete'); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ stopReason: 'end_turn' }); }); it('handles unknown tool gracefully', async () => { diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 6e24d82..4f972ed 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -450,15 +450,16 @@ export async function runAgent(opts: RunAgentOptions): Promise { } }; + const finish = async (stopReason: RunAgentResult['stopReason']): Promise => { + await fireStop(stopReason); + const message = [...history].reverse().find((candidate) => candidate.role === 'assistant'); + opts.onEvent?.({ type: 'turn_complete', stopReason, message }); + return { history, turnsUsed, usage: totalUsage, stopReason, modeSignal }; + }; + for (let turn = 0; turn < maxTurns; turn++) { if (opts.signal?.aborted) { - return { - history, - turnsUsed, - usage: totalUsage, - stopReason: 'aborted', - modeSignal, - }; + return finish('aborted'); } turnsUsed++; @@ -481,11 +482,11 @@ export async function runAgent(opts: RunAgentOptions): Promise { }); } catch (err) { if (opts.signal?.aborted || (err as { name?: string }).name === 'AbortError') { - return { history, turnsUsed, usage: totalUsage, stopReason: 'aborted', modeSignal }; + return finish('aborted'); } const message = (err as Error).message ?? 'unknown'; opts.onEvent?.({ type: 'error', error: message }); - return { history, turnsUsed, usage: totalUsage, stopReason: 'error', modeSignal }; + return finish('error'); } totalUsage.inputTokens += result.usage.inputTokens; @@ -508,7 +509,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { history.push(assistantMsg); if (opts.session) await opts.session.manager.append(opts.session.id, assistantMsg); - opts.onEvent?.({ type: 'turn_complete', message: assistantMsg }); + opts.onEvent?.({ type: 'model_step_complete', step: turnsUsed, message: assistantMsg }); // Emit any tool_use events for (const block of result.content) { @@ -524,8 +525,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { // If no tool calls, we're done if (result.stopReason !== 'tool_use') { - await fireStop('end_turn'); - return { history, turnsUsed, usage: totalUsage, stopReason: 'end_turn', modeSignal }; + return finish('end_turn'); } // Execute tool calls and append a single user-role message with tool_result @@ -576,7 +576,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { opts.signal, ); if (opts.signal?.aborted) { - return { history, turnsUsed, usage: totalUsage, stopReason: 'aborted', modeSignal }; + return finish('aborted'); } // 'always' = host has (or will) persist a matcher; treat as allow-this-call. allowed = decision === true || decision === 'always'; @@ -758,8 +758,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { } } - await fireStop('max_turns'); - return { history, turnsUsed, usage: totalUsage, stopReason: 'max_turns', modeSignal }; + return finish('max_turns'); } export const AGENT_MODULE_VERSION = '0.1.0'; diff --git a/packages/core/src/ipc/protocol.ts b/packages/core/src/ipc/protocol.ts index c7adf47..a7630f7 100644 --- a/packages/core/src/ipc/protocol.ts +++ b/packages/core/src/ipc/protocol.ts @@ -4,7 +4,7 @@ // Goals: // 1. Type-safe channel names + payload shapes (no string-typed `ipc.invoke`). // 2. Stream agent events (text_delta / tool_use / tool_result / usage / -// turn_complete / error) one-way from main → renderer. +// model_step_complete / turn_complete / error) one-way from main → renderer. // 3. Same shape works for the future web SDK if we host the agent loop // out-of-process (just swap the transport). // diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f49507d..5e9fbb7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -181,7 +181,14 @@ export type AgentEvent = | { type: 'thinking_delta'; text: string } | { type: 'tool_use'; id: string; name: string; input: Record } | { type: 'tool_result'; id: string; result: ToolResult } - | { type: 'turn_complete'; message: StoredMessage } + /** One provider round-trip completed; a user turn may contain many steps. */ + | { type: 'model_step_complete'; step: number; message: StoredMessage } + /** The whole user turn reached one terminal state. Emitted exactly once. */ + | { + type: 'turn_complete'; + stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error'; + message?: StoredMessage; + } | { type: 'usage'; inputTokens: number; From a1234843d63522a14f61e55b98108c7706df2f50 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:42:29 +0800 Subject: [PATCH 04/23] feat: add shared runtime host boundary --- apps/cli/src/headless.ts | 21 +++-- apps/cli/src/repl.ts | 57 +++++++------ apps/lsp/src/handler.ts | 14 ++-- apps/vscode/src/extension.ts | 20 +++-- packages/core/README.md | 2 +- packages/core/src/agent.ts | 2 +- packages/core/src/index.ts | 4 + packages/core/src/runtime/host.test.ts | 109 +++++++++++++++++++++++++ packages/core/src/runtime/host.ts | 84 +++++++++++++++++++ packages/core/src/runtime/index.ts | 6 ++ 10 files changed, 265 insertions(+), 54 deletions(-) create mode 100644 packages/core/src/runtime/host.test.ts create mode 100644 packages/core/src/runtime/host.ts diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index a13c056..f76f997 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -21,6 +21,7 @@ import { EFFORT_PARAMS, HookDispatcher, ReadTool, + RuntimeHost, SessionManager, ToolRegistry, WebFetchTool, @@ -39,7 +40,6 @@ import { loadSkills, makeSkillTool, resolveCredentials, - runAgent, wirePlugins, collectPluginContributions, type AgentEvent, @@ -271,9 +271,18 @@ export async function runHeadless(opts: HeadlessOpts): Promise { } let exitCode = 0; try { - const result = await runAgent({ + const runtime = new RuntimeHost({ provider, tools, + cwd, + mode, + permissions: settings.permissions, + hooks, + pluginDirs: pluginContrib.dirs, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + }); + const result = await runtime.run({ systemPrompt, userMessage, history: [], @@ -281,15 +290,9 @@ export async function runHeadless(opts: HeadlessOpts): Promise { maxTokens, temperature, maxTurns, - cwd, + signal: ctrl.signal, session: { manager: sessions, id: session.id }, - mode, - permissions: settings.permissions, - hooks, - pluginDirs: pluginContrib.dirs, autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 }, - autoMode: settings.autoMode, - sandboxConfig: settings.sandbox, // In headless mode there's no human to ask: auto-deny anything that // would normally need approval. Users wanting auto-yes should pass // --mode dontAsk or --mode bypassPermissions (gated by trust). diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index f6192fa..fb88bee 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -8,6 +8,7 @@ import { EFFORT_PARAMS, HookDispatcher, ReadTool, + RuntimeHost, SessionManager, TaskManager, ToolRegistry, @@ -37,7 +38,6 @@ import { contextWindowFor, makeSkillTool, resolveCredentials, - runAgent, settingsPaths, wirePlugins, collectPluginContributions, @@ -429,6 +429,17 @@ export async function startRepl(opts: ReplOpts): Promise { } let history: StoredMessage[] = resolved.seededHistory; + const runtime = new RuntimeHost({ + provider, + tools, + cwd, + mode, + permissions: settings.permissions, + hooks, + pluginDirs: pluginContrib.dirs, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + }); const ctx: SessionContext = { cwd, model, @@ -471,25 +482,20 @@ export async function startRepl(opts: ReplOpts): Promise { // reading ctx.model/ctx.mode live so /model and /mode switches are honored. const tasks = new TaskManager((spec) => { const ac = new AbortController(); - const done = runAgent({ - provider, - tools, - systemPrompt, - userMessage: spec.prompt, - model: ctx.model, - maxTokens, - temperature, - cwd: ctx.cwd, - signal: ac.signal, - mode: ctx.mode as Mode, - permissions: settings.permissions, - hooks, - pluginDirs: pluginContrib.dirs, - sandboxConfig: settings.sandbox, - autoMode: settings.autoMode, - subAgentDepth: 1, - systemReminders: false, - }).then((r) => assistantText(r.history)); + const done = runtime + .run({ + systemPrompt, + userMessage: spec.prompt, + model: ctx.model, + maxTokens, + temperature, + cwd: ctx.cwd, + signal: ac.signal, + modeOverride: ctx.mode as Mode, + subAgentDepth: 1, + systemReminders: false, + }) + .then((r) => assistantText(r.history)); return { done, abort: () => ac.abort() }; }); ctx.tasks = tasks; @@ -649,9 +655,7 @@ export async function startRepl(opts: ReplOpts): Promise { } // Otherwise: send to agent (with mode/permission/hooks gating from M3b) - const result = await runAgent({ - provider, - tools, + const result = await runtime.run({ systemPrompt, userMessage: userInput, history, @@ -663,13 +667,8 @@ export async function startRepl(opts: ReplOpts): Promise { // ctx.sessionId (not the launch `session.id`) so a live `/resume ` // switch redirects new messages to the resumed session. session: { manager: sessions, id: ctx.sessionId }, - mode: ctx.mode as Mode, - permissions: settings.permissions, - hooks, - pluginDirs: pluginContrib.dirs, + modeOverride: ctx.mode as Mode, autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 }, - autoMode: settings.autoMode, - sandboxConfig: settings.sandbox, // Session-scoped manager: the agent's TaskCreate calls land here too, so // background tasks persist across turns and show up in /tasks. taskManager: tasks, diff --git a/apps/lsp/src/handler.ts b/apps/lsp/src/handler.ts index 1651cb4..b642321 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -132,12 +132,12 @@ async function handleRunAgent( void (async () => { try { const [ - { runAgent }, + { RuntimeHost }, { DeepSeekProvider }, { ToolRegistry, BUILTIN_TOOLS, SAFE_READONLY_TOOLS }, { resolveCredentials, CredentialsStore }, ] = await Promise.all([ - import('@deepcode/core').then((m) => ({ runAgent: m.runAgent })), + import('@deepcode/core').then((m) => ({ RuntimeHost: m.RuntimeHost })), import('@deepcode/core').then((m) => ({ DeepSeekProvider: m.DeepSeekProvider })), import('@deepcode/core').then((m) => ({ ToolRegistry: m.ToolRegistry, @@ -163,16 +163,18 @@ async function handleRunAgent( baseURL: creds.baseURL, }); - const result = await runAgent({ + const runtime = new RuntimeHost({ provider, tools: new ToolRegistry(BUILTIN_TOOLS), + cwd: state.rootUri ? new URL(state.rootUri).pathname : process.cwd(), + mode: 'default', + permissions: { allow: [...SAFE_READONLY_TOOLS] }, + }); + const result = await runtime.run({ systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', userMessage: args.prompt!, model: args.model ?? 'deepseek-chat', - cwd: state.rootUri ? new URL(state.rootUri).pathname : process.cwd(), signal: abortController.signal, - mode: 'default', - permissions: { allow: [...SAFE_READONLY_TOOLS] }, onEvent: (e) => { send({ jsonrpc: '2.0', diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 3ccd089..c1e5099 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -89,15 +89,17 @@ async function runAgent( authToken: creds.authToken, baseURL: creds.baseURL, }); - await core.runAgent({ + const runtime = new core.RuntimeHost({ provider, tools: new core.ToolRegistry(core.BUILTIN_TOOLS), - systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', - userMessage, - model: 'deepseek-chat', cwd, mode: 'default', permissions: { allow: [...core.SAFE_READONLY_TOOLS] }, + }); + await runtime.run({ + systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', + userMessage, + model: 'deepseek-chat', onEvent: (e) => { if (e.type === 'text_delta') out.append(e.text); else if (e.type === 'tool_use') out.appendLine(`\n[${e.name}] ${formatInput(e.input)}`); @@ -157,15 +159,17 @@ class ChatViewProvider implements vscode.WebviewViewProvider { baseURL: creds.baseURL, }); let buffer = ''; - await core.runAgent({ + const runtime = new core.RuntimeHost({ provider, tools: new core.ToolRegistry(core.BUILTIN_TOOLS), - systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', - userMessage: msg.text, - model: 'deepseek-chat', cwd: this.vscodeMod.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), mode: 'default', permissions: { allow: [...core.SAFE_READONLY_TOOLS] }, + }); + await runtime.run({ + systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', + userMessage: msg.text, + model: 'deepseek-chat', onEvent: (e) => { if (e.type === 'text_delta') { buffer += e.text; diff --git a/packages/core/README.md b/packages/core/README.md index 818d030..6e24127 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,7 +6,7 @@ DeepCode 的 TypeScript 内核包:agent loop、DeepSeek provider、tools、con ## 当前状态 -主要模块均已有实现与测试。当前最重要的已知限制不是“缺少骨架”,而是不同 host 对 `runAgent` 的组装不一致:CLI 传入完整 permissions/hooks/sandbox/session/task services,desktop、LSP 与 VS Code 只传入其中一部分。后续通过不可绕过的 `RuntimeHost` 收敛,而不是继续增加 host-specific wiring。 +主要模块均已有实现与测试。CLI、headless、LSP 与 VS Code 已通过 `RuntimeHost` 固定 provider、tools、permissions、hooks 与 sandbox 等安全服务;`runAgent` 保留为 core 内部循环和 desktop 迁移期兼容入口。当前剩余的主要 host 差异是 desktop renderer 仍直接运行 provider/loop,后续按 packaging ADR 迁出 WebView。 关键入口: diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 4f972ed..6f386cf 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -8,7 +8,7 @@ import { TaskManager, type TaskRunner } from './tasks/manager.js'; import type { HookDispatcher } from './hooks/index.js'; import type { Mode } from './types.js'; import type { Provider } from './providers/types.js'; -import { resolveRuntimePolicy } from './runtime/index.js'; +import { resolveRuntimePolicy } from './runtime/policy.js'; // NOTE: reminders + sessions are lazy-loaded inside the loop so a browser // build (Tauri renderer) that doesn't use them avoids pulling node:fs at // module-load time. See `loadRemindersIfEnabled` and `appendSessionIfSet`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f0028e7..851f441 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -181,7 +181,11 @@ export type { ApprovalCallback, ApprovalDecision } from './agent.js'; export { SAFE_DEFAULT_PERMISSIONS, SAFE_READONLY_TOOLS, + RuntimeHost, + createRuntimeHost, resolveRuntimePolicy, + type RuntimeHostOptions, + type RuntimeTurnOptions, type RuntimePolicyInput, } from './runtime/index.js'; diff --git a/packages/core/src/runtime/host.test.ts b/packages/core/src/runtime/host.test.ts new file mode 100644 index 0000000..6d9976b --- /dev/null +++ b/packages/core/src/runtime/host.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import type { Provider, ProviderResult, ProviderRunOpts } from '../providers/types.js'; +import { ToolRegistry } from '../tools/registry.js'; +import type { ToolHandler } from '../types.js'; +import { RuntimeHost } from './host.js'; + +class ScriptedProvider implements Provider { + readonly name = 'scripted'; + constructor(private readonly results: ProviderResult[]) {} + async runTurn(_opts: ProviderRunOpts): Promise { + const result = this.results.shift(); + if (!result) throw new Error('no scripted result'); + return result; + } +} + +const usage = { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 }; + +function writeThenDone(): ProviderResult[] { + return [ + { + content: [ + { + type: 'tool_use', + id: 'write-1', + name: 'Write', + input: { file_path: 'x', content: 'x' }, + }, + ], + stopReason: 'tool_use', + usage, + }, + { + content: [{ type: 'text', text: 'done' }], + stopReason: 'end_turn', + usage, + }, + ]; +} + +describe('RuntimeHost', () => { + it('fails closed when the host omits policy and approval', async () => { + let executions = 0; + const write: ToolHandler = { + name: 'Write', + definition: { name: 'Write', description: 'test', inputSchema: {} }, + execute: async () => { + executions++; + return { content: 'wrote' }; + }, + }; + const host = new RuntimeHost({ + provider: new ScriptedProvider(writeThenDone()), + tools: new ToolRegistry([write]), + cwd: '/tmp', + }); + + const result = await host.run({ + systemPrompt: '', + userMessage: 'write', + model: 'deepseek-chat', + systemReminders: false, + }); + + expect(executions).toBe(0); + expect(result.history.flatMap((message) => message.content)).toContainEqual( + expect.objectContaining({ type: 'tool_result', is_error: true }), + ); + }); + + it('keeps host policy while accepting an explicit turn mode override', async () => { + let executions = 0; + const write: ToolHandler = { + name: 'Write', + definition: { name: 'Write', description: 'test', inputSchema: {} }, + execute: async () => { + executions++; + return { content: 'wrote' }; + }, + }; + const host = new RuntimeHost({ + provider: new ScriptedProvider(writeThenDone()), + tools: new ToolRegistry([write]), + cwd: '/tmp', + mode: 'default', + }); + + await host.run({ + systemPrompt: '', + userMessage: 'write', + model: 'deepseek-chat', + systemReminders: false, + modeOverride: 'bypassPermissions', + }); + + expect(executions).toBe(1); + expect(host.mode).toBe('default'); + }); + + it('requires a cwd at either boundary', () => { + const host = new RuntimeHost({ + provider: new ScriptedProvider([]), + tools: new ToolRegistry(), + }); + expect(() => host.run({ systemPrompt: '', userMessage: 'x', model: 'deepseek-chat' })).toThrow( + /requires cwd/, + ); + }); +}); diff --git a/packages/core/src/runtime/host.ts b/packages/core/src/runtime/host.ts new file mode 100644 index 0000000..9b8d866 --- /dev/null +++ b/packages/core/src/runtime/host.ts @@ -0,0 +1,84 @@ +import { + runAgent, + type ApprovalCallback, + type RunAgentOptions, + type RunAgentResult, +} from '../agent.js'; +import type { AutoModeConfig, PermissionRules, SandboxConfig } from '../config/types.js'; +import type { HookDispatcher } from '../hooks/index.js'; +import type { Provider } from '../providers/types.js'; +import type { ToolRegistry } from '../tools/registry.js'; +import type { Mode } from '../types.js'; +import { resolveRuntimePolicy } from './policy.js'; + +export interface RuntimeHostOptions { + provider: Provider; + tools: ToolRegistry; + /** Default working directory; a turn may override it explicitly. */ + cwd?: string; + /** Safe fallback is `default`, even for untyped callers. */ + mode?: Mode; + permissions?: PermissionRules; + hooks?: HookDispatcher; + approval?: ApprovalCallback; + autoMode?: AutoModeConfig; + sandboxConfig?: SandboxConfig; + pluginDirs?: string[]; +} + +type HostBoundOption = + | 'provider' + | 'tools' + | 'mode' + | 'permissions' + | 'hooks' + | 'approval' + | 'autoMode' + | 'sandboxConfig' + | 'pluginDirs'; + +export type RuntimeTurnOptions = Omit & { + cwd?: string; + /** Explicit per-turn mode change; all other safety services remain host-owned. */ + modeOverride?: Mode; + /** Per-turn UI callback; omission remains fail-closed for `ask` decisions. */ + approval?: ApprovalCallback; +}; + +/** + * Host-owned assembly boundary for the agent runtime. Clients provide turn + * input, while provider/tool/policy/hook/sandbox services stay consistent. + */ +export class RuntimeHost { + readonly mode: Mode; + readonly permissions: PermissionRules; + + constructor(private readonly options: RuntimeHostOptions) { + const policy = resolveRuntimePolicy(options); + this.mode = policy.mode; + this.permissions = policy.permissions; + } + + run(turn: RuntimeTurnOptions): Promise { + const cwd = turn.cwd ?? this.options.cwd; + if (!cwd) throw new Error('RuntimeHost requires cwd in the host or turn options'); + const { modeOverride, approval, ...agentTurn } = turn; + return runAgent({ + ...agentTurn, + provider: this.options.provider, + tools: this.options.tools, + cwd, + mode: modeOverride ?? this.mode, + permissions: this.permissions, + hooks: this.options.hooks, + approval: approval ?? this.options.approval, + autoMode: this.options.autoMode, + sandboxConfig: this.options.sandboxConfig, + pluginDirs: this.options.pluginDirs, + }); + } +} + +export function createRuntimeHost(options: RuntimeHostOptions): RuntimeHost { + return new RuntimeHost(options); +} diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index 2fc289a..c803ad7 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -4,3 +4,9 @@ export { resolveRuntimePolicy, type RuntimePolicyInput, } from './policy.js'; +export { + RuntimeHost, + createRuntimeHost, + type RuntimeHostOptions, + type RuntimeTurnOptions, +} from './host.js'; From 2e32697b607e55d7d81eb16c8c6db12b1ad40bd7 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:48:51 +0800 Subject: [PATCH 05/23] feat: normalize sessions to canonical v1 --- README.md | 1 + apps/desktop/src-tauri/src/commands.rs | 208 ++++++++++++++++++--- docs/design/session-format-v1.md | 54 ++++++ packages/core/src/index.ts | 1 + packages/core/src/sessions/index.ts | 1 + packages/core/src/sessions/manager.ts | 2 - packages/core/src/sessions/storage.test.ts | 66 ++++++- packages/core/src/sessions/storage.ts | 174 ++++++++++++++--- 8 files changed, 445 insertions(+), 62 deletions(-) create mode 100644 docs/design/session-format-v1.md diff --git a/README.md b/README.md index 7cd4a31..bff7b32 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Mac 客户端(v1 即将发布):拖入 Applications → 首启完成 onboar | [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md) | 整体开发方案 v0.5(1500+ 行 / §3 模块 / §6 里程碑) | | [docs/VISUAL_DESIGN.html](docs/VISUAL_DESIGN.html) | 视觉设计 v0.4(11 屏 mockup) | | [docs/security-model.md](docs/security-model.md) | 威胁模型 + 防御层 + 攻击向量测试 + 已知缺口 | +| [docs/design/session-format-v1.md](docs/design/session-format-v1.md) | 统一 session JSONL、旧格式迁移与 writer ownership | | [docs/design/sandbox-plan-worktree.md](docs/design/sandbox-plan-worktree.md) | sandbox × plan mode × worktree 关系矩阵 | | [docs/design/plugin-security.md](docs/design/plugin-security.md) | plugin 信任 ladder + sandbox 子进程 | | [docs/design/effort-levels.md](docs/design/effort-levels.md) | 5 档 effort 到 DeepSeek API 参数映射 | diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 97da891..eeb7bc0 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -4,6 +4,8 @@ use crate::credentials::{self, Credentials}; use crate::settings; use serde::Serialize; +use std::collections::HashMap; +use std::io::Write; use std::path::PathBuf; #[derive(Serialize)] @@ -137,9 +139,10 @@ pub fn session_create(cwd: String) -> Result { let id = format!("{}-{}", date, rand_id); let dir = home.join(".deepcode").join("sessions"); std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?; - let path = dir.join(format!("{}.jsonl", id)); + let path = dir.join(format!("{}.v1.jsonl", id)); let header = serde_json::json!({ "type": "session_meta", + "schema_version": 1, "id": id, "cwd": cwd, "created_at": secs, @@ -153,15 +156,18 @@ pub fn session_create(cwd: String) -> Result { /// Append a single JSON line to a session's JSONL file. #[tauri::command] pub fn session_append(id: String, message: serde_json::Value) -> Result<(), String> { + safe_session_id(&id)?; let Some(home) = dirs::home_dir() else { return Err("no home directory".into()); }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{}.jsonl", id)); - let line = format!("{}\n", message); - use std::io::Write; + let dir = home.join(".deepcode").join("sessions"); + std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?; + let _lock = SessionWriterLock::acquire(&dir, &id)?; + let path = ensure_canonical_session(&dir, &id)?; + let mut normalized = message; + normalized["type"] = serde_json::Value::String("message".to_string()); + normalized["schema_version"] = serde_json::Value::Number(1.into()); + let line = format!("{}\n", normalized); let mut f = std::fs::OpenOptions::new() .create(true) .append(true) @@ -177,13 +183,12 @@ pub fn session_append(id: String, message: serde_json::Value) -> Result<(), Stri /// timestamp }`. Returns an empty vec if the file doesn't exist. #[tauri::command] pub fn session_read(id: String) -> Result, String> { + safe_session_id(&id)?; let Some(home) = dirs::home_dir() else { return Err("no home directory".into()); }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{}.jsonl", id)); + let dir = home.join(".deepcode").join("sessions"); + let path = readable_session_path(&dir, &id); let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), @@ -192,6 +197,89 @@ pub fn session_read(id: String) -> Result, String> { parse_session_messages(&text) } +struct SessionWriterLock { + path: PathBuf, +} + +impl SessionWriterLock { + fn acquire(dir: &std::path::Path, id: &str) -> Result { + let path = dir.join(format!("{id}.writer.lock")); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + format!("session {id} already has an active writer") + } else { + format!("open {}: {}", path.display(), e) + } + })?; + writeln!(file, "pid={}", std::process::id()).map_err(|e| e.to_string())?; + Ok(Self { path }) + } +} + +impl Drop for SessionWriterLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn readable_session_path(dir: &std::path::Path, id: &str) -> PathBuf { + let canonical = dir.join(format!("{id}.v1.jsonl")); + if canonical.exists() { + canonical + } else { + dir.join(format!("{id}.jsonl")) + } +} + +fn ensure_canonical_session(dir: &std::path::Path, id: &str) -> Result { + let canonical = dir.join(format!("{id}.v1.jsonl")); + if canonical.exists() { + return Ok(canonical); + } + let legacy = dir.join(format!("{id}.jsonl")); + let text = match std::fs::read_to_string(&legacy) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(format!("read {}: {}", legacy.display(), error)), + }; + let messages = parse_session_messages(&text)?; + let sidecar = dir.join(format!("{id}.meta.json")); + let legacy_meta = std::fs::read_to_string(sidecar) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + let mut header = text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|value| value.get("type").and_then(|v| v.as_str()) == Some("session_meta")) + .or(legacy_meta) + .unwrap_or_else(|| serde_json::json!({ "type": "session_meta", "id": id, "cwd": "" })); + header["type"] = serde_json::Value::String("session_meta".to_string()); + header["schema_version"] = serde_json::Value::Number(1.into()); + header["id"] = serde_json::Value::String(id.to_string()); + if let Some(created_at) = header.get("createdAt").cloned() { + header["created_at"] = created_at; + } + if let Some(updated_at) = header.get("updatedAt").cloned() { + header["updated_at"] = updated_at; + } + let mut lines = vec![header.to_string()]; + for mut message in messages { + message["type"] = serde_json::Value::String("message".to_string()); + message["schema_version"] = serde_json::Value::Number(1.into()); + lines.push(message.to_string()); + } + let temp = dir.join(format!("{id}.v1.{}.tmp", std::process::id())); + std::fs::write(&temp, lines.join("\n") + "\n") + .map_err(|e| format!("write {}: {}", temp.display(), e))?; + std::fs::rename(&temp, &canonical) + .map_err(|e| format!("rename {}: {}", temp.display(), e))?; + Ok(canonical) +} + fn parse_session_messages(text: &str) -> Result, String> { let lines: Vec<&str> = text.split('\n').collect(); let last_content = lines.iter().rposition(|line| !line.trim().is_empty()); @@ -314,13 +402,13 @@ fn derive_session_title(path: &std::path::Path) -> Option { /// Set (or clear, with "") a session's manual title on its session_meta header. #[tauri::command] pub fn session_set_title(id: String, title: String) -> Result<(), String> { + safe_session_id(&id)?; let Some(home) = dirs::home_dir() else { return Err("no home directory".into()); }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{id}.jsonl")); + let dir = home.join(".deepcode").join("sessions"); + let _lock = SessionWriterLock::acquire(&dir, &id)?; + let path = ensure_canonical_session(&dir, &id)?; let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {}", path.display(), e))?; let trimmed = title.trim(); let mut lines: Vec = text.lines().map(|l| l.to_string()).collect(); @@ -338,7 +426,9 @@ pub fn session_set_title(id: String, title: String) -> Result<(), String> { } if !updated { // No meta header (older session) — prepend one carrying the title. - let meta = serde_json::json!({ "type": "session_meta", "id": id, "title": trimmed }); + let meta = serde_json::json!({ + "type": "session_meta", "schema_version": 1, "id": id, "title": trimmed + }); lines.insert(0, meta.to_string()); } std::fs::write(&path, lines.join("\n") + "\n") @@ -377,7 +467,7 @@ pub fn list_sessions() -> Result, String> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(format!("read_dir {}: {}", dir.display(), e)), }; - let mut out = Vec::new(); + let mut selected: HashMap = HashMap::new(); for entry in read.flatten() { let path = entry.path(); if !path.is_file() { @@ -386,11 +476,20 @@ pub fn list_sessions() -> Result, String> { let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue; }; - if !name.ends_with(".jsonl") { + let (id, canonical) = if let Some(id) = name.strip_suffix(".v1.jsonl") { + (id.to_string(), true) + } else if let Some(id) = name.strip_suffix(".jsonl") { + (id.to_string(), false) + } else { continue; + }; + if canonical || !selected.contains_key(&id) { + selected.insert(id, path); } - let id = name.trim_end_matches(".jsonl").to_string(); - let meta = entry.metadata().map_err(|e| e.to_string())?; + } + let mut out = Vec::new(); + for (id, path) in selected { + let meta = std::fs::metadata(&path).map_err(|e| e.to_string())?; let updated_at_secs = meta .modified() .ok() @@ -425,11 +524,17 @@ pub fn session_delete(id: String) -> Result<(), String> { let Some(home) = dirs::home_dir() else { return Err("no home directory".into()); }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{id}.jsonl")); - std::fs::remove_file(&path).map_err(|e| format!("delete {}: {}", path.display(), e)) + let dir = home.join(".deepcode").join("sessions"); + let mut removed = false; + for name in [format!("{id}.v1.jsonl"), format!("{id}.jsonl"), format!("{id}.meta.json")] { + let path = dir.join(name); + match std::fs::remove_file(&path) { + Ok(()) => removed = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("delete {}: {}", path.display(), error)), + } + } + if removed { Ok(()) } else { Err(format!("session not found: {id}")) } } /// Archive a session by moving its JSONL into sessions/archived/ — excluded from @@ -444,9 +549,17 @@ pub fn session_archive(id: String) -> Result<(), String> { let archived = dir.join("archived"); std::fs::create_dir_all(&archived) .map_err(|e| format!("mkdir {}: {}", archived.display(), e))?; - let from = dir.join(format!("{id}.jsonl")); - let to = archived.join(format!("{id}.jsonl")); - std::fs::rename(&from, &to).map_err(|e| format!("archive {}: {}", from.display(), e)) + let mut moved = false; + for name in [format!("{id}.v1.jsonl"), format!("{id}.jsonl"), format!("{id}.meta.json")] { + let from = dir.join(&name); + let to = archived.join(&name); + match std::fs::rename(&from, &to) { + Ok(()) => moved = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("archive {}: {}", from.display(), error)), + } + } + if moved { Ok(()) } else { Err(format!("session not found: {id}")) } } /// Path to the `deepcode` CLI so the GUI can drop users into it for advanced @@ -814,6 +927,45 @@ mod contract_tests { assert!(error.contains("line 2"), "got {error}"); } + #[test] + fn canonical_session_normalizes_without_touching_legacy() { + let root = std::env::temp_dir().join(format!( + "dc-session-v1-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let legacy = root.join("legacy.jsonl"); + let original = "{\"role\":\"user\",\"content\":[]}\n"; + std::fs::write(&legacy, original).unwrap(); + std::fs::write( + root.join("legacy.meta.json"), + "{\"id\":\"legacy\",\"cwd\":\"/core\",\"createdAt\":\"2025-01-01T00:00:00Z\",\"updatedAt\":\"2025-01-02T00:00:00Z\"}", + ) + .unwrap(); + + let _lock = SessionWriterLock::acquire(&root, "legacy").unwrap(); + let canonical = ensure_canonical_session(&root, "legacy").unwrap(); + assert_eq!(std::fs::read_to_string(&legacy).unwrap(), original); + let normalized = std::fs::read_to_string(canonical).unwrap(); + let records: Vec = normalized + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(records.len(), 2); + assert_eq!(records[0]["schema_version"], 1); + assert_eq!(records[0]["cwd"], "/core"); + assert_eq!(records[0]["created_at"], "2025-01-01T00:00:00Z"); + assert_eq!(records[1]["type"], "message"); + assert_eq!(records[1]["schema_version"], 1); + assert!(SessionWriterLock::acquire(&root, "legacy").is_err()); + drop(_lock); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn skill_info_serializes_camel_case() { let v = serde_json::to_value(SkillInfo { diff --git a/docs/design/session-format-v1.md b/docs/design/session-format-v1.md new file mode 100644 index 0000000..b51976b --- /dev/null +++ b/docs/design/session-format-v1.md @@ -0,0 +1,54 @@ +# Session Format v1 + +Status: experimental, implemented by `@deepcode/core` and the Tauri desktop backend. + +## Goals + +- one append format across CLI, headless and desktop; +- lossless reads of historical core and desktop sessions; +- no in-place mutation of legacy files; +- explicit cross-process writer ownership; +- recover an interrupted final append, but never hide middle corruption. + +## Files + +For logical session `` under `~/.deepcode/sessions/`: + +- `.v1.jsonl` is the canonical stream; +- `.writer.lock` is held with create-new semantics for each metadata rewrite or append; +- `.jsonl` and `.meta.json` are legacy, read-only inputs; +- `/snapshots/` remains the session artifact directory. + +On the first write to a legacy session, DeepCode creates the canonical stream atomically and appends there. The legacy bytes remain unchanged. Explicit user archive/delete operations may move or remove both generations. + +## Records + +The first record is metadata: + +```json +{ + "type": "session_meta", + "schema_version": 1, + "id": "…", + "cwd": "/repo", + "created_at": "…", + "updated_at": "…", + "model": "deepseek-chat" +} +``` + +Every later record is a completed message envelope: + +```json +{ "type": "message", "schema_version": 1, "role": "assistant", "content": [], "timestamp": "…" } +``` + +Streaming deltas are not persisted. Tool calls and results are stored only after they become completed content blocks in the message history. + +## Recovery and ownership + +The lock filename and create-new behavior are identical in TypeScript and Rust, so two hosts cannot silently interleave writes. A conflicting writer receives an explicit error. The lock contains diagnostic owner information and is removed when the operation exits. + +Readers prefer v1, otherwise detect either legacy layout. An invalid final record without a newline is treated as an interrupted append and ignored. Invalid JSON or an invalid message in the middle is reported with its line number and blocks normalization. + +Crash-stale lock recovery is intentionally deferred to the single-owner app-server: clients must not guess that another process is dead and steal ownership. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 851f441..d6aa5d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -60,6 +60,7 @@ export { newSessionId, readSessionRecords, SessionCorruptionError, + SessionWriterConflictError, captureSnapshot, captureGitCheckpoint, listSnapshots, diff --git a/packages/core/src/sessions/index.ts b/packages/core/src/sessions/index.ts index db140c4..fd3667d 100644 --- a/packages/core/src/sessions/index.ts +++ b/packages/core/src/sessions/index.ts @@ -9,6 +9,7 @@ export { newSessionId, readSessionRecords, SessionCorruptionError, + SessionWriterConflictError, type SessionMeta, type SessionFiles, type SessionDiagnostic, diff --git a/packages/core/src/sessions/manager.ts b/packages/core/src/sessions/manager.ts index bd6b360..c789c1d 100644 --- a/packages/core/src/sessions/manager.ts +++ b/packages/core/src/sessions/manager.ts @@ -9,7 +9,6 @@ import { newSessionId, readMessages, readMeta, - touchSession, writeMeta, type SessionMeta, } from './storage.js'; @@ -54,7 +53,6 @@ export class SessionManager { async append(sessionId: string, msg: StoredMessage): Promise { await appendMessage(this.root, sessionId, msg); - await touchSession(this.root, sessionId); } async list(): Promise { diff --git a/packages/core/src/sessions/storage.test.ts b/packages/core/src/sessions/storage.test.ts index f02d0bb..a7f09f4 100644 --- a/packages/core/src/sessions/storage.test.ts +++ b/packages/core/src/sessions/storage.test.ts @@ -10,6 +10,7 @@ import { readMeta, readSessionRecords, SessionCorruptionError, + SessionWriterConflictError, sessionFiles, writeMeta, } from './storage.js'; @@ -63,6 +64,15 @@ describe('session storage', () => { expect(got[0]?.role).toBe('user'); expect(got[1]?.role).toBe('assistant'); if (got[0]?.content[0]?.type === 'text') expect(got[0].content[0].text).toBe('hello'); + const records = (await readFile(sessionFiles(root, id).jsonlPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect(records[0]).toMatchObject({ type: 'session_meta', schema_version: 1, id }); + expect(records.slice(1)).toEqual([ + expect.objectContaining({ type: 'message', schema_version: 1, role: 'user' }), + expect.objectContaining({ type: 'message', schema_version: 1, role: 'assistant' }), + ]); }); it('readMessages returns [] when jsonl missing', async () => { @@ -71,7 +81,7 @@ describe('session storage', () => { it('reads desktop header + typed message JSONL without changing its bytes', async () => { const id = 'desktop-old'; - const path = sessionFiles(root, id).jsonlPath; + const path = sessionFiles(root, id).legacyJsonlPath; const original = [ JSON.stringify({ type: 'session_meta', @@ -101,7 +111,7 @@ describe('session storage', () => { it('tolerates only an incomplete final JSONL record', async () => { const id = 'truncated-tail'; await writeFile( - sessionFiles(root, id).jsonlPath, + sessionFiles(root, id).legacyJsonlPath, `${JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'complete' }] })}\n{"role":"assistant"`, 'utf8', ); @@ -117,7 +127,7 @@ describe('session storage', () => { it('reports middle corruption instead of silently dropping history', async () => { const id = 'middle-corrupt'; await writeFile( - sessionFiles(root, id).jsonlPath, + sessionFiles(root, id).legacyJsonlPath, [ JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'before' }] }), '{not-json}', @@ -154,7 +164,7 @@ describe('session storage', () => { it('listSessions includes desktop-only JSONL sessions', async () => { await writeFile( - sessionFiles(root, 'desktop-list').jsonlPath, + sessionFiles(root, 'desktop-list').legacyJsonlPath, `${JSON.stringify({ type: 'session_meta', id: 'desktop-list', @@ -171,7 +181,53 @@ describe('session storage', () => { it('sessionFiles returns sensible paths', () => { const f = sessionFiles('/root', 'abc'); expect(f.metaPath).toBe('/root/abc.meta.json'); - expect(f.jsonlPath).toBe('/root/abc.jsonl'); + expect(f.jsonlPath).toBe('/root/abc.v1.jsonl'); + expect(f.legacyJsonlPath).toBe('/root/abc.jsonl'); + expect(f.writerLockPath).toBe('/root/abc.writer.lock'); expect(f.snapshotsDir).toBe('/root/abc/snapshots'); }); + + it('normalizes a legacy core session without changing legacy bytes', async () => { + const id = 'legacy-normalize'; + const files = sessionFiles(root, id); + const legacyMeta = JSON.stringify( + { + id, + cwd: '/legacy', + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + }, + null, + 2, + ); + const legacyJsonl = `${JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'old' }], + })}\n`; + await writeFile(files.metaPath, legacyMeta, 'utf8'); + await writeFile(files.legacyJsonlPath, legacyJsonl, 'utf8'); + + await appendMessage(root, id, { + role: 'assistant', + content: [{ type: 'text', text: 'new' }], + }); + + expect(await readFile(files.metaPath, 'utf8')).toBe(legacyMeta); + expect(await readFile(files.legacyJsonlPath, 'utf8')).toBe(legacyJsonl); + const parsed = await readSessionRecords(root, id); + expect(parsed.format).toBe('canonical-v1'); + expect(parsed.meta).toMatchObject({ id, cwd: '/legacy' }); + expect(parsed.messages).toHaveLength(2); + }); + + it('rejects a second writer instead of interleaving records', async () => { + const id = 'writer-owned'; + const files = sessionFiles(root, id); + await writeFile(files.writerLockPath, '{"pid":1}', 'utf8'); + + await expect( + appendMessage(root, id, { role: 'user', content: [{ type: 'text', text: 'x' }] }), + ).rejects.toBeInstanceOf(SessionWriterConflictError); + await expect(readFile(files.jsonlPath, 'utf8')).rejects.toThrow(); + }); }); diff --git a/packages/core/src/sessions/storage.ts b/packages/core/src/sessions/storage.ts index 18330fc..32f958b 100644 --- a/packages/core/src/sessions/storage.ts +++ b/packages/core/src/sessions/storage.ts @@ -1,5 +1,4 @@ -// Session storage — jsonl persistence at ~/.deepcode/sessions/.jsonl -// Each line is one StoredMessage envelope. +// Session storage — canonical v1 JSONL plus read-only legacy compatibility. // Spec: docs/DEVELOPMENT_PLAN.md §3.5 import { promises as fs } from 'node:fs'; @@ -7,7 +6,7 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import type { StoredMessage } from '../types.js'; -export type SessionFormat = 'core-v0' | 'desktop-v0' | 'empty'; +export type SessionFormat = 'canonical-v1' | 'core-v0' | 'desktop-v0' | 'empty'; export interface SessionDiagnostic { line: number; @@ -38,6 +37,13 @@ export class SessionCorruptionError extends Error { } } +export class SessionWriterConflictError extends Error { + constructor(readonly sessionId: string) { + super(`Session ${sessionId} already has an active writer`); + this.name = 'SessionWriterConflictError'; + } +} + export interface SessionMeta { id: string; cwd: string; @@ -52,33 +58,52 @@ export function defaultSessionsDir(): string { } export interface SessionFiles { + /** Read-only core v0 metadata sidecar. */ metaPath: string; + /** Canonical v1 stream used for all new writes. */ jsonlPath: string; + /** Read-only core/desktop v0 stream. */ + legacyJsonlPath: string; + writerLockPath: string; snapshotsDir: string; } export function sessionFiles(root: string, sessionId: string): SessionFiles { return { metaPath: join(root, `${sessionId}.meta.json`), - jsonlPath: join(root, `${sessionId}.jsonl`), + jsonlPath: join(root, `${sessionId}.v1.jsonl`), + legacyJsonlPath: join(root, `${sessionId}.jsonl`), + writerLockPath: join(root, `${sessionId}.writer.lock`), snapshotsDir: join(root, sessionId, 'snapshots'), }; } export async function writeMeta(root: string, meta: SessionMeta): Promise { const files = sessionFiles(root, meta.id); - await fs.mkdir(dirname(files.metaPath), { recursive: true }); - await fs.writeFile(files.metaPath, JSON.stringify(meta, null, 2), 'utf8'); + await withWriterLock(files, meta.id, async () => { + let messages: StoredMessage[] = []; + try { + const parsed = await readRecordsFromPath(files.jsonlPath); + const fatal = parsed.diagnostics.filter((diagnostic) => diagnostic.fatal); + if (fatal.length > 0) throw new SessionCorruptionError(meta.id, fatal); + messages = parsed.messages; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await writeCanonical(files.jsonlPath, meta, messages); + }); } export async function readMeta(root: string, sessionId: string): Promise { const files = sessionFiles(root, sessionId); + const records = await readSessionRecords(root, sessionId); + if (records.format === 'canonical-v1' && records.meta) return records.meta; try { const raw = await fs.readFile(files.metaPath, 'utf8'); return JSON.parse(raw) as SessionMeta; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return (await readSessionRecords(root, sessionId)).meta; + return records.meta; } throw err; } @@ -90,8 +115,10 @@ export async function appendMessage( message: StoredMessage, ): Promise { const files = sessionFiles(root, sessionId); - await fs.mkdir(dirname(files.jsonlPath), { recursive: true }); - await fs.appendFile(files.jsonlPath, JSON.stringify(message) + '\n', 'utf8'); + await withWriterLock(files, sessionId, async () => { + await ensureCanonical(sessionId, files); + await fs.appendFile(files.jsonlPath, JSON.stringify(messageRecord(message)) + '\n', 'utf8'); + }); } export async function readMessages(root: string, sessionId: string): Promise { @@ -115,37 +142,134 @@ function desktopMeta(value: Record, updatedAt: string): Session : typeof value.created_at === 'string' ? value.created_at : updatedAt; + const normalizedUpdatedAt = + value.schema_version === 1 && typeof value.updated_at === 'string' + ? value.updated_at + : updatedAt; return { id: value.id, cwd: typeof value.cwd === 'string' ? value.cwd : '', createdAt, - updatedAt, + updatedAt: normalizedUpdatedAt, model: typeof value.model === 'string' ? value.model : undefined, title: typeof value.title === 'string' ? value.title : undefined, }; } +function metaRecord(meta: SessionMeta): Record { + return { + type: 'session_meta', + schema_version: 1, + id: meta.id, + cwd: meta.cwd, + created_at: meta.createdAt, + updated_at: meta.updatedAt, + ...(meta.model ? { model: meta.model } : {}), + ...(meta.title ? { title: meta.title } : {}), + }; +} + +function messageRecord(message: StoredMessage): Record { + return { type: 'message', schema_version: 1, ...message }; +} + +async function writeCanonical( + path: string, + meta: SessionMeta, + messages: StoredMessage[], +): Promise { + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + const body = [metaRecord(meta), ...messages.map(messageRecord)] + .map((record) => JSON.stringify(record)) + .join('\n'); + await fs.writeFile(tempPath, body + '\n', { encoding: 'utf8', flag: 'wx' }); + await fs.rename(tempPath, path); +} + +async function withWriterLock( + files: SessionFiles, + sessionId: string, + operation: () => Promise, +): Promise { + await fs.mkdir(dirname(files.writerLockPath), { recursive: true }); + let lock: Awaited>; + try { + lock = await fs.open(files.writerLockPath, 'wx'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new SessionWriterConflictError(sessionId); + } + throw error; + } + try { + await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); + return await operation(); + } finally { + await lock.close(); + await fs.unlink(files.writerLockPath).catch(() => undefined); + } +} + +async function ensureCanonical(sessionId: string, files: SessionFiles): Promise { + try { + await fs.access(files.jsonlPath); + return; + } catch { + // Normalize below while holding the writer lock. + } + + const legacy = await readRecordsFromPath(files.legacyJsonlPath).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + return { format: 'empty', meta: null, messages: [], diagnostics: [] } as SessionReadResult; + } + throw error; + }, + ); + const fatal = legacy.diagnostics.filter((diagnostic) => diagnostic.fatal); + if (fatal.length > 0) throw new SessionCorruptionError(sessionId, fatal); + + let sidecar: SessionMeta | null = null; + try { + sidecar = JSON.parse(await fs.readFile(files.metaPath, 'utf8')) as SessionMeta; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const now = new Date().toISOString(); + const meta = legacy.meta ?? + sidecar ?? { + id: sessionId, + cwd: '', + createdAt: now, + updatedAt: now, + }; + await writeCanonical(files.jsonlPath, meta, legacy.messages); +} + /** Parse both historical JSONL layouts without modifying either one. */ export async function readSessionRecords( root: string, sessionId: string, ): Promise { const files = sessionFiles(root, sessionId); - let raw: string; - let updatedAt: string; try { - const [text, stat] = await Promise.all([ - fs.readFile(files.jsonlPath, 'utf8'), - fs.stat(files.jsonlPath), - ]); - raw = text; - updatedAt = stat.mtime.toISOString(); + return await readRecordsFromPath(files.jsonlPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + try { + return await readRecordsFromPath(files.legacyJsonlPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { format: 'empty', meta: null, messages: [], diagnostics: [] }; } throw error; } +} + +async function readRecordsFromPath(path: string): Promise { + const [raw, stat] = await Promise.all([fs.readFile(path, 'utf8'), fs.stat(path)]); + const updatedAt = stat.mtime.toISOString(); const lines = raw.split('\n'); let lastContentIndex = -1; @@ -190,12 +314,14 @@ export async function readSessionRecords( const record = value as Record; if (record.type === 'session_meta') { - format = 'desktop-v0'; + format = record.schema_version === 1 ? 'canonical-v1' : 'desktop-v0'; meta ??= desktopMeta(record, updatedAt); continue; } if (record.type === 'message') { - format = 'desktop-v0'; + if (format !== 'canonical-v1') { + format = record.schema_version === 1 ? 'canonical-v1' : 'desktop-v0'; + } if (isStoredMessage(record)) { messages.push({ role: record.role, @@ -242,6 +368,7 @@ export async function listSessions(root: string): Promise { const ids = new Set(); for (const entry of entries) { if (entry.endsWith('.meta.json')) ids.add(entry.slice(0, -'.meta.json'.length)); + else if (entry.endsWith('.v1.jsonl')) ids.add(entry.slice(0, -'.v1.jsonl'.length)); else if (entry.endsWith('.jsonl')) ids.add(entry.slice(0, -'.jsonl'.length)); } const metas = await Promise.all( @@ -258,13 +385,6 @@ export async function listSessions(root: string): Promise { .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } -export async function touchSession(root: string, sessionId: string): Promise { - const meta = await readMeta(root, sessionId); - if (!meta) return; - meta.updatedAt = new Date().toISOString(); - await writeMeta(root, meta); -} - export function newSessionId(): string { // Short prefix + uuid-ish — collision risk is negligible at this scale. const ts = new Date() From 38196c9a82cb202c4158ed169e39944ac5a1c76c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:52:50 +0800 Subject: [PATCH 06/23] feat: define experimental runtime protocol --- docs/design/runtime-protocol-v1.md | 64 ++++++++ packages/protocol/README.md | 10 ++ packages/protocol/package.json | 27 ++++ packages/protocol/src/codec.test.ts | 25 +++ packages/protocol/src/codec.ts | 30 ++++ packages/protocol/src/index.ts | 3 + packages/protocol/src/runtime.test.ts | 136 ++++++++++++++++ packages/protocol/src/runtime.ts | 217 ++++++++++++++++++++++++++ packages/protocol/src/types.ts | 83 ++++++++++ packages/protocol/tsconfig.json | 11 ++ packages/protocol/vitest.config.ts | 5 + tsconfig.json | 1 + 12 files changed, 612 insertions(+) create mode 100644 docs/design/runtime-protocol-v1.md create mode 100644 packages/protocol/README.md create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/codec.test.ts create mode 100644 packages/protocol/src/codec.ts create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/src/runtime.test.ts create mode 100644 packages/protocol/src/runtime.ts create mode 100644 packages/protocol/src/types.ts create mode 100644 packages/protocol/tsconfig.json create mode 100644 packages/protocol/vitest.config.ts diff --git a/docs/design/runtime-protocol-v1.md b/docs/design/runtime-protocol-v1.md new file mode 100644 index 0000000..fda1253 --- /dev/null +++ b/docs/design/runtime-protocol-v1.md @@ -0,0 +1,64 @@ +# Experimental runtime protocol v1 + +Status: experimental +Owner: runtime architecture +Implementation: `packages/protocol` + +## Purpose + +DeepCode clients currently integrate with the agent loop through surface-specific callbacks. The +experimental runtime protocol introduces a transport-neutral boundary that can be shared by the +CLI, desktop, VS Code, LSP, and a future local app server. It is intentionally independent of +Node.js, Tauri, React, and model providers. + +Version 1 proves lifecycle semantics and record/replay behavior. It is not yet a promise that +existing clients will migrate without a negotiated version check. + +## Lifecycle + +A thread contains ordered turns. A thread may have at most one `in_progress` turn. A turn starts +with a completed `user_message` item and reaches exactly one terminal state: + +```text + +-> completed +in_progress --------+-> interrupted + +-> failed +``` + +Terminal transitions are idempotent. Once a turn is terminal, later terminal requests return the +stored terminal snapshot and no second terminal event is emitted. Completed items cannot be added +to a terminal turn. + +## Durable and transient events + +Durable events describe state that can be reconstructed after a process restart: + +- `thread.started` +- `turn.started` +- `item.completed` +- `turn.completed` +- `turn.interrupted` +- `turn.failed` + +`item.delta` is transient. A delta is suitable for live UI streaming, but it is neither saved by +the thread store nor included in protocol recordings. A client that reconnects reads the latest +completed-item snapshot instead of replaying partial text. + +State is saved before its corresponding durable event is emitted. A consumer may therefore read +the referenced thread immediately after receiving an event. + +## Initialization and compatibility + +Clients call `initialize` before other methods and inspect both `protocolVersion` and advertised +capabilities. Version 1 advertises thread resume, turn interruption, completed-item persistence, +and transient deltas. + +Unknown methods and non-object request parameters are rejected by the line-oriented JSON codec. +Future incompatible lifecycle changes require a new protocol version; optional behavior should be +introduced through capabilities. + +## Current scope + +The in-memory store and codec are reference implementations used by contract tests. Production +transport, authorization, persistent storage, backpressure, and wiring to `RuntimeHost` belong to +the app-server phase of the alignment roadmap. diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 0000000..1b3f572 --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,10 @@ +# @deepcode/protocol + +Experimental, transport-neutral lifecycle contracts for DeepCode runtimes and clients. + +The package deliberately has no Node.js, Tauri, React, or model-provider dependency. Durable +events describe thread, turn, and completed-item state; streaming deltas are transient and are +excluded from record/replay snapshots. + +This is an internal experimental boundary. Consumers must negotiate `protocolVersion` through +`initialize` instead of assuming backwards compatibility. diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..1675b7d --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,27 @@ +{ + "name": "@deepcode/protocol", + "version": "0.0.0", + "private": true, + "description": "Experimental provider- and transport-neutral DeepCode lifecycle protocol", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -b", + "test": "vitest run", + "lint": "echo 'lint: configured at workspace root' && exit 0", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } +} diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts new file mode 100644 index 0000000..456504c --- /dev/null +++ b/packages/protocol/src/codec.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeProtocolRequest, encodeProtocolMessage } from './codec.js'; + +describe('protocol codec', () => { + it('has a stable line-oriented JSON representation', () => { + const request = { + id: 1, + method: 'initialize' as const, + params: { client: 'protocol-test' }, + }; + + expect(encodeProtocolMessage(request)).toBe( + '{"id":1,"method":"initialize","params":{"client":"protocol-test"}}', + ); + expect(decodeProtocolRequest(encodeProtocolMessage(request))).toEqual(request); + }); + + it.each(['{}', '{"id":1,"method":"unknown"}', '{"id":1,"method":"initialize","params":[]}'])( + 'rejects an invalid request: %s', + (raw) => { + expect(() => decodeProtocolRequest(raw)).toThrow('invalid protocol request'); + }, + ); +}); diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts new file mode 100644 index 0000000..58c18d6 --- /dev/null +++ b/packages/protocol/src/codec.ts @@ -0,0 +1,30 @@ +import type { ProtocolMethod, ProtocolRequest, ProtocolResponse } from './types.js'; + +const protocolMethods = new Set([ + 'initialize', + 'thread/start', + 'thread/read', + 'thread/resume', + 'turn/start', + 'turn/interrupt', +]); + +export function encodeProtocolMessage(message: ProtocolRequest | ProtocolResponse): string { + return JSON.stringify(message); +} + +export function decodeProtocolRequest(raw: string): ProtocolRequest { + const value = JSON.parse(raw) as Partial; + const validParams = + value.params === undefined || + (typeof value.params === 'object' && value.params !== null && !Array.isArray(value.params)); + if ( + (typeof value.id !== 'string' && typeof value.id !== 'number') || + typeof value.method !== 'string' || + !protocolMethods.has(value.method as ProtocolMethod) || + !validParams + ) { + throw new Error('invalid protocol request'); + } + return { id: value.id, method: value.method, params: value.params ?? {} } as ProtocolRequest; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 0000000..9679568 --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './runtime.js'; +export * from './codec.js'; diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts new file mode 100644 index 0000000..969f1f1 --- /dev/null +++ b/packages/protocol/src/runtime.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { + MemoryThreadStore, + ProtocolInvariantError, + ProtocolRecorder, + ProtocolRuntime, +} from './runtime.js'; +import type { ProtocolEvent } from './types.js'; + +function deterministicRuntime( + store: MemoryThreadStore, + events: ProtocolEvent[] = [], +): ProtocolRuntime { + let tick = 0; + let sequence = 0; + return new ProtocolRuntime({ + store, + now: () => `2026-08-01T00:00:0${tick++}.000Z`, + newId: (prefix) => `${prefix}-${++sequence}`, + onEvent: (event) => events.push(event), + }); +} + +describe('ProtocolRuntime', () => { + it('advertises the versioned lifecycle capabilities', () => { + const runtime = deterministicRuntime(new MemoryThreadStore()); + + expect(runtime.initialize()).toEqual({ + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + }, + }); + }); + + it('persists completed items, keeps deltas transient, and resumes after restart', async () => { + const store = new MemoryThreadStore(); + const events: ProtocolEvent[] = []; + const runtime = deterministicRuntime(store, events); + const thread = await runtime.startThread('/workspace'); + const turn = await runtime.startTurn(thread.id, { text: 'inspect the repository' }); + const assistant = await runtime.appendCompletedItem(thread.id, turn.id, 'assistant_message', { + text: 'working', + }); + + const savesBeforeDelta = store.saveCount; + runtime.publishDelta({ + threadId: thread.id, + turnId: turn.id, + itemId: assistant.id, + delta: '...', + }); + expect(store.saveCount).toBe(savesBeforeDelta); + + const completed = await runtime.completeTurn(thread.id, turn.id); + expect(store.saveCount).toBe(4); + expect(completed.status).toBe('completed'); + + const restartedRuntime = deterministicRuntime(store); + await expect(restartedRuntime.resumeThread(thread.id)).resolves.toEqual({ + ...thread, + updatedAt: completed.completedAt, + turns: [completed], + }); + expect(events.map((event) => event.type)).toEqual([ + 'thread.started', + 'turn.started', + 'item.completed', + 'item.completed', + 'item.delta', + 'turn.completed', + ]); + }); + + it('allows only one active turn per thread', async () => { + const runtime = deterministicRuntime(new MemoryThreadStore()); + const thread = await runtime.startThread('/workspace'); + await runtime.startTurn(thread.id, { text: 'first' }); + + await expect(runtime.startTurn(thread.id, { text: 'second' })).rejects.toThrow( + new ProtocolInvariantError(`Thread ${thread.id} already has an active turn`), + ); + }); + + it('makes terminal transitions idempotent and rejects late items', async () => { + const store = new MemoryThreadStore(); + const events: ProtocolEvent[] = []; + const runtime = deterministicRuntime(store, events); + const thread = await runtime.startThread('/workspace'); + const turn = await runtime.startTurn(thread.id, { text: 'stop me' }); + + const interrupted = await runtime.interruptTurn(thread.id, turn.id); + const savesAfterInterrupt = store.saveCount; + await expect(runtime.interruptTurn(thread.id, turn.id)).resolves.toEqual(interrupted); + await expect(runtime.completeTurn(thread.id, turn.id)).resolves.toEqual(interrupted); + expect(store.saveCount).toBe(savesAfterInterrupt); + expect(events.filter((event) => event.type === 'turn.interrupted')).toHaveLength(1); + expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(0); + await expect( + runtime.appendCompletedItem(thread.id, turn.id, 'assistant_message', { text: 'late' }), + ).rejects.toThrow(`Cannot append to terminal turn ${turn.id}`); + }); + + it('records and replays only durable events', async () => { + const recorder = new ProtocolRecorder(); + const runtime = new ProtocolRuntime({ + store: new MemoryThreadStore(), + now: () => '2026-08-01T00:00:00.000Z', + newId: (prefix) => `${prefix}-1`, + onEvent: (event) => recorder.record(event), + }); + const thread = await runtime.startThread('/workspace'); + const turn = await runtime.startTurn(thread.id, { text: 'hello' }); + runtime.publishDelta({ + threadId: thread.id, + turnId: turn.id, + itemId: 'item-streaming', + delta: 'hel', + }); + await runtime.completeTurn(thread.id, turn.id); + + const replayed: ProtocolEvent[] = []; + recorder.replay((event) => replayed.push(event)); + expect(replayed).toEqual(recorder.snapshot()); + expect(replayed.map((event) => event.type)).toEqual([ + 'thread.started', + 'turn.started', + 'item.completed', + 'turn.completed', + ]); + }); +}); diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts new file mode 100644 index 0000000..326a75e --- /dev/null +++ b/packages/protocol/src/runtime.ts @@ -0,0 +1,217 @@ +import { + PROTOCOL_VERSION, + type CompletedItem, + type CompletedItemType, + type DurableProtocolEvent, + type InitializeResult, + type ProtocolEvent, + type ThreadSnapshot, + type TransientDeltaEvent, + type TurnSnapshot, + type TurnStatus, +} from './types.js'; + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export interface ThreadStore { + load(threadId: string): Promise; + save(thread: ThreadSnapshot): Promise; +} + +export class MemoryThreadStore implements ThreadStore { + private readonly threads = new Map(); + saveCount = 0; + + async load(threadId: string): Promise { + const thread = this.threads.get(threadId); + return thread ? clone(thread) : null; + } + + async save(thread: ThreadSnapshot): Promise { + this.saveCount++; + this.threads.set(thread.id, clone(thread)); + } +} + +export interface ProtocolRuntimeOptions { + store: ThreadStore; + now?: () => string; + newId?: (prefix: 'thread' | 'turn' | 'item') => string; + onEvent?: (event: ProtocolEvent) => void; +} + +export class ProtocolInvariantError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProtocolInvariantError'; + } +} + +export class ProtocolRuntime { + private readonly now: () => string; + private readonly newId: (prefix: 'thread' | 'turn' | 'item') => string; + + constructor(private readonly options: ProtocolRuntimeOptions) { + this.now = options.now ?? (() => new Date().toISOString()); + this.newId = + options.newId ?? + ((prefix) => + `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`); + } + + initialize(): InitializeResult { + return { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + }, + }; + } + + async startThread(cwd: string): Promise { + const now = this.now(); + const thread: ThreadSnapshot = { + id: this.newId('thread'), + cwd, + createdAt: now, + updatedAt: now, + turns: [], + }; + await this.options.store.save(thread); + this.emit({ type: 'thread.started', thread: clone(thread) }); + return clone(thread); + } + + readThread(threadId: string): Promise { + return this.options.store.load(threadId); + } + + async resumeThread(threadId: string): Promise { + return this.requireThread(threadId); + } + + async startTurn(threadId: string, input: Record): Promise { + const thread = await this.requireThread(threadId); + if (thread.turns.some((turn) => turn.status === 'in_progress')) { + throw new ProtocolInvariantError(`Thread ${threadId} already has an active turn`); + } + const now = this.now(); + const inputItem = this.completedItem('user_message', input, now); + const turn: TurnSnapshot = { + id: this.newId('turn'), + threadId, + status: 'in_progress', + startedAt: now, + items: [inputItem], + }; + thread.turns.push(turn); + thread.updatedAt = now; + await this.options.store.save(thread); + this.emit({ type: 'turn.started', threadId, turn: clone(turn) }); + this.emit({ type: 'item.completed', threadId, turnId: turn.id, item: clone(inputItem) }); + return clone(turn); + } + + async appendCompletedItem( + threadId: string, + turnId: string, + type: CompletedItemType, + payload: Record, + ): Promise { + const thread = await this.requireThread(threadId); + const turn = this.requireTurn(thread, turnId); + if (turn.status !== 'in_progress') { + throw new ProtocolInvariantError(`Cannot append to terminal turn ${turnId}`); + } + const item = this.completedItem(type, payload, this.now()); + turn.items.push(item); + thread.updatedAt = item.completedAt; + await this.options.store.save(thread); + this.emit({ type: 'item.completed', threadId, turnId, item: clone(item) }); + return clone(item); + } + + publishDelta(event: Omit): void { + this.emit({ type: 'item.delta', ...event }); + } + + completeTurn(threadId: string, turnId: string): Promise { + return this.finishTurn(threadId, turnId, 'completed'); + } + + interruptTurn(threadId: string, turnId: string): Promise { + return this.finishTurn(threadId, turnId, 'interrupted'); + } + + failTurn(threadId: string, turnId: string): Promise { + return this.finishTurn(threadId, turnId, 'failed'); + } + + private async finishTurn( + threadId: string, + turnId: string, + requested: Exclude, + ): Promise { + const thread = await this.requireThread(threadId); + const turn = this.requireTurn(thread, turnId); + if (turn.status !== 'in_progress') return clone(turn); + const now = this.now(); + turn.status = requested; + turn.completedAt = now; + thread.updatedAt = now; + await this.options.store.save(thread); + const type = + requested === 'completed' + ? 'turn.completed' + : requested === 'interrupted' + ? 'turn.interrupted' + : 'turn.failed'; + this.emit({ type, threadId, turn: clone(turn) } as DurableProtocolEvent); + return clone(turn); + } + + private async requireThread(threadId: string): Promise { + const thread = await this.options.store.load(threadId); + if (!thread) throw new ProtocolInvariantError(`Thread not found: ${threadId}`); + return thread; + } + + private requireTurn(thread: ThreadSnapshot, turnId: string): TurnSnapshot { + const turn = thread.turns.find((candidate) => candidate.id === turnId); + if (!turn) throw new ProtocolInvariantError(`Turn not found: ${turnId}`); + return turn; + } + + private completedItem( + type: CompletedItemType, + payload: Record, + completedAt: string, + ): CompletedItem { + return { id: this.newId('item'), type, payload: clone(payload), completedAt }; + } + + private emit(event: ProtocolEvent): void { + this.options.onEvent?.(event); + } +} + +export class ProtocolRecorder { + private readonly records: DurableProtocolEvent[] = []; + + record(event: ProtocolEvent): void { + if (event.type !== 'item.delta') this.records.push(clone(event)); + } + + replay(consumer: (event: DurableProtocolEvent) => void): void { + for (const event of this.records) consumer(clone(event)); + } + + snapshot(): DurableProtocolEvent[] { + return clone(this.records); + } +} diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts new file mode 100644 index 0000000..9c1c99a --- /dev/null +++ b/packages/protocol/src/types.ts @@ -0,0 +1,83 @@ +export const PROTOCOL_VERSION = 1 as const; + +export type TurnStatus = 'in_progress' | 'completed' | 'interrupted' | 'failed'; +export type CompletedItemType = + | 'user_message' + | 'assistant_message' + | 'tool_call' + | 'tool_result' + | 'approval' + | 'ask_user' + | 'error'; + +export interface CompletedItem { + id: string; + type: CompletedItemType; + payload: Record; + completedAt: string; +} + +export interface TurnSnapshot { + id: string; + threadId: string; + status: TurnStatus; + startedAt: string; + completedAt?: string; + items: CompletedItem[]; +} + +export interface ThreadSnapshot { + id: string; + cwd: string; + createdAt: string; + updatedAt: string; + turns: TurnSnapshot[]; +} + +export type DurableProtocolEvent = + | { type: 'thread.started'; thread: ThreadSnapshot } + | { type: 'turn.started'; threadId: string; turn: TurnSnapshot } + | { type: 'item.completed'; threadId: string; turnId: string; item: CompletedItem } + | { type: 'turn.completed'; threadId: string; turn: TurnSnapshot } + | { type: 'turn.interrupted'; threadId: string; turn: TurnSnapshot } + | { type: 'turn.failed'; threadId: string; turn: TurnSnapshot }; + +export interface TransientDeltaEvent { + type: 'item.delta'; + threadId: string; + turnId: string; + itemId: string; + delta: string; +} + +export type ProtocolEvent = DurableProtocolEvent | TransientDeltaEvent; + +export interface InitializeResult { + protocolVersion: typeof PROTOCOL_VERSION; + capabilities: { + threadResume: true; + turnInterrupt: true; + completedItemPersistence: true; + transientDeltas: true; + }; +} + +export type ProtocolMethod = + | 'initialize' + | 'thread/start' + | 'thread/read' + | 'thread/resume' + | 'turn/start' + | 'turn/interrupt'; + +export interface ProtocolRequest { + id: string | number; + method: ProtocolMethod; + params: Record; +} + +export interface ProtocolResponse { + id: string | number; + result?: unknown; + error?: { code: string; message: string }; +} diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 0000000..571f2cf --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/protocol/vitest.config.ts b/packages/protocol/vitest.config.ts new file mode 100644 index 0000000..7067968 --- /dev/null +++ b/packages/protocol/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { include: ['src/**/*.test.ts'], environment: 'node' }, +}); diff --git a/tsconfig.json b/tsconfig.json index b2e6051..e448224 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "include": [], "references": [ { "path": "./packages/core" }, + { "path": "./packages/protocol" }, { "path": "./packages/shared-ui" }, { "path": "./apps/cli" }, { "path": "./apps/desktop" }, From 7a51ce08381e519e3694c49658ed16f2b7e818af Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:59:00 +0800 Subject: [PATCH 07/23] docs: decide desktop runtime packaging --- docs/CODEX_ALIGNMENT_PLAN.md | 10 +- docs/adr/0001-desktop-runtime-sidecar.md | 120 +++++++++++++++++++++ package.json | 1 + scripts/spike-desktop-sidecar.mjs | 126 +++++++++++++++++++++++ 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0001-desktop-runtime-sidecar.md create mode 100644 scripts/spike-desktop-sidecar.mjs diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 3cf0eb3..8a03f43 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -267,11 +267,13 @@ model tool call ### PR 4 — Desktop runtime packaging ADR/spike -- 在 bundled Node sidecar、单可执行 sidecar、Rust runtime 与过渡 renderer loop 中做可发布选择。 -- 必须在签名后的 `.app`、无系统 Node 环境中证明启动、退出、取消、升级和恢复。 -- 同时决定单客户端 stdio 还是多客户端 daemon/socket,不提前承诺 active turn 跨端附着。 +- 已由 `docs/adr/0001-desktop-runtime-sidecar.md` 决定采用 Tauri 监督的 target-specific Node 22 + sidecar、单文件 app-server resource 与单客户端 stdio;不承诺 active turn 跨端附着。 +- 可复现 probe 必须在无系统 Node 的 PATH 中证明协议握手,并报告 runtime 体积与冷启动。 +- 签名、notarization、取消、升级和恢复仍是迁移 renderer 前的 release gate,不能用本地 ad-hoc + 签名冒充发布验证。 -验收:形成 ADR、可复现 spike、安装包体积/冷启动/签名结果和失败回滚路径。 +验收:形成 ADR、可复现 spike、本机构建体积/冷启动证据、发布签名 gate 和失败回滚路径。 ### PR 5 — App-server 垂直切片与 CLI diff --git a/docs/adr/0001-desktop-runtime-sidecar.md b/docs/adr/0001-desktop-runtime-sidecar.md new file mode 100644 index 0000000..f38f9fe --- /dev/null +++ b/docs/adr/0001-desktop-runtime-sidecar.md @@ -0,0 +1,120 @@ +# ADR 0001: Package the desktop runtime as a supervised Node sidecar + +- Status: Accepted +- Date: 2026-08-01 +- Decision owners: DeepCode runtime maintainers +- Roadmap: `docs/CODEX_ALIGNMENT_PLAN.md`, PR 4 + +## Context + +The Tauri renderer currently imports selected pieces of `@deepcode/core`, creates the +`DeepSeekProvider`, and calls `runAgent` inside the WebView. The renderer receives API credentials +from Rust, cannot load core modules that depend on Node APIs, and disables runtime features such as +hooks and system reminders. Each workaround widens the behavior gap between desktop and the other +clients. + +Moving the existing TypeScript runtime behind a process boundary is therefore required. An +installed desktop app cannot assume that Node is present on the user's `PATH`. + +Tauri 2 supports target-specific external binaries for precisely this class of dependency; its +[sidecar documentation](https://v2.tauri.app/develop/sidecar/) describes embedding executables so +users do not need to install runtimes such as Node or Python. Node also offers +[single-executable applications](https://nodejs.org/download/release/latest-jod/docs/api/single-executable-applications.html), +but the Node 22 feature is still marked active development, accepts one embedded CommonJS script, +and requires a separate platform-specific injection step. + +## Decision + +DeepCode will use a **Tauri-supervised, target-specific Node 22 sidecar** as the transitional +desktop runtime boundary. + +- The release build pins and checksum-verifies an official Node 22 binary for each supported + target. It does not copy an arbitrary developer-machine runtime into a release. +- The app server is bundled into one CommonJS file and included as an app resource. Its production + dependency graph must not resolve modules from a user-controlled working directory. +- Tauri packages the runtime through `bundle.externalBin`. The filename follows Tauri's required + target suffix convention, such as `deepcode-runtime-aarch64-apple-darwin`. +- Rust owns sidecar startup, shutdown, crash reporting, and stdio. The renderer communicates only + through the versioned line-delimited JSON protocol. +- The sidecar owns `RuntimeHost`, provider creation, credentials, configuration, hooks, MCP, + permissions, sandboxing, session persistence, and the agent lifecycle. Credentials never enter + WebView memory. +- Version 1 uses one child process per desktop app and a single-client stdio connection. A shared + multi-client daemon requires a later decision covering socket authentication, ownership, + subscriptions, and backpressure. +- Release signing explicitly signs the nested runtime before the outer `.app`; CI then performs + deep strict signature verification before notarization. + +The bundled runtime is an implementation detail behind the protocol. It may later become a Node +SEA, another compatible JavaScript runtime, or a native implementation without changing clients. + +## Spike evidence + +`pnpm spike:desktop-sidecar` creates an isolated temporary layout, copies and target-thins the +current runtime, strips it where supported, ad-hoc signs the resulting Mach-O, clears `PATH`, and +performs an `initialize` handshake over stdio. The script fails unless the child reports protocol +version 1 without discovering a system runtime. + +On the 2026-08-01 Apple Silicon development host: + +| Measurement | Result | +| ------------------------------ | ------------------- | +| Existing unsigned Tauri `.app` | 6,733,824 bytes | +| Local universal Node runtime | 237,619,616 bytes | +| Target-thin arm64 runtime | 117,655,968 bytes | +| Thin, stripped runtime | 108,412,080 bytes | +| Isolated protocol handshake | passed with no PATH | +| Cold isolated handshake | 1.02 seconds | + +These are topology measurements, not release promises. The local runtime is Homebrew's universal +Node 24 build, whereas production will use a pinned target-specific Node 22 distribution. A signed +and notarized artifact cannot be verified locally without release credentials, so that remains a +release-CI gate rather than a claimed spike result. + +## Options considered + +### Keep the agent loop in the WebView + +Rejected. It exposes credentials to renderer JavaScript, forces browser-compatible subsets of +core, duplicates host assembly, and cannot provide a trustworthy long-running backend. + +### Node single-executable application + +Deferred. SEA does not remove the Node runtime size, and Node 22 adds CommonJS bundling, blob +injection, fuse mutation, and post-injection signing to the release chain while the feature remains +in active development. Reconsider after the app-server bundle is stable and the SEA build is +reproducible on all release targets. + +### Bun-compiled sidecar + +Deferred. It could simplify single-file creation, but it adds a second JavaScript runtime and new +compatibility risk for Node-heavy core modules. It may be evaluated later against the same protocol +and test corpus. + +### Rewrite the runtime in Rust + +Rejected for this migration. It preserves a small app but duplicates the provider, tool, hook, +plugin, MCP, session, and policy implementations before their shared semantics are stable. + +### Require a system Node installation + +Rejected. It makes the desktop artifact non-self-contained and introduces unsupported version and +PATH variation. + +## Consequences and gates + +The installed app becomes materially larger. That cost is accepted to eliminate the higher-risk +renderer runtime split, but release PRs must report uncompressed `.app` size, compressed DMG size, +and cold handshake time. The first production sidecar is blocked if any of these gates fail: + +- a clean environment with an empty `PATH` cannot initialize the server; +- the bundled app resolves server code or dependencies outside its signed resources; +- credentials or provider calls remain in renderer bundles; +- interrupting the desktop turn does not stop the backend operation; +- nested and outer signatures fail `codesign --verify --deep --strict`; +- notarization or stapling fails; +- the final DMG exceeds 100 MB without a separate maintainers' decision. + +Rollback is a configuration-level switch to the existing renderer runtime during the experimental +phase. The fallback must be removed once sidecar parity, credential isolation, and signed release +gates pass; it is not a permanent dual architecture. diff --git a/package.json b/package.json index 6f0a30f..ebe6be2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "format": "prettier --write \"**/*.{ts,tsx,json,md,yml,yaml}\"", "format:check": "prettier --check \"**/*.{ts,tsx,json,md,yml,yaml}\"", "docs:check": "node scripts/check-docs.mjs", + "spike:desktop-sidecar": "node scripts/spike-desktop-sidecar.mjs", "clean": "pnpm -r clean", "prepare": "husky || true" }, diff --git a/scripts/spike-desktop-sidecar.mjs b/scripts/spike-desktop-sidecar.mjs new file mode 100644 index 0000000..f53a586 --- /dev/null +++ b/scripts/spike-desktop-sidecar.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +import { copyFile, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { performance } from 'node:perf_hooks'; +import process from 'node:process'; +import { createInterface } from 'node:readline'; + +const probeSource = String.raw` +const readline = require('node:readline'); +const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +lines.on('line', (line) => { + const request = JSON.parse(line); + const result = request.method === 'initialize' + ? { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + }, + runtime: { execPath: process.execPath, path: process.env.PATH ?? null }, + } + : undefined; + process.stdout.write(JSON.stringify({ id: request.id, result }) + '\n'); + lines.close(); +}); +`; + +async function runProbe(runtimePath, serverPath) { + const startedAt = performance.now(); + const child = spawn(runtimePath, [serverPath], { + env: { PATH: '' }, + stdio: ['pipe', 'pipe', 'inherit'], + }); + const output = createInterface({ input: child.stdout, crlfDelay: Infinity }); + const exited = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + const response = new Promise((resolve, reject) => { + output.once('line', (line) => { + try { + resolve(JSON.parse(line)); + } catch (error) { + reject(error); + } + }); + }); + child.stdin.end('{"id":1,"method":"initialize","params":{}}\n'); + const result = await Promise.race([ + response, + exited.then(({ code, signal }) => { + throw new Error(`sidecar probe exited before responding (code=${code}, signal=${signal})`); + }), + ]); + const { code, signal } = await exited; + if (code !== 0) throw new Error(`sidecar probe exited with code=${code}, signal=${signal}`); + return { result, handshakeMilliseconds: performance.now() - startedAt }; +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), 'deepcode-sidecar-')); +const runtimePath = join(temporaryRoot, 'deepcode-runtime'); +const serverPath = join(temporaryRoot, 'app-server.cjs'); + +try { + await copyFile(process.execPath, runtimePath); + await writeFile(serverPath, probeSource); + const sourceRuntimeBytes = (await stat(runtimePath)).size; + let thinned = false; + if (process.platform === 'darwin' && ['arm64', 'x64'].includes(process.arch)) { + const thinPath = `${runtimePath}.thin`; + const architecture = process.arch === 'x64' ? 'x86_64' : process.arch; + const thin = spawnSync('/usr/bin/lipo', [ + runtimePath, + '-thin', + architecture, + '-output', + thinPath, + ]); + if (thin.status === 0) { + await rename(thinPath, runtimePath); + thinned = true; + } + } + const targetRuntimeBytes = (await stat(runtimePath)).size; + const strip = + process.platform === 'darwin' ? spawnSync('/usr/bin/strip', ['-S', runtimePath]) : null; + const stripped = strip?.status === 0; + const sign = + process.platform === 'darwin' + ? spawnSync('/usr/bin/codesign', ['--force', '--sign', '-', runtimePath]) + : null; + const signed = sign?.status === 0; + const afterStrip = (await stat(runtimePath)).size; + const { result: response, handshakeMilliseconds } = await runProbe(runtimePath, serverPath); + if (response?.result?.protocolVersion !== 1 || response?.result?.runtime?.path !== '') { + throw new Error('sidecar did not complete an isolated protocol handshake'); + } + + process.stdout.write( + `${JSON.stringify( + { + status: 'ok', + platform: `${process.platform}-${process.arch}`, + sourceRuntimeBytes, + targetRuntimeBytes, + runtimeBytesAfterStrip: afterStrip, + thinned, + stripped, + signed, + protocolVersion: response.result.protocolVersion, + handshakeMilliseconds: Math.round(handshakeMilliseconds * 100) / 100, + childExecPath: response.result.runtime.execPath, + childPath: response.result.runtime.path, + }, + null, + 2, + )}\n`, + ); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} From b3b7d822e3a47581bff0eab1b20073cd8aa3ab5e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 14:11:34 +0800 Subject: [PATCH 08/23] feat: add experimental app server --- apps/cli/package.json | 1 + apps/cli/src/cli.ts | 9 + apps/cli/src/completion.ts | 1 + apps/cli/src/parse-args.ts | 1 + apps/cli/tsconfig.json | 6 +- apps/server/README.md | 16 ++ apps/server/package.json | 38 ++++ apps/server/src/cli.ts | 9 + apps/server/src/default-runtime.ts | 35 ++++ apps/server/src/index.ts | 6 + apps/server/src/run.ts | 29 +++ apps/server/src/runtime-executor.test.ts | 136 +++++++++++++ apps/server/src/runtime-executor.ts | 104 ++++++++++ apps/server/src/server.test.ts | 176 +++++++++++++++++ apps/server/src/server.ts | 242 +++++++++++++++++++++++ apps/server/src/stdio.test.ts | 105 ++++++++++ apps/server/src/stdio.ts | 81 ++++++++ apps/server/src/store.ts | 38 ++++ apps/server/tsconfig.json | 12 ++ apps/server/vitest.config.ts | 5 + docs/design/app-server-v1.md | 84 ++++++++ packages/protocol/src/codec.test.ts | 17 ++ packages/protocol/src/codec.ts | 11 +- packages/protocol/src/types.ts | 7 +- pnpm-lock.yaml | 31 +++ tsconfig.json | 1 + 26 files changed, 1197 insertions(+), 4 deletions(-) create mode 100644 apps/server/README.md create mode 100644 apps/server/package.json create mode 100644 apps/server/src/cli.ts create mode 100644 apps/server/src/default-runtime.ts create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/run.ts create mode 100644 apps/server/src/runtime-executor.test.ts create mode 100644 apps/server/src/runtime-executor.ts create mode 100644 apps/server/src/server.test.ts create mode 100644 apps/server/src/server.ts create mode 100644 apps/server/src/stdio.test.ts create mode 100644 apps/server/src/stdio.ts create mode 100644 apps/server/src/store.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/server/vitest.config.ts create mode 100644 docs/design/app-server-v1.md diff --git a/apps/cli/package.json b/apps/cli/package.json index 1b65597..5c66d68 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -21,6 +21,7 @@ "start": "node ./dist/cli.js" }, "dependencies": { + "@deepcode/app-server": "workspace:*", "@deepcode/core": "workspace:*", "@deepcode/shared-ui": "workspace:*" }, diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 22e9d38..9a33f55 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -4,6 +4,7 @@ // M2: onboarding + REPL + slash commands + settings + permissions matcher. import { CredentialsStore, VERSION, redact } from '@deepcode/core'; +import { runAppServer } from '@deepcode/app-server'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { runHeadless } from './headless.js'; @@ -80,6 +81,14 @@ async function main(): Promise { errOutput: process.stderr, }); } + if (args.positional[0] === 'app-server') { + await runAppServer({ + input: process.stdin, + output: process.stdout, + home: process.env.DEEPCODE_HOME ?? resolve(homedir(), '.deepcode'), + }); + return 0; + } if (args.positional[0] === 'trust') { return runTrustCommand(args.positional.slice(1), { cwd: process.cwd(), diff --git a/apps/cli/src/completion.ts b/apps/cli/src/completion.ts index f938260..795b592 100644 --- a/apps/cli/src/completion.ts +++ b/apps/cli/src/completion.ts @@ -53,6 +53,7 @@ const SUBCOMMANDS = [ 'doctor', 'upgrade', 'mcp', + 'app-server', 'trust', 'plugins', 'skills', diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index f2fc27f..6149fe6 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -285,6 +285,7 @@ USAGE deepcode cron Scheduled tasks: install/uninstall/list/status deepcode scheduler run Run due scheduled jobs (invoked by launchd) deepcode mcp serve Expose DeepCode tools as an MCP server (stdio) + deepcode app-server Run the experimental lifecycle server (JSONL stdio) deepcode trust [--plan-only] Trust this directory's project config (hooks/MCP/...) deepcode plugins list [--json] List installed plugins deepcode plugins install Install a plugin (gh:owner/repo | name@npm | ./path) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 1b5060f..372efed 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -9,5 +9,9 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], - "references": [{ "path": "../../packages/core" }, { "path": "../../packages/shared-ui" }] + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/shared-ui" }, + { "path": "../server" } + ] } diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..5bc1b33 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,16 @@ +# @deepcode/app-server + +Experimental line-delimited JSON runtime server for DeepCode clients. + +The server owns lifecycle state and delegates model work to `RuntimeHost`. Completed items and +terminal turn state are persisted; streaming deltas are notifications only. The initial transport +is single-client stdio, matching the desktop packaging decision in +`docs/adr/0001-desktop-runtime-sidecar.md`. + +After a workspace build, run `node apps/server/dist/cli.js` and send one JSON request per line: + +```json +{ "id": 1, "method": "initialize", "params": {} } +``` + +The transport is experimental. Clients must negotiate `protocolVersion` before using it. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..5d9f707 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepcode/app-server", + "version": "0.0.0", + "private": true, + "description": "Experimental DeepCode runtime protocol server", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "deepcode-app-server": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -b", + "test": "vitest run", + "lint": "echo 'lint: configured at workspace root' && exit 0", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "dependencies": { + "@deepcode/core": "workspace:*", + "@deepcode/protocol": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=22" + } +} diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts new file mode 100644 index 0000000..eeeedb6 --- /dev/null +++ b/apps/server/src/cli.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +import process from 'node:process'; + +import { runAppServer } from './run.js'; + +const home = process.env.DEEPCODE_HOME ?? `${process.env.HOME ?? process.cwd()}/.deepcode`; + +await runAppServer({ input: process.stdin, output: process.stdout, home }); diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts new file mode 100644 index 0000000..3fd8dc4 --- /dev/null +++ b/apps/server/src/default-runtime.ts @@ -0,0 +1,35 @@ +import { + BUILTIN_TOOLS, + CredentialsStore, + DeepSeekProvider, + RuntimeHost, + SAFE_READONLY_TOOLS, + ToolRegistry, + resolveCredentials, +} from '@deepcode/core'; + +import { RuntimeHostExecutor } from './runtime-executor.js'; + +export function createDefaultTurnExecutor(): RuntimeHostExecutor { + return new RuntimeHostExecutor({ + createHost: async (cwd) => { + const credentials = await resolveCredentials({ store: new CredentialsStore() }); + if (!credentials.apiKey && !credentials.authToken) { + throw new Error( + 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', + ); + } + return new RuntimeHost({ + provider: new DeepSeekProvider({ + apiKey: credentials.apiKey ?? '', + authToken: credentials.authToken, + baseURL: credentials.baseURL, + }), + tools: new ToolRegistry(BUILTIN_TOOLS), + cwd, + mode: 'default', + permissions: { allow: [...SAFE_READONLY_TOOLS] }, + }); + }, + }); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..d6beb9a --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,6 @@ +export * from './server.js'; +export * from './store.js'; +export * from './runtime-executor.js'; +export * from './default-runtime.js'; +export * from './stdio.js'; +export * from './run.js'; diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts new file mode 100644 index 0000000..fc90649 --- /dev/null +++ b/apps/server/src/run.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path'; +import type { Readable, Writable } from 'node:stream'; + +import type { ProtocolNotification } from '@deepcode/protocol'; + +import { createDefaultTurnExecutor } from './default-runtime.js'; +import { AppServer, type TurnExecutor } from './server.js'; +import { FileThreadStore } from './store.js'; +import { ProtocolLineWriter, serveStdio } from './stdio.js'; + +export interface RunAppServerOptions { + input: Readable; + output: Writable; + home: string; + executor?: TurnExecutor; +} + +export async function runAppServer(options: RunAppServerOptions): Promise { + const writer = new ProtocolLineWriter(options.output); + const server = new AppServer({ + executor: options.executor ?? createDefaultTurnExecutor(), + store: new FileThreadStore(join(options.home, 'threads-v1')), + onEvent: (event) => { + const notification: ProtocolNotification = { method: 'event', params: event }; + void writer.enqueue(notification); + }, + }); + await serveStdio(server, options.input, writer); +} diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts new file mode 100644 index 0000000..38d675c --- /dev/null +++ b/apps/server/src/runtime-executor.test.ts @@ -0,0 +1,136 @@ +import { + RuntimeHost, + ToolRegistry, + type Provider, + type ProviderResult, + type ProviderRunOpts, +} from '@deepcode/core'; +import type { ThreadSnapshot, TurnSnapshot } from '@deepcode/protocol'; +import { describe, expect, it } from 'vitest'; + +import { RuntimeHostExecutor, historyFromThread } from './runtime-executor.js'; + +const priorAssistant = { + role: 'assistant' as const, + content: [{ type: 'text' as const, text: 'prior answer' }], +}; + +const thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:01.000Z', + turns: [ + { + id: 'turn-prior', + threadId: 'thread-1', + status: 'completed', + startedAt: '2026-08-01T00:00:00.000Z', + completedAt: '2026-08-01T00:00:01.000Z', + items: [ + { + id: 'item-user', + type: 'user_message', + payload: { text: 'prior question' }, + completedAt: '2026-08-01T00:00:00.000Z', + }, + { + id: 'item-assistant', + type: 'assistant_message', + payload: { message: priorAssistant }, + completedAt: '2026-08-01T00:00:01.000Z', + }, + ], + }, + ], +}; + +class StreamingProvider implements Provider { + readonly name = 'streaming-test'; + seenMessages: ProviderRunOpts['messages'] = []; + + async runTurn(options: ProviderRunOpts): Promise { + this.seenMessages = options.messages; + options.handlers?.onTextDelta?.('new '); + options.handlers?.onTextDelta?.('answer'); + return { + content: [{ type: 'text', text: 'new answer' }], + stopReason: 'end_turn', + usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + } +} + +describe('RuntimeHostExecutor', () => { + it('reconstructs history and returns only messages created by the new turn', async () => { + const provider = new StreamingProvider(); + const host = new RuntimeHost({ + provider, + tools: new ToolRegistry(), + cwd: '/workspace', + }); + const executor = new RuntimeHostExecutor({ createHost: () => host }); + const turn: TurnSnapshot = { + id: 'turn-current', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }; + const deltas: string[] = []; + + const result = await executor.execute({ + thread, + turn, + input: { text: 'current question' }, + signal: new AbortController().signal, + publishDelta: (_itemId, delta) => deltas.push(delta), + }); + + expect(provider.seenMessages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'prior question' }] }, + priorAssistant, + expect.objectContaining({ + role: 'user', + content: [{ type: 'text', text: 'current question' }], + }), + ]); + expect(deltas).toEqual(['new ', 'answer']); + expect(result).toEqual({ + status: 'completed', + items: [ + { + type: 'assistant_message', + payload: { + message: expect.objectContaining({ + role: 'assistant', + content: [{ type: 'text', text: 'new answer' }], + }), + }, + }, + ], + }); + }); + + it('ignores non-message protocol items when rebuilding provider history', () => { + const withError: ThreadSnapshot = { + ...thread, + turns: [ + { + ...thread.turns[0]!, + items: [ + ...thread.turns[0]!.items, + { + id: 'item-error', + type: 'error', + payload: { message: 'transport failed' }, + completedAt: '2026-08-01T00:00:01.000Z', + }, + ], + }, + ], + }; + + expect(historyFromThread(withError)).toHaveLength(2); + }); +}); diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts new file mode 100644 index 0000000..ebf51d8 --- /dev/null +++ b/apps/server/src/runtime-executor.ts @@ -0,0 +1,104 @@ +import { type AgentEvent, type RuntimeHost, type StoredMessage } from '@deepcode/core'; +import type { CompletedItem, ThreadSnapshot } from '@deepcode/protocol'; + +import type { TurnExecutionArgs, TurnExecutionItem, TurnExecutor } from './server.js'; + +export interface RuntimeHostExecutorOptions { + createHost: (cwd: string) => Promise | RuntimeHost; + systemPrompt?: string; + model?: string; +} + +const DEFAULT_SYSTEM_PROMPT = + 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise and accurate.'; + +export class RuntimeHostExecutor implements TurnExecutor { + constructor(private readonly options: RuntimeHostExecutorOptions) {} + + async execute(args: TurnExecutionArgs) { + const host = await this.options.createHost(args.thread.cwd); + const history = historyFromThread(args.thread); + const baselineLength = history.length; + const text = typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input); + const streamingItemId = `${args.turn.id}-assistant`; + const events: AgentEvent[] = []; + const result = await host.run({ + cwd: args.thread.cwd, + systemPrompt: this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, + userMessage: text, + history, + model: this.options.model ?? 'deepseek-chat', + signal: args.signal, + systemReminders: false, + approval: async () => false, + onEvent: (event) => { + events.push(event); + if (event.type === 'text_delta') args.publishDelta(streamingItemId, event.text); + }, + }); + + const newMessages = result.history.slice(baselineLength); + const items = completedItemsFromMessages(newMessages, text); + if (result.stopReason === 'error') { + const error = [...events].reverse().find((event) => event.type === 'error'); + if (error?.type === 'error') items.push({ type: 'error', payload: { message: error.error } }); + } + return { + items, + status: result.stopReason === 'error' ? ('failed' as const) : ('completed' as const), + }; + } +} + +export function historyFromThread(thread: ThreadSnapshot): StoredMessage[] { + const history: StoredMessage[] = []; + for (const turn of thread.turns) { + for (const item of turn.items) { + const message = messageFromItem(item); + if (message) history.push(message); + } + } + return history; +} + +function messageFromItem(item: CompletedItem): StoredMessage | null { + if (item.type === 'user_message' && typeof item.payload.text === 'string') { + return { role: 'user', content: [{ type: 'text', text: item.payload.text }] }; + } + const message = item.payload.message; + if (!isStoredMessage(message)) return null; + return message; +} + +function completedItemsFromMessages( + messages: StoredMessage[], + inputText: string, +): TurnExecutionItem[] { + const items: TurnExecutionItem[] = []; + for (const [index, message] of messages.entries()) { + if (index === 0 && isMatchingInputMessage(message, inputText)) continue; + items.push({ + type: message.role === 'assistant' ? 'assistant_message' : 'tool_result', + payload: { message }, + }); + } + return items; +} + +function isMatchingInputMessage(message: StoredMessage, text: string): boolean { + return ( + message.role === 'user' && + message.content.length === 1 && + message.content[0]?.type === 'text' && + message.content[0].text === text + ); +} + +function isStoredMessage(value: unknown): value is StoredMessage { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return ( + (candidate.role === 'user' || candidate.role === 'assistant') && + Array.isArray(candidate.content) + ); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts new file mode 100644 index 0000000..dc3c5ad --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ProtocolEvent, ProtocolRequest } from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AppServer, type TurnExecutor } from './server.js'; +import { FileThreadStore } from './store.js'; + +let temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.map((root) => rm(root, { recursive: true, force: true }))); + temporaryRoots = []; +}); + +function request( + id: number, + method: ProtocolRequest['method'], + params: Record = {}, +): ProtocolRequest { + return { id, method, params }; +} + +function deterministicOptions() { + let sequence = 0; + let tick = 0; + return { + now: () => `2026-08-01T00:00:0${tick++}.000Z`, + newId: (prefix: 'thread' | 'turn' | 'item') => `${prefix}-${++sequence}`, + }; +} + +describe('AppServer', () => { + it('routes initialization and thread lifecycle requests', async () => { + const server = new AppServer({ + executor: { execute: async () => ({}) }, + ...deterministicOptions(), + }); + + await expect(server.handle(request(1, 'initialize'))).resolves.toEqual({ + id: 1, + result: expect.objectContaining({ protocolVersion: 1 }), + }); + const started = await server.handle(request(2, 'thread/start', { cwd: '/workspace' })); + expect(started).toEqual({ + id: 2, + result: expect.objectContaining({ id: 'thread-1', cwd: '/workspace' }), + }); + await expect( + server.handle(request(3, 'thread/read', { threadId: 'thread-1' })), + ).resolves.toEqual(started.id === 2 ? { id: 3, result: started.result } : undefined); + }); + + it('persists completed items and terminal state while publishing deltas transiently', async () => { + const events: ProtocolEvent[] = []; + const executor: TurnExecutor = { + execute: async ({ publishDelta }) => { + publishDelta('assistant-stream', 'hel'); + return { + items: [{ type: 'assistant_message', payload: { text: 'hello' } }], + }; + }, + }; + const server = new AppServer({ + executor, + onEvent: (event) => events.push(event), + ...deterministicOptions(), + }); + await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const started = await server.handle( + request(2, 'turn/start', { threadId: 'thread-1', input: { text: 'hello' } }), + ); + expect(started).toEqual({ + id: 2, + result: expect.objectContaining({ id: 'turn-3', status: 'in_progress' }), + }); + await server.waitForIdle(); + + const read = await server.handle(request(3, 'thread/read', { threadId: 'thread-1' })); + expect(read).toEqual({ + id: 3, + result: expect.objectContaining({ + turns: [ + expect.objectContaining({ + status: 'completed', + items: [ + expect.objectContaining({ type: 'user_message' }), + expect.objectContaining({ type: 'assistant_message', payload: { text: 'hello' } }), + ], + }), + ], + }), + }); + expect(events.map((event) => event.type)).toContain('item.delta'); + expect((read.result as { turns: Array<{ items: unknown[] }> }).turns[0]?.items).toHaveLength(2); + }); + + it('interrupts the actual executor and emits one terminal event', async () => { + const events: ProtocolEvent[] = []; + let observedAbort!: () => void; + const aborted = new Promise((resolve) => { + observedAbort = resolve; + }); + const executor: TurnExecutor = { + execute: async ({ signal }) => { + await new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + observedAbort(); + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ); + }); + }, + }; + const server = new AppServer({ + executor, + onEvent: (event) => events.push(event), + ...deterministicOptions(), + }); + await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + await server.handle( + request(2, 'turn/start', { threadId: 'thread-1', input: { text: 'wait' } }), + ); + + await expect( + server.handle(request(3, 'turn/interrupt', { threadId: 'thread-1', turnId: 'turn-3' })), + ).resolves.toEqual({ id: 3, result: { interrupted: true } }); + await aborted; + await server.waitForIdle(); + expect(events.filter((event) => event.type === 'turn.interrupted')).toHaveLength(1); + expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(0); + }); + + it('marks an orphaned active turn interrupted when a new process resumes it', async () => { + const root = await mkdtemp(join(tmpdir(), 'deepcode-app-server-')); + temporaryRoots.push(root); + const store = new FileThreadStore(root); + const first = new AppServer({ + store, + executor: { execute: () => new Promise(() => {}) }, + ...deterministicOptions(), + }); + await first.handle(request(1, 'thread/start', { cwd: '/workspace' })); + await first.handle( + request(2, 'turn/start', { threadId: 'thread-1', input: { text: 'unfinished' } }), + ); + + const restarted = new AppServer({ store, executor: { execute: async () => ({}) } }); + const response = await restarted.handle(request(3, 'thread/resume', { threadId: 'thread-1' })); + expect(response).toEqual({ + id: 3, + result: expect.objectContaining({ + turns: [expect.objectContaining({ status: 'interrupted' })], + }), + }); + }); + + it('returns structured errors for invalid requests', async () => { + const server = new AppServer({ executor: { execute: async () => ({}) } }); + await expect(server.handle(request(1, 'thread/start'))).resolves.toEqual({ + id: 1, + error: { code: 'invalid_request', message: 'cwd is required' }, + }); + await expect( + server.handle(request(2, 'thread/read', { threadId: '../credentials' })), + ).resolves.toEqual({ + id: 2, + error: { code: 'invalid_request', message: 'threadId is invalid' }, + }); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts new file mode 100644 index 0000000..9a6c4e8 --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,242 @@ +import { + MemoryThreadStore, + ProtocolInvariantError, + ProtocolRuntime, + type CompletedItemType, + type ProtocolEvent, + type ProtocolRequest, + type ProtocolResponse, + type ThreadSnapshot, + type ThreadStore, + type TurnSnapshot, +} from '@deepcode/protocol'; + +export interface TurnExecutionItem { + type: CompletedItemType; + payload: Record; +} + +export interface TurnExecutionResult { + items?: TurnExecutionItem[]; + status?: 'completed' | 'failed'; +} + +export interface TurnExecutionArgs { + thread: ThreadSnapshot; + turn: TurnSnapshot; + input: Record; + signal: AbortSignal; + publishDelta: (itemId: string, delta: string) => void; +} + +export interface TurnExecutor { + execute(args: TurnExecutionArgs): Promise; +} + +export interface AppServerOptions { + executor: TurnExecutor; + store?: ThreadStore; + now?: () => string; + newId?: (prefix: 'thread' | 'turn' | 'item') => string; + onEvent?: (event: ProtocolEvent) => void; +} + +interface ActiveTurn { + threadId: string; + controller: AbortController; + task: Promise; +} + +class RequestValidationError extends Error {} + +export class AppServer { + private readonly lifecycle: ProtocolRuntime; + private readonly activeTurns = new Map(); + private readonly terminalTransitions = new Map>(); + + constructor(private readonly options: AppServerOptions) { + this.lifecycle = new ProtocolRuntime({ + store: options.store ?? new MemoryThreadStore(), + now: options.now, + newId: options.newId, + onEvent: options.onEvent, + }); + } + + async handle(request: ProtocolRequest): Promise { + try { + return { id: request.id, result: await this.dispatch(request) }; + } catch (error) { + const code = + error instanceof ProtocolInvariantError + ? 'invalid_state' + : error instanceof RequestValidationError + ? 'invalid_request' + : 'internal_error'; + return { + id: request.id, + error: { + code, + message: (error as Error).message ?? String(error), + }, + }; + } + } + + async waitForIdle(): Promise { + await Promise.all([...this.activeTurns.values()].map(({ task }) => task)); + } + + async shutdown(): Promise { + const active = [...this.activeTurns.entries()]; + await Promise.all( + active.map(async ([turnId, turn]) => { + turn.controller.abort(); + await this.finishOnce(turnId, () => this.lifecycle.interruptTurn(turn.threadId, turnId)); + }), + ); + await Promise.allSettled(active.map(([, { task }]) => task)); + } + + private async dispatch(request: ProtocolRequest): Promise { + switch (request.method) { + case 'initialize': + return this.lifecycle.initialize(); + case 'thread/start': + return this.lifecycle.startThread(requiredString(request.params, 'cwd')); + case 'thread/read': + return this.lifecycle.readThread(requiredId(request.params, 'threadId')); + case 'thread/resume': + return this.resumeThread(requiredId(request.params, 'threadId')); + case 'turn/start': + return this.startTurn(request.params); + case 'turn/interrupt': + return this.interruptTurn(request.params); + } + } + + private async resumeThread(threadId: string): Promise { + let thread = await this.lifecycle.resumeThread(threadId); + const orphaned = thread.turns.find( + (turn) => turn.status === 'in_progress' && !this.activeTurns.has(turn.id), + ); + if (orphaned) { + await this.lifecycle.interruptTurn(threadId, orphaned.id); + thread = await this.lifecycle.resumeThread(threadId); + } + return thread; + } + + private async startTurn(params: Record): Promise { + const threadId = requiredId(params, 'threadId'); + const input = requiredRecord(params, 'input'); + const thread = await this.lifecycle.resumeThread(threadId); + const turn = await this.lifecycle.startTurn(threadId, input); + const controller = new AbortController(); + const task = this.executeTurn(thread, turn, input, controller); + this.activeTurns.set(turn.id, { threadId, controller, task }); + return turn; + } + + private async interruptTurn(params: Record): Promise<{ interrupted: boolean }> { + const threadId = requiredId(params, 'threadId'); + const turnId = requiredId(params, 'turnId'); + const active = this.activeTurns.get(turnId); + if (!active) return { interrupted: false }; + if (active.threadId !== threadId) + throw new RequestValidationError(`Turn ${turnId} does not belong to ${threadId}`); + active.controller.abort(); + const terminal = await this.finishOnce(turnId, () => + this.lifecycle.interruptTurn(threadId, turnId), + ); + return { interrupted: terminal.status === 'interrupted' }; + } + + private async executeTurn( + thread: ThreadSnapshot, + turn: TurnSnapshot, + input: Record, + controller: AbortController, + ): Promise { + try { + const result = await this.options.executor.execute({ + thread, + turn, + input, + signal: controller.signal, + publishDelta: (itemId, delta) => { + this.lifecycle.publishDelta({ + threadId: thread.id, + turnId: turn.id, + itemId, + delta, + }); + }, + }); + if (controller.signal.aborted) { + await this.finishOnce(turn.id, () => this.lifecycle.interruptTurn(thread.id, turn.id)); + return; + } + for (const item of result.items ?? []) { + await this.lifecycle.appendCompletedItem(thread.id, turn.id, item.type, item.payload); + } + if (result.status === 'failed') { + await this.finishOnce(turn.id, () => this.lifecycle.failTurn(thread.id, turn.id)); + } else { + await this.finishOnce(turn.id, () => this.lifecycle.completeTurn(thread.id, turn.id)); + } + } catch (error) { + if (controller.signal.aborted || (error as Error).name === 'AbortError') { + await this.finishOnce(turn.id, () => this.lifecycle.interruptTurn(thread.id, turn.id)); + } else { + await this.lifecycle.appendCompletedItem(thread.id, turn.id, 'error', { + message: (error as Error).message ?? String(error), + }); + await this.finishOnce(turn.id, () => this.lifecycle.failTurn(thread.id, turn.id)); + } + } finally { + this.activeTurns.delete(turn.id); + } + } + + private finishOnce( + turnId: string, + transition: () => Promise, + ): Promise { + const existing = this.terminalTransitions.get(turnId); + if (existing) return existing; + const pending = transition(); + this.terminalTransitions.set(turnId, pending); + const cleanup = () => { + if (this.terminalTransitions.get(turnId) === pending) { + this.terminalTransitions.delete(turnId); + } + }; + void pending.then(cleanup, cleanup); + return pending; + } +} + +function requiredString(params: Record, key: string): string { + const value = params[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new RequestValidationError(`${key} is required`); + } + return value; +} + +function requiredId(params: Record, key: string): string { + const value = requiredString(params, key); + if (!/^[a-zA-Z0-9._-]+$/.test(value)) { + throw new RequestValidationError(`${key} is invalid`); + } + return value; +} + +function requiredRecord(params: Record, key: string): Record { + const value = params[key]; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new RequestValidationError(`${key} must be an object`); + } + return value as Record; +} diff --git a/apps/server/src/stdio.test.ts b/apps/server/src/stdio.test.ts new file mode 100644 index 0000000..54d4da9 --- /dev/null +++ b/apps/server/src/stdio.test.ts @@ -0,0 +1,105 @@ +import { PassThrough, Writable } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { AppServer } from './server.js'; +import { ProtocolLineWriter, serveStdio } from './stdio.js'; + +describe('stdio transport', () => { + it('continues after malformed input and writes one response per valid request', async () => { + const input = new PassThrough(); + let output = ''; + const writer = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + }, + }); + const server = new AppServer({ executor: { execute: async () => ({}) } }); + const serving = serveStdio(server, input, writer); + + input.end('not-json\n{"id":1,"method":"initialize","params":{}}\n'); + await serving; + + const messages = output + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect(messages).toEqual([ + { id: null, error: { code: 'parse_error', message: expect.any(String) } }, + { id: 1, result: expect.objectContaining({ protocolVersion: 1 }) }, + ]); + }); + + it('honors writable backpressure and drops only excess transient deltas', async () => { + let output = ''; + let release!: () => void; + const destination = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + output += chunk.toString(); + release = callback; + }, + }); + const writer = new ProtocolLineWriter(destination, 1); + const durable = writer.enqueue({ id: 1, result: { ok: true } }); + await Promise.resolve(); + await writer.enqueue({ + method: 'event', + params: { + type: 'item.delta', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + delta: 'drop under pressure', + }, + }); + release(); + await durable; + await writer.flush(); + + expect(output.trim()).toBe('{"id":1,"result":{"ok":true}}'); + }); + + it('interrupts active work when the single owning client disconnects', async () => { + const input = new PassThrough(); + const output = new Writable({ write: (_chunk, _encoding, callback) => callback() }); + let aborted = false; + let sequence = 0; + const server = new AppServer({ + newId: (prefix) => `${prefix}-${++sequence}`, + executor: { + execute: ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + aborted = true; + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ); + }), + }, + }); + const serving = serveStdio(server, input, output); + input.end( + '{"id":1,"method":"thread/start","params":{"cwd":"/workspace"}}\n' + + '{"id":2,"method":"turn/start","params":{"threadId":"thread-1","input":{"text":"wait"}}}\n', + ); + + await serving; + expect(aborted).toBe(true); + const read = await server.handle({ + id: 3, + method: 'thread/read', + params: { threadId: 'thread-1' }, + }); + expect(read).toEqual({ + id: 3, + result: expect.objectContaining({ + turns: [expect.objectContaining({ status: 'interrupted' })], + }), + }); + }); +}); diff --git a/apps/server/src/stdio.ts b/apps/server/src/stdio.ts new file mode 100644 index 0000000..0503548 --- /dev/null +++ b/apps/server/src/stdio.ts @@ -0,0 +1,81 @@ +import { createInterface } from 'node:readline'; +import type { Readable, Writable } from 'node:stream'; +import { once } from 'node:events'; + +import { + decodeProtocolRequest, + encodeProtocolMessage, + type ProtocolNotification, + type ProtocolRequest, + type ProtocolResponse, +} from '@deepcode/protocol'; + +import type { AppServer } from './server.js'; + +type OutboundMessage = ProtocolResponse | ProtocolNotification; + +export class ProtocolLineWriter { + private tail = Promise.resolve(); + private failure: unknown; + private pending = 0; + + constructor( + private readonly output: Writable, + private readonly maxPending = 1024, + ) {} + + enqueue(message: OutboundMessage): Promise { + if (this.pending >= this.maxPending && isTransientDelta(message)) { + return Promise.resolve(); + } + this.pending++; + const task = this.tail.then(async () => { + if (this.failure) throw this.failure; + const accepted = this.output.write(`${encodeProtocolMessage(message)}\n`); + if (!accepted) await once(this.output, 'drain'); + }); + this.tail = task.catch((error) => { + this.failure = error; + }); + void task.then( + () => this.pending--, + () => this.pending--, + ); + return task; + } + + async flush(): Promise { + await this.tail; + if (this.failure) throw this.failure; + } +} + +export async function serveStdio( + server: AppServer, + input: Readable, + destination: Writable | ProtocolLineWriter, +): Promise { + const writer = + destination instanceof ProtocolLineWriter ? destination : new ProtocolLineWriter(destination); + const lines = createInterface({ input, crlfDelay: Infinity }); + for await (const line of lines) { + if (!line.trim()) continue; + let request: ProtocolRequest; + try { + request = decodeProtocolRequest(line); + } catch (error) { + await writer.enqueue({ + id: null, + error: { code: 'parse_error', message: (error as Error).message ?? String(error) }, + }); + continue; + } + await writer.enqueue(await server.handle(request)); + } + await server.shutdown(); + await writer.flush(); +} + +function isTransientDelta(message: OutboundMessage): boolean { + return 'method' in message && message.method === 'event' && message.params.type === 'item.delta'; +} diff --git a/apps/server/src/store.ts b/apps/server/src/store.ts new file mode 100644 index 0000000..9235b1b --- /dev/null +++ b/apps/server/src/store.ts @@ -0,0 +1,38 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import process from 'node:process'; + +import type { ThreadSnapshot, ThreadStore } from '@deepcode/protocol'; + +function validThreadId(threadId: string): boolean { + return /^[a-zA-Z0-9._-]+$/.test(threadId); +} + +export class FileThreadStore implements ThreadStore { + private sequence = 0; + + constructor(readonly directory: string) {} + + async load(threadId: string): Promise { + const path = this.pathFor(threadId); + try { + return JSON.parse(await readFile(path, 'utf8')) as ThreadSnapshot; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + } + + async save(thread: ThreadSnapshot): Promise { + const path = this.pathFor(thread.id); + await mkdir(this.directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${++this.sequence}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(thread)}\n`, { mode: 0o600 }); + await rename(temporaryPath, path); + } + + private pathFor(threadId: string): string { + if (!validThreadId(threadId)) throw new Error(`Invalid thread id: ${threadId}`); + return join(this.directory, `${threadId}.json`); + } +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..2b3d3d0 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "references": [{ "path": "../../packages/core" }, { "path": "../../packages/protocol" }] +} diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..41f954b --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { environment: 'node', include: ['src/**/*.test.ts'] }, +}); diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md new file mode 100644 index 0000000..d4e7501 --- /dev/null +++ b/docs/design/app-server-v1.md @@ -0,0 +1,84 @@ +# Experimental app-server vertical slice + +Status: experimental +Transport: line-delimited JSON over stdio +Implementation: `apps/server` + +## Ownership model + +Version 1 has one app-server process and one owning client. It is not a daemon and does not allow a +second client to attach to an active turn. The client keeps stdin open for the lifetime of the +server. EOF means the owner disconnected: the server aborts every active executor, persists each +turn as `interrupted`, waits for cancellation to settle, and exits. + +This boundary is suitable for the Tauri-supervised sidecar selected in ADR 0001. A future shared +daemon requires a separate authenticated socket and subscription design. + +## Framing + +Each request and response occupies one UTF-8 JSON line. Events use a notification envelope: + +```json +{ "method": "event", "params": { "type": "turn.completed", "threadId": "thread-1", "turn": {} } } +``` + +Malformed lines receive a `parse_error` response with a null id; the server continues reading the +connection. Request validation and lifecycle invariant errors use string error codes because the +protocol is still experimental. + +The writer observes Node stream backpressure. If its bounded queue is saturated, it may drop only +`item.delta` notifications; durable lifecycle and completed-item events are never intentionally +dropped. Reconnecting clients recover completed state through `thread/read` or `thread/resume`, not +by expecting partial deltas to replay. + +## Methods + +| Method | Required parameters | Result | +| ---------------- | -------------------------- | --------------------------------------- | +| `initialize` | none | version and capabilities | +| `thread/start` | `cwd` | new thread snapshot | +| `thread/read` | `threadId` | thread snapshot or null | +| `thread/resume` | `threadId` | resumable snapshot | +| `turn/start` | `threadId`, object `input` | in-progress turn snapshot | +| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race | + +`turn/start` returns before model work finishes. The server emits transient deltas while the turn +runs, then persists new provider-history messages as completed items before emitting exactly one +terminal turn event. + +If a process crashes after persisting an in-progress turn, the next `thread/resume` marks that +orphaned turn interrupted. Version 1 does not attempt to resurrect an unknown provider request or +tool process after a crash. + +## Storage and security + +The Node-specific `FileThreadStore` writes one mode-0600 JSON snapshot per thread through a +same-directory temporary file and atomic rename. The app-server CLI stores these under +`~/.deepcode/threads-v1` by default. This is the protocol rollout store; canonical session-v1 files +remain readable compatibility data until the client migration joins their indexes. + +`RuntimeHostExecutor` reconstructs exact stored provider messages from completed protocol items. +The default server runtime resolves credentials only in the trusted backend, uses the central +`RuntimeHost`, and denies interactive approvals because version 1 has not yet added an approval +request/response method. Consequently write or shell actions requiring approval fail closed. + +## Entrypoints + +After `pnpm build`, either command starts the same handler: + +```bash +node apps/server/dist/cli.js +node apps/cli/dist/cli.js app-server +``` + +The second form is exposed as `deepcode app-server` in packaged CLI builds. Existing REPL and +headless output contracts remain unchanged during this experimental phase. + +## Deferred from this slice + +- approval and ask-user server requests; +- config provenance and per-turn model/effort options; +- thread listing, archive, fork, and search; +- multi-client subscriptions or active-turn attachment; +- joining the new thread snapshot index with legacy/canonical session listings; +- a production-bundled CommonJS app-server artifact and pinned Node 22 runtime. diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts index 456504c..bd9e9bd 100644 --- a/packages/protocol/src/codec.test.ts +++ b/packages/protocol/src/codec.test.ts @@ -16,6 +16,23 @@ describe('protocol codec', () => { expect(decodeProtocolRequest(encodeProtocolMessage(request))).toEqual(request); }); + it('encodes event notifications without a request id', () => { + expect( + encodeProtocolMessage({ + method: 'event', + params: { + type: 'item.delta', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + delta: 'hello', + }, + }), + ).toBe( + '{"method":"event","params":{"type":"item.delta","threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"hello"}}', + ); + }); + it.each(['{}', '{"id":1,"method":"unknown"}', '{"id":1,"method":"initialize","params":[]}'])( 'rejects an invalid request: %s', (raw) => { diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index 58c18d6..23191dc 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -1,4 +1,9 @@ -import type { ProtocolMethod, ProtocolRequest, ProtocolResponse } from './types.js'; +import type { + ProtocolMethod, + ProtocolNotification, + ProtocolRequest, + ProtocolResponse, +} from './types.js'; const protocolMethods = new Set([ 'initialize', @@ -9,7 +14,9 @@ const protocolMethods = new Set([ 'turn/interrupt', ]); -export function encodeProtocolMessage(message: ProtocolRequest | ProtocolResponse): string { +export function encodeProtocolMessage( + message: ProtocolRequest | ProtocolResponse | ProtocolNotification, +): string { return JSON.stringify(message); } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 9c1c99a..5ae796c 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -77,7 +77,12 @@ export interface ProtocolRequest { } export interface ProtocolResponse { - id: string | number; + id: string | number | null; result?: unknown; error?: { code: string; message: string }; } + +export interface ProtocolNotification { + method: 'event'; + params: ProtocolEvent; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b25967a..740b5ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: apps/cli: dependencies: + '@deepcode/app-server': + specifier: workspace:* + version: link:../server '@deepcode/core': specifier: workspace:* version: link:../../packages/core @@ -129,6 +132,25 @@ importers: specifier: ^2.1.9 version: 2.1.9(@types/node@22.19.19) + apps/server: + dependencies: + '@deepcode/core': + specifier: workspace:* + version: link:../../packages/core + '@deepcode/protocol': + specifier: workspace:* + version: link:../../packages/protocol + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.19) + apps/vscode: dependencies: '@deepcode/core': @@ -167,6 +189,15 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@22.19.19) + packages/protocol: + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) + packages/shared-ui: devDependencies: typescript: diff --git a/tsconfig.json b/tsconfig.json index e448224..d3aba50 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./apps/cli" }, { "path": "./apps/desktop" }, { "path": "./apps/lsp" }, + { "path": "./apps/server" }, { "path": "./apps/vscode" } ] } From bd151b8431b9336a6485cf9391f04630c2a7dfb6 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 14:13:00 +0800 Subject: [PATCH 09/23] chore: sync protocol workspace lockfile --- pnpm-lock.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b25967a..7d23aa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,15 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@22.19.19) + packages/protocol: + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) + packages/shared-ui: devDependencies: typescript: From 0ae2f5cbf5f4f44e515711ed7ba3d52ffeac8cad Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 14:29:40 +0800 Subject: [PATCH 10/23] feat: package and supervise desktop app server --- .github/workflows/release.yml | 13 ++ .gitignore | 2 + apps/desktop/README.md | 19 +- apps/desktop/package.json | 3 + apps/desktop/scripts/prepare-runtime.mjs | 77 ++++++++ .../src-tauri/capabilities/default.json | 1 - apps/desktop/src-tauri/src/app_server.rs | 184 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 13 +- apps/desktop/src-tauri/tauri.conf.json | 8 +- apps/desktop/src/lib/protocol-client.test.ts | 108 ++++++++++ apps/desktop/src/lib/protocol-client.ts | 143 ++++++++++++++ apps/desktop/src/lib/tauri-api.test.ts | 20 ++ apps/desktop/src/lib/tauri-api.ts | 21 ++ apps/desktop/tsconfig.json | 6 +- apps/server/package.json | 2 + apps/server/scripts/build-sidecar.mjs | 25 +++ apps/server/src/default-runtime.ts | 13 +- apps/server/src/sidecar-entry.ts | 10 + docs/CODEX_ALIGNMENT_PLAN.md | 2 + docs/adr/0001-desktop-runtime-sidecar.md | 16 ++ eslint.config.js | 1 + packages/core/package.json | 12 ++ pnpm-lock.yaml | 6 + scripts/sign-and-notarize.sh | 11 +- 24 files changed, 694 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/scripts/prepare-runtime.mjs create mode 100644 apps/desktop/src-tauri/src/app_server.rs create mode 100644 apps/desktop/src/lib/protocol-client.test.ts create mode 100644 apps/desktop/src/lib/protocol-client.ts create mode 100644 apps/server/scripts/build-sidecar.mjs create mode 100644 apps/server/src/sidecar-entry.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42eb576..21004f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,6 +116,19 @@ jobs: - name: pnpm install run: pnpm install --frozen-lockfile + - name: Prepare pinned Node sidecar runtime + env: + NODE_SIDECAR_VERSION: 22.23.1 + NODE_SIDECAR_SHA256: ef28d8fab2c0e4314522d4bb1b7173270aa3937e93b92cb7de79c112ac1fa953 + run: | + archive="node-v${NODE_SIDECAR_VERSION}-darwin-arm64.tar.xz" + curl --fail --location --retry 3 \ + "https://nodejs.org/dist/v${NODE_SIDECAR_VERSION}/${archive}" \ + --output "$RUNNER_TEMP/$archive" + echo "${NODE_SIDECAR_SHA256} $RUNNER_TEMP/$archive" | shasum -a 256 --check + tar -xJf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" + echo "DEEPCODE_NODE_RUNTIME=$RUNNER_TEMP/node-v${NODE_SIDECAR_VERSION}-darwin-arm64/bin/node" >> "$GITHUB_ENV" + - name: Set version run: | cd apps/desktop diff --git a/.gitignore b/.gitignore index d124e0b..31adebe 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ apps/desktop/dist-electron/ # Tauri build outputs (Rust target dir is large) apps/desktop/src-tauri/target/ apps/desktop/src-tauri/gen/ +apps/desktop/src-tauri/binaries/ +apps/server/dist-sidecar/ # Note: Cargo.lock IS committed (best practice for applications) # Release artifacts — too large for git; CI uploads to GitHub Releases instead diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0818901..da03991 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -15,9 +15,10 @@ src/ renderer(React + Vite,无 Tailwind,手写设计系统 screens/ About / MCPManager / Onboarding / Permissions / Plugins / Repl / Sessions / Settings / Skills components/ Sidebar / InspectorRail / ToolCard / UpdateBanner … - lib/ tauri-api(renderer↔Rust IPC 封装)· mac-agent · - mac-tools · repl-stream · updater … + lib/ tauri-api(renderer↔Rust IPC 封装)· protocol-client · + mac-agent(实验期 fallback)· repl-stream · updater … src-tauri/ Rust 主进程 + src/app_server.rs bundled runtime 启停、stdio 与 crash event src/commands.rs #[tauri::command] —— renderer 通过 invoke() 调用 src/credentials.rs 凭据读写(原子写入) src/settings.rs 设置持久化 @@ -31,6 +32,11 @@ src-tauri/ Rust 主进程 renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试见 `src/lib/tauri-api.test.ts`(#84)。 +实验 app-server 由 Tauri 作为 target-specific sidecar 监督。`apps/server` 会被打成单个 +`app-server.cjs` resource,Node runtime 通过 `bundle.externalBin` 进入 `.app`;renderer 只能通过 +Rust commands 与版本化协议通信,不能直接使用 shell plugin。现有 `mac-agent` 在迁移期保留为显式 +fallback,不能作为长期双架构。 + ## 开发 依赖在 monorepo 根 `pnpm install` 一次装好;Rust 工具链 + Tauri CLI 见下。 @@ -40,19 +46,20 @@ renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试 | `pnpm dev` | 仅 Vite dev server(5173)—— 一般由 Tauri 自动拉起 | | `pnpm tauri:dev` | 完整 app:Tauri 启 dev server + 原生窗口,热重载 | | `pnpm build` | `tsc -b` + `vite build` → `dist/`(renderer 产物) | -| `pnpm tauri:build` | 当前架构的 .app / .dmg | +| `pnpm tauri:build` | 构建包含 runtime + app-server 的 `.app` | | `pnpm tauri:build:universal` | universal-apple-darwin 通用二进制 | | `pnpm typecheck` | `tsc -b` | | `pnpm test` | `vitest run`(lib 单测 + IPC 契约测试) | -`tauri.conf.json` 里 `beforeDevCommand` / `beforeBuildCommand` 分别接 -`pnpm dev` / `pnpm build`,所以平时只跑 `pnpm tauri:dev` 即可。 +`tauri.conf.json` 的 dev/build hooks 会先生成 app-server bundle 和目标 runtime,再启动 Vite 或 +Tauri release build,所以平时只跑 `pnpm tauri:dev` 即可。 ### 前置工具 - Node ≥ 22、pnpm - Rust 工具链(`rustup`)—— Tauri 主进程是 Rust - 通用构建需 `rustup target add aarch64-apple-darwin x86_64-apple-darwin` +- 通用构建还要求 `DEEPCODE_NODE_RUNTIME` 指向同时含 arm64/x86_64 的通用 Node binary ## 打包 / 签名 @@ -60,5 +67,7 @@ renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试 `src-tauri/Entitlements.plist`。 - 签名 + 公证需要 Apple Developer ID 证书,以及 `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` 等环境变量(CI 走 secrets)。 +- release CI 固定 Node 22.23.1,校验官方 SHA256 后才进入 Tauri 打包;nested runtime 先签,outer + `.app` 后签,再做 strict deep verification 与 notarization。 详见 `docs/DEVELOPMENT_PLAN.md` §4 / §4a / §4b。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b26b64f..a733102 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,9 @@ "type": "module", "scripts": { "dev": "vite", + "dev:tauri": "pnpm --filter @deepcode/app-server build:sidecar && node scripts/prepare-runtime.mjs && pnpm dev", "build": "tsc -b && vite build", + "build:tauri-assets": "pnpm build && pnpm --filter @deepcode/app-server build:sidecar && node scripts/prepare-runtime.mjs", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", @@ -20,6 +22,7 @@ }, "dependencies": { "@deepcode/core": "workspace:*", + "@deepcode/protocol": "workspace:*", "@deepcode/shared-ui": "workspace:*", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", diff --git a/apps/desktop/scripts/prepare-runtime.mjs b/apps/desktop/scripts/prepare-runtime.mjs new file mode 100644 index 0000000..e99db2c --- /dev/null +++ b/apps/desktop/scripts/prepare-runtime.mjs @@ -0,0 +1,77 @@ +import { copyFile, mkdir, rename, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const target = + process.env.DEEPCODE_TARGET ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? hostTargetTriple(); +const source = process.env.DEEPCODE_NODE_RUNTIME ?? process.execPath; + +if (process.env.CI && !process.env.DEEPCODE_NODE_RUNTIME) { + throw new Error('CI desktop packaging requires a pinned DEEPCODE_NODE_RUNTIME'); +} + +const destination = resolve( + desktopRoot, + 'src-tauri', + 'binaries', + `deepcode-runtime-${target}${target.includes('windows') ? '.exe' : ''}`, +); +await mkdir(dirname(destination), { recursive: true }); +await copyFile(source, destination); + +let thinned = false; +if (process.platform === 'darwin' && target.endsWith('apple-darwin')) { + const architectures = spawnSync('/usr/bin/lipo', ['-archs', destination]); + if (architectures.status !== 0) { + throw new Error(`unable to inspect Node runtime architecture: ${architectures.stderr.toString()}`); + } + const availableArchitectures = architectures.stdout.toString().trim().split(/\s+/); + if (target.startsWith('universal')) { + if (!availableArchitectures.includes('arm64') || !availableArchitectures.includes('x86_64')) { + throw new Error('universal desktop target requires a universal Node runtime'); + } + } else { + const architecture = target.startsWith('aarch64') ? 'arm64' : 'x86_64'; + if (!availableArchitectures.includes(architecture)) { + throw new Error( + `desktop target ${target} requires ${architecture}, but Node runtime contains ${availableArchitectures.join(', ')}`, + ); + } + const thinPath = `${destination}.thin`; + const thin = spawnSync('/usr/bin/lipo', [ + destination, + '-thin', + architecture, + '-output', + thinPath, + ]); + if (thin.status === 0) { + await rename(thinPath, destination); + thinned = true; + } + } + const strip = spawnSync('/usr/bin/strip', ['-S', destination]); + if (strip.status !== 0) throw new Error(`strip failed: ${strip.stderr.toString()}`); + const sign = spawnSync('/usr/bin/codesign', ['--force', '--sign', '-', destination]); + if (sign.status !== 0) throw new Error(`ad-hoc signing failed: ${sign.stderr.toString()}`); +} + +process.stdout.write( + `${JSON.stringify({ target, source, destination, bytes: (await stat(destination)).size, thinned })}\n`, +); + +function hostTargetTriple() { + if (process.platform === 'darwin') { + return `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-apple-darwin`; + } + if (process.platform === 'linux') { + return `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-unknown-linux-gnu`; + } + if (process.platform === 'win32') { + return `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-pc-windows-msvc`; + } + throw new Error(`Unsupported desktop sidecar host: ${process.platform}-${process.arch}`); +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index b23265a..2180f48 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -13,7 +13,6 @@ "dialog:default", "fs:default", "opener:default", - "shell:default", "updater:default", "process:default", "process:allow-restart" diff --git a/apps/desktop/src-tauri/src/app_server.rs b/apps/desktop/src-tauri/src/app_server.rs new file mode 100644 index 0000000..c5012cf --- /dev/null +++ b/apps/desktop/src-tauri/src/app_server.rs @@ -0,0 +1,184 @@ +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{path::BaseDirectory, AppHandle, Emitter, Manager, State}; +use tauri_plugin_shell::{ + process::{CommandChild, CommandEvent}, + ShellExt, +}; + +struct ManagedChild { + pid: u32, + child: CommandChild, +} + +#[derive(Default)] +pub struct AppServerState { + child: Mutex>, +} + +impl Drop for AppServerState { + fn drop(&mut self) { + if let Ok(slot) = self.child.get_mut() { + if let Some(managed) = slot.take() { + let _ = managed.child.kill(); + } + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppServerStatus { + running: bool, + pid: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AppServerOutput { + stream: &'static str, + line: String, + code: Option, + signal: Option, +} + +#[tauri::command] +pub fn app_server_start( + app: AppHandle, + state: State<'_, AppServerState>, +) -> Result { + let mut slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + if let Some(managed) = slot.as_ref() { + return Ok(AppServerStatus { + running: true, + pid: Some(managed.pid), + }); + } + + let script = app + .path() + .resolve("app-server.cjs", BaseDirectory::Resource) + .map_err(|error| format!("resolve app-server resource: {error}"))?; + let (mut receiver, child) = app + .shell() + .sidecar("deepcode-runtime") + .map_err(|error| format!("resolve bundled runtime: {error}"))? + .arg(script) + .spawn() + .map_err(|error| format!("start app-server: {error}"))?; + let pid = child.pid(); + *slot = Some(ManagedChild { pid, child }); + drop(slot); + + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = receiver.recv().await { + let (stream, line, code, signal, terminated) = match event { + CommandEvent::Stdout(bytes) => ( + "stdout", + String::from_utf8_lossy(&bytes).into_owned(), + None, + None, + false, + ), + CommandEvent::Stderr(bytes) => ( + "stderr", + String::from_utf8_lossy(&bytes).into_owned(), + None, + None, + false, + ), + CommandEvent::Error(error) => ("error", error, None, None, false), + CommandEvent::Terminated(payload) => ( + "terminated", + String::new(), + payload.code, + payload.signal, + true, + ), + _ => continue, + }; + let _ = handle.emit( + "app-server-output", + AppServerOutput { + stream, + line, + code, + signal, + }, + ); + if terminated { + if let Ok(mut current) = handle.state::().child.lock() { + if current.as_ref().is_some_and(|managed| managed.pid == pid) { + current.take(); + } + } + } + } + }); + + Ok(AppServerStatus { + running: true, + pid: Some(pid), + }) +} + +#[tauri::command] +pub fn app_server_send(state: State<'_, AppServerState>, message: String) -> Result<(), String> { + validate_request_line(&message)?; + let mut slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + let managed = slot + .as_mut() + .ok_or_else(|| "app-server is not running".to_string())?; + managed + .child + .write(format!("{message}\n").as_bytes()) + .map_err(|error| format!("write app-server request: {error}")) +} + +#[tauri::command] +pub fn app_server_stop(state: State<'_, AppServerState>) -> Result<(), String> { + let mut slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + if let Some(managed) = slot.take() { + managed + .child + .kill() + .map_err(|error| format!("stop app-server: {error}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn app_server_status(state: State<'_, AppServerState>) -> Result { + let slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + Ok(AppServerStatus { + running: slot.is_some(), + pid: slot.as_ref().map(|managed| managed.pid), + }) +} + +fn validate_request_line(message: &str) -> Result<(), String> { + if message.contains(['\n', '\r']) { + return Err("app-server request must be one line".to_string()); + } + let value: serde_json::Value = serde_json::from_str(message) + .map_err(|error| format!("invalid app-server JSON: {error}"))?; + if !value.is_object() { + return Err("app-server request must be a JSON object".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::validate_request_line; + + #[test] + fn request_line_must_be_one_json_object() { + assert!(validate_request_line(r#"{"id":1,"method":"initialize","params":{}}"#).is_ok()); + assert!(validate_request_line("{}\n{}").is_err()); + assert!(validate_request_line("not-json").is_err()); + assert!(validate_request_line("[]").is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ed42f40..fcf92c0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -6,9 +6,10 @@ // commands that the frontend can't do (file dialogs, credentials read/write, // settings file IO, child-process spawn for CLI integration). // -// The agent loop itself runs in the renderer via @deepcode/core — no Node -// runtime in main process means smaller binary + faster startup. +// During the protocol rollout, Rust also supervises the bundled app-server +// sidecar. The renderer loop remains only as an explicit compatibility path. +mod app_server; mod commands; mod credentials; mod settings; @@ -16,6 +17,9 @@ mod snapshots; mod tools; mod voice; +use app_server::{ + app_server_send, app_server_start, app_server_status, app_server_stop, AppServerState, +}; use commands::{ append_allow_matcher, cli_path, get_app_info, get_settings_path, list_plugins, list_sessions, list_skills, load_keybindings, load_settings_file, open_url, read_credentials, @@ -40,8 +44,13 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .manage(VoiceState::default()) .manage(BashState::default()) + .manage(AppServerState::default()) .invoke_handler(tauri::generate_handler![ get_app_info, + app_server_start, + app_server_send, + app_server_stop, + app_server_status, read_credentials, save_credentials, load_settings_file, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index fc38f3a..bcce2ab 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -6,8 +6,8 @@ "build": { "frontendDist": "../dist", "devUrl": "http://localhost:5173", - "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm build" + "beforeDevCommand": "pnpm dev:tauri", + "beforeBuildCommand": "pnpm build:tauri-assets" }, "app": { "windows": [ @@ -32,8 +32,10 @@ "active": true, "targets": ["app"], "resources": { - "../../../packages/core/skills": "skills" + "../../../packages/core/skills": "skills", + "../../server/dist-sidecar/app-server.cjs": "app-server.cjs" }, + "externalBin": ["binaries/deepcode-runtime"], "category": "public.app-category.developer-tools", "shortDescription": "DeepSeek-powered coding agent", "longDescription": "DeepCode is a Claude-Code-parity coding agent powered by DeepSeek — chat, plan mode, tool use, sandboxed bash, MCP, plugins.", diff --git a/apps/desktop/src/lib/protocol-client.test.ts b/apps/desktop/src/lib/protocol-client.test.ts new file mode 100644 index 0000000..8ff16e4 --- /dev/null +++ b/apps/desktop/src/lib/protocol-client.test.ts @@ -0,0 +1,108 @@ +import type { ProtocolRequest } from '@deepcode/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { + DesktopProtocolClient, + type AppServerOutput, + type ProtocolClientBridge, +} from './protocol-client.js'; + +class FakeBridge implements ProtocolClientBridge { + handler?: (output: AppServerOutput) => void; + started = 0; + stopped = 0; + requests: ProtocolRequest[] = []; + + async listen(handler: (output: AppServerOutput) => void) { + this.handler = handler; + return () => { + this.handler = undefined; + }; + } + + async start() { + this.started++; + } + + async send(raw: string) { + const request = JSON.parse(raw) as ProtocolRequest; + this.requests.push(request); + queueMicrotask(() => { + this.handler?.({ + stream: 'stdout', + line: JSON.stringify({ + id: request.id, + result: + request.method === 'initialize' + ? { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + }, + } + : { ok: true }, + }), + }); + }); + } + + async stop() { + this.stopped++; + } +} + +describe('DesktopProtocolClient', () => { + it('starts the supervised process and negotiates protocol v1', async () => { + const bridge = new FakeBridge(); + const client = new DesktopProtocolClient(bridge); + + await expect(client.connect()).resolves.toEqual( + expect.objectContaining({ protocolVersion: 1 }), + ); + expect(bridge.started).toBe(1); + expect(bridge.requests[0]).toEqual({ id: 1, method: 'initialize', params: {} }); + await client.close(); + expect(bridge.stopped).toBe(1); + }); + + it('routes durable and transient notifications to subscribers', async () => { + const bridge = new FakeBridge(); + const client = new DesktopProtocolClient(bridge); + const subscriber = vi.fn(); + client.subscribe(subscriber); + await client.connect(); + + bridge.handler?.({ + stream: 'stdout', + line: JSON.stringify({ + method: 'event', + params: { + type: 'item.delta', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + delta: 'hello', + }, + }), + }); + + expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ type: 'item.delta' })); + await client.close(); + }); + + it('rejects pending requests when the supervised process terminates', async () => { + const bridge = new FakeBridge(); + const client = new DesktopProtocolClient(bridge, 1000); + await client.connect(); + bridge.send = async (raw) => { + bridge.requests.push(JSON.parse(raw) as ProtocolRequest); + }; + const pending = client.request('thread/read', { threadId: 'thread-1' }); + bridge.handler?.({ stream: 'terminated', line: '', code: 1 }); + + await expect(pending).rejects.toThrow('app-server terminated'); + }); +}); diff --git a/apps/desktop/src/lib/protocol-client.ts b/apps/desktop/src/lib/protocol-client.ts new file mode 100644 index 0000000..91ab30b --- /dev/null +++ b/apps/desktop/src/lib/protocol-client.ts @@ -0,0 +1,143 @@ +import { listen } from '@tauri-apps/api/event'; +import { + encodeProtocolMessage, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ProtocolRequest, + type ProtocolResponse, +} from '@deepcode/protocol'; + +import { appServerSend, appServerStart, appServerStop } from './tauri-api.js'; + +export interface AppServerOutput { + stream: 'stdout' | 'stderr' | 'error' | 'terminated'; + line: string; + code?: number; + signal?: number; +} + +export interface ProtocolClientBridge { + listen(handler: (output: AppServerOutput) => void): Promise<() => void>; + start(): Promise; + send(message: string): Promise; + stop(): Promise; +} + +const tauriBridge: ProtocolClientBridge = { + async listen(handler) { + return listen('app-server-output', (event) => handler(event.payload)); + }, + start: appServerStart, + send: appServerSend, + stop: appServerStop, +}; + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timeout: ReturnType; +} + +export class DesktopProtocolClient { + private readonly pending = new Map(); + private readonly subscribers = new Set<(event: ProtocolEvent) => void>(); + private nextId = 1; + private unlisten?: () => void; + + constructor( + private readonly bridge: ProtocolClientBridge = tauriBridge, + private readonly timeoutMs = 30_000, + ) {} + + async connect(): Promise { + if (!this.unlisten) this.unlisten = await this.bridge.listen((output) => this.receive(output)); + await this.bridge.start(); + const initialized = await this.request('initialize'); + if (initialized.protocolVersion !== 1) { + await this.close(); + throw new Error(`Unsupported app-server protocol version: ${initialized.protocolVersion}`); + } + return initialized; + } + + subscribe(handler: (event: ProtocolEvent) => void): () => void { + this.subscribers.add(handler); + return () => this.subscribers.delete(handler); + } + + async request(method: ProtocolMethod, params: Record = {}): Promise { + const id = this.nextId++; + const request: ProtocolRequest = { id, method, params }; + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`app-server request timed out: ${method}`)); + }, this.timeoutMs); + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + timeout, + }); + }); + try { + await this.bridge.send(encodeProtocolMessage(request)); + } catch (error) { + this.rejectRequest(id, error as Error); + } + return response; + } + + async close(): Promise { + this.rejectAll(new Error('app-server client closed')); + this.unlisten?.(); + this.unlisten = undefined; + await this.bridge.stop(); + } + + private receive(output: AppServerOutput): void { + if (output.stream === 'terminated') { + this.rejectAll( + new Error( + `app-server terminated (code=${output.code ?? 'none'}, signal=${output.signal ?? 'none'})`, + ), + ); + return; + } + if (output.stream !== 'stdout') return; + let message: ProtocolResponse | { method: 'event'; params: ProtocolEvent }; + try { + message = JSON.parse(output.line) as + | ProtocolResponse + | { method: 'event'; params: ProtocolEvent }; + } catch { + this.rejectAll(new Error('app-server emitted invalid JSON')); + return; + } + if ('method' in message) { + if (message.method === 'event') { + for (const subscriber of this.subscribers) subscriber(message.params); + } + return; + } + if (message.id === null || typeof message.id !== 'number') return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timeout); + if (message.error) pending.reject(new Error(`${message.error.code}: ${message.error.message}`)); + else pending.resolve(message.result); + } + + private rejectRequest(id: number, error: Error): void { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timeout); + pending.reject(error); + } + + private rejectAll(error: Error): void { + for (const id of this.pending.keys()) this.rejectRequest(id, error); + } +} diff --git a/apps/desktop/src/lib/tauri-api.test.ts b/apps/desktop/src/lib/tauri-api.test.ts index 62dc029..1b390ff 100644 --- a/apps/desktop/src/lib/tauri-api.test.ts +++ b/apps/desktop/src/lib/tauri-api.test.ts @@ -10,6 +10,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; import { + appServerSend, + appServerStart, + appServerStatus, + appServerStop, appendAllowMatcher, getAppInfo, listPlugins, @@ -73,6 +77,22 @@ describe('saveCredentials', () => { }); describe('command name + argument contracts', () => { + it('maps app-server supervision commands without exposing process details', async () => { + invokeMock.mockResolvedValue({ running: true, pid: 42 }); + await expect(appServerStart()).resolves.toEqual({ running: true, pid: 42 }); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_start'); + + await appServerSend('{"id":1,"method":"initialize","params":{}}'); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_send', { + message: '{"id":1,"method":"initialize","params":{}}', + }); + + await appServerStatus(); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_status'); + await appServerStop(); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_stop'); + }); + it('getAppInfo → get_app_info (no args)', async () => { invokeMock.mockResolvedValue({ version: '1.0.0', platform: 'darwin', home_dir: '/Users/x' }); await getAppInfo(); diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index db7c053..b5d56c7 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -17,6 +17,11 @@ export interface Credentials { baseURL?: string; } +export interface AppServerStatus { + running: boolean; + pid?: number; +} + export interface SessionMeta { id: string; path: string; @@ -30,6 +35,22 @@ export async function getAppInfo(): Promise { return invoke('get_app_info'); } +export async function appServerStart(): Promise { + return invoke('app_server_start'); +} + +export async function appServerSend(message: string): Promise { + await invoke('app_server_send', { message }); +} + +export async function appServerStop(): Promise { + await invoke('app_server_stop'); +} + +export async function appServerStatus(): Promise { + return invoke('app_server_status'); +} + export async function readCredentials(): Promise { // Backend uses snake_case Rust fields; convert. const raw = (await invoke('read_credentials')) as { diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index e84ce27..5319bfe 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -15,5 +15,9 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "dist-types", "src-tauri"], - "references": [{ "path": "../../packages/core" }, { "path": "../../packages/shared-ui" }] + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/protocol" }, + { "path": "../../packages/shared-ui" } + ] } diff --git a/apps/server/package.json b/apps/server/package.json index 5d9f707..36821da 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,6 +18,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", + "build:sidecar": "node scripts/build-sidecar.mjs", "typecheck": "tsc -b", "test": "vitest run", "lint": "echo 'lint: configured at workspace root' && exit 0", @@ -29,6 +30,7 @@ }, "devDependencies": { "@types/node": "^22.10.0", + "esbuild": "^0.21.5", "typescript": "^5.7.0", "vitest": "^2.1.9" }, diff --git a/apps/server/scripts/build-sidecar.mjs b/apps/server/scripts/build-sidecar.mjs new file mode 100644 index 0000000..03114d8 --- /dev/null +++ b/apps/server/scripts/build-sidecar.mjs @@ -0,0 +1,25 @@ +import { mkdir, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const output = resolve(packageRoot, 'dist-sidecar', 'app-server.cjs'); +await mkdir(dirname(output), { recursive: true }); +await build({ + entryPoints: [resolve(packageRoot, 'src', 'sidecar-entry.ts')], + outfile: output, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + minify: true, + sourcemap: false, + legalComments: 'none', + banner: { js: '#!/usr/bin/env node' }, +}); + +const bytes = (await stat(output)).size; +process.stdout.write(`Built ${output} (${bytes} bytes)\n`); diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 3fd8dc4..f123821 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -1,12 +1,7 @@ -import { - BUILTIN_TOOLS, - CredentialsStore, - DeepSeekProvider, - RuntimeHost, - SAFE_READONLY_TOOLS, - ToolRegistry, - resolveCredentials, -} from '@deepcode/core'; +import { CredentialsStore, resolveCredentials } from '@deepcode/core/credentials'; +import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; +import { RuntimeHost, SAFE_READONLY_TOOLS } from '@deepcode/core/runtime'; +import { BUILTIN_TOOLS, ToolRegistry } from '@deepcode/core/tools'; import { RuntimeHostExecutor } from './runtime-executor.js'; diff --git a/apps/server/src/sidecar-entry.ts b/apps/server/src/sidecar-entry.ts new file mode 100644 index 0000000..90f0536 --- /dev/null +++ b/apps/server/src/sidecar-entry.ts @@ -0,0 +1,10 @@ +import process from 'node:process'; + +import { runAppServer } from './run.js'; + +const home = process.env.DEEPCODE_HOME ?? `${process.env.HOME ?? process.cwd()}/.deepcode`; + +runAppServer({ input: process.stdin, output: process.stdout, home }).catch((error) => { + process.stderr.write(`DeepCode app-server fatal: ${(error as Error).message ?? String(error)}\n`); + process.exitCode = 1; +}); diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 8a03f43..97a45cd 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -286,6 +286,8 @@ model tool call ### PR 6 — Desktop runtime migration - 按 ADR 把 runtime 移出 renderer,移除 WebView 中的 provider/API key。 +- 已建立可构建的 CJS app-server、target runtime、Rust supervisor 与 renderer protocol client;迁移期 + `mac-agent` 仅作 feature fallback。 - React 只消费协议事件;接入真实 interrupt、恢复与 structured items。 - 把 `preview-app.html` 变成自动化 fixture harness;收敛现有 Changes/Files/Inspector。 diff --git a/docs/adr/0001-desktop-runtime-sidecar.md b/docs/adr/0001-desktop-runtime-sidecar.md index f38f9fe..29ffb6a 100644 --- a/docs/adr/0001-desktop-runtime-sidecar.md +++ b/docs/adr/0001-desktop-runtime-sidecar.md @@ -71,6 +71,22 @@ Node 24 build, whereas production will use a pinned target-specific Node 22 dist and notarized artifact cannot be verified locally without release credentials, so that remains a release-CI gate rather than a claimed spike result. +### Packaged implementation evidence + +The first implementation build on the same host adds the production-shaped artifacts: + +| Measurement | Result | +| ------------------------------------- | ---------------------- | +| Bundled CommonJS app-server | 214,155 bytes | +| Thin/stripped bundled runtime | 108,412,096 bytes | +| Complete sidecar-enabled `.app` | 115,355,648 bytes | +| Handshake from packaged paths | passed with empty PATH | +| Nested-then-outer ad-hoc verification | strict deep pass | + +The release workflow now pins Node 22.23.1 and verifies the official archive SHA256 before the +Tauri build. Local ad-hoc signing proves bundle structure and signing order only; Developer ID, +notarization, stapling, and compressed DMG size remain release gates. + ## Options considered ### Keep the agent loop in the WebView diff --git a/eslint.config.js b/eslint.config.js index 7069520..5866c29 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,6 +11,7 @@ export default [ { ignores: [ '**/dist/**', + '**/dist-sidecar/**', '**/dist-electron/**', '**/node_modules/**', '**/target/**', // Rust/Cargo build output (generated JS in src-tauri/target) diff --git a/packages/core/package.json b/packages/core/package.json index 6d5a1e1..c3038c5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,18 @@ "types": "./dist/keybindings/vim.d.ts", "import": "./dist/keybindings/vim.js" }, + "./credentials": { + "types": "./dist/credentials/index.d.ts", + "import": "./dist/credentials/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime/index.js" + }, + "./tools": { + "types": "./dist/tools/index.d.ts", + "import": "./dist/tools/index.js" + }, "./skills/*": "./skills/*", "./package.json": "./package.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 740b5ae..5e1e9b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@deepcode/core': specifier: workspace:* version: link:../../packages/core + '@deepcode/protocol': + specifier: workspace:* + version: link:../../packages/protocol '@deepcode/shared-ui': specifier: workspace:* version: link:../../packages/shared-ui @@ -144,6 +147,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.19.19 + esbuild: + specifier: ^0.21.5 + version: 0.21.5 typescript: specifier: ^5.7.0 version: 5.9.3 diff --git a/scripts/sign-and-notarize.sh b/scripts/sign-and-notarize.sh index 02ea3bf..92ab4c4 100755 --- a/scripts/sign-and-notarize.sh +++ b/scripts/sign-and-notarize.sh @@ -65,7 +65,16 @@ echo "==> Signing identity: $SIGNING_ID" # ----- 3. Re-sign the .app with hardened runtime ----- echo "==> Signing $APP_PATH ..." -codesign --force --deep --options runtime \ +SIDECAR_PATH="$APP_PATH/Contents/MacOS/deepcode-runtime" +if [ ! -x "$SIDECAR_PATH" ]; then + echo "ERROR: bundled runtime not found at $SIDECAR_PATH" + exit 1 +fi +codesign --force --options runtime \ + --sign "$SIGNING_ID" \ + --timestamp \ + "$SIDECAR_PATH" +codesign --force --options runtime \ --entitlements apps/desktop/src-tauri/Entitlements.plist \ --sign "$SIGNING_ID" \ --timestamp \ From 75d4b57e4cf6ddaa324a4a489271232f15ba219f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:05:09 +0800 Subject: [PATCH 11/23] ci: prepare desktop sidecar placeholder --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d8feb..68df73b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,15 @@ jobs: - uses: actions/checkout@v6 - name: Show Rust toolchain run: rustc --version && cargo --version + # Tauri validates every externalBin path in its build script. This job + # only compiles/tests Rust and never executes or packages the sidecar, so + # use a target-correct placeholder instead of copying a 100+ MB runtime. + - name: Prepare Tauri sidecar placeholder + run: | + target="$(rustc -vV | sed -n 's/^host: //p')" + runtime="apps/desktop/src-tauri/binaries/deepcode-runtime-${target}" + mkdir -p "$(dirname "$runtime")" + touch "$runtime" - name: Check and test Tauri backend run: | cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked From 136711984238d24d2a4d5f3c62ef99534e781c1d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:10:16 +0800 Subject: [PATCH 12/23] ci: stub desktop bundle resources for Rust checks --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68df73b..151b181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,8 @@ jobs: runtime="apps/desktop/src-tauri/binaries/deepcode-runtime-${target}" mkdir -p "$(dirname "$runtime")" touch "$runtime" + mkdir -p apps/server/dist-sidecar + touch apps/server/dist-sidecar/app-server.cjs - name: Check and test Tauri backend run: | cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked From 580f9c8decf70a31abc90e773f1bdc1ee1389f34 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 14:36:36 +0800 Subject: [PATCH 13/23] feat: add interactive app server protocol --- apps/server/README.md | 3 +- apps/server/src/default-runtime.ts | 4 +- apps/server/src/runtime-executor.test.ts | 84 +++++++++++ apps/server/src/runtime-executor.ts | 81 ++++++++++- apps/server/src/server.test.ts | 104 +++++++++++++- apps/server/src/server.ts | 172 +++++++++++++++++++++++ docs/CODEX_ALIGNMENT_PLAN.md | 6 +- docs/design/app-server-v1.md | 29 ++-- docs/design/runtime-protocol-v1.md | 10 +- packages/protocol/README.md | 5 +- packages/protocol/src/codec.test.ts | 11 ++ packages/protocol/src/codec.ts | 2 + packages/protocol/src/runtime.test.ts | 18 +++ packages/protocol/src/runtime.ts | 15 +- packages/protocol/src/types.ts | 64 ++++++++- 15 files changed, 573 insertions(+), 35 deletions(-) diff --git a/apps/server/README.md b/apps/server/README.md index 5bc1b33..f5dc8f4 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -3,7 +3,8 @@ Experimental line-delimited JSON runtime server for DeepCode clients. The server owns lifecycle state and delegates model work to `RuntimeHost`. Completed items and -terminal turn state are persisted; streaming deltas are notifications only. The initial transport +terminal turn state are persisted; streaming and interactive requests are notifications only. +Approval and user-input responses are bound to their active thread and turn. The initial transport is single-client stdio, matching the desktop packaging decision in `docs/adr/0001-desktop-runtime-sidecar.md`. diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index f123821..01bbc2f 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -7,7 +7,7 @@ import { RuntimeHostExecutor } from './runtime-executor.js'; export function createDefaultTurnExecutor(): RuntimeHostExecutor { return new RuntimeHostExecutor({ - createHost: async (cwd) => { + createHost: async (cwd, mode) => { const credentials = await resolveCredentials({ store: new CredentialsStore() }); if (!credentials.apiKey && !credentials.authToken) { throw new Error( @@ -22,7 +22,7 @@ export function createDefaultTurnExecutor(): RuntimeHostExecutor { }), tools: new ToolRegistry(BUILTIN_TOOLS), cwd, - mode: 'default', + mode, permissions: { allow: [...SAFE_READONLY_TOOLS] }, }); }, diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts index 38d675c..4f115b0 100644 --- a/apps/server/src/runtime-executor.test.ts +++ b/apps/server/src/runtime-executor.test.ts @@ -10,6 +10,16 @@ import { describe, expect, it } from 'vitest'; import { RuntimeHostExecutor, historyFromThread } from './runtime-executor.js'; +function protocolCallbacks() { + return { + publishToolStarted: () => undefined, + publishToolCompleted: () => undefined, + publishUsage: () => undefined, + requestApproval: async () => 'deny' as const, + requestUserInput: async () => '', + }; +} + const priorAssistant = { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'prior answer' }], @@ -61,6 +71,28 @@ class StreamingProvider implements Provider { } } +class ToolProvider implements Provider { + readonly name = 'tool-test'; + calls = 0; + + async runTurn(options: ProviderRunOpts): Promise { + this.calls++; + if (this.calls === 1) { + return { + content: [{ type: 'tool_use', id: 'tool-1', name: 'WriteTest', input: { value: 'ok' } }], + stopReason: 'tool_use', + usage: { inputTokens: 3, outputTokens: 4, reasoningTokens: 1, cacheReadTokens: 2 }, + }; + } + options.handlers?.onTextDelta?.('done'); + return { + content: [{ type: 'text', text: 'done' }], + stopReason: 'end_turn', + usage: { inputTokens: 5, outputTokens: 6, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + } +} + describe('RuntimeHostExecutor', () => { it('reconstructs history and returns only messages created by the new turn', async () => { const provider = new StreamingProvider(); @@ -85,6 +117,7 @@ describe('RuntimeHostExecutor', () => { input: { text: 'current question' }, signal: new AbortController().signal, publishDelta: (_itemId, delta) => deltas.push(delta), + ...protocolCallbacks(), }); expect(provider.seenMessages).toEqual([ @@ -133,4 +166,55 @@ describe('RuntimeHostExecutor', () => { expect(historyFromThread(withError)).toHaveLength(2); }); + + it('projects tool, usage, and approval activity onto protocol callbacks', async () => { + const provider = new ToolProvider(); + const tools = new ToolRegistry(); + tools.register({ + name: 'WriteTest', + definition: { name: 'WriteTest', description: 'test', inputSchema: { type: 'object' } }, + execute: async () => ({ content: 'wrote test value' }), + }); + const host = new RuntimeHost({ provider, tools, cwd: '/workspace', mode: 'default' }); + const executor = new RuntimeHostExecutor({ createHost: () => host }); + const turn: TurnSnapshot = { + id: 'turn-tool', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }; + const started: string[] = []; + const completed: string[] = []; + const usage: number[] = []; + const approvals: string[] = []; + + const result = await executor.execute({ + thread, + turn, + input: { text: 'write it', effort: 'low' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + publishToolStarted: (itemId) => started.push(itemId), + publishToolCompleted: (itemId) => completed.push(itemId), + publishUsage: (value) => usage.push(value.inputTokens), + requestApproval: async (toolName) => { + approvals.push(toolName); + return 'allow'; + }, + requestUserInput: async () => '', + }); + + expect(started).toEqual(['tool-1']); + expect(completed).toEqual(['tool-1']); + expect(usage).toEqual([3, 5]); + expect(approvals).toEqual(['WriteTest']); + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'approval' }), + expect.objectContaining({ type: 'assistant_message' }), + expect.objectContaining({ type: 'tool_result' }), + ]), + ); + }); }); diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts index ebf51d8..a27a8b9 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -1,10 +1,17 @@ -import { type AgentEvent, type RuntimeHost, type StoredMessage } from '@deepcode/core'; +import { + type AgentEvent, + type Effort, + type Mode, + type RuntimeHost, + type StoredMessage, +} from '@deepcode/core'; +import { EFFORT_PARAMS } from '@deepcode/core/dist/providers/deepseek.js'; import type { CompletedItem, ThreadSnapshot } from '@deepcode/protocol'; import type { TurnExecutionArgs, TurnExecutionItem, TurnExecutor } from './server.js'; export interface RuntimeHostExecutorOptions { - createHost: (cwd: string) => Promise | RuntimeHost; + createHost: (cwd: string, mode: Mode) => Promise | RuntimeHost; systemPrompt?: string; model?: string; } @@ -16,29 +23,71 @@ export class RuntimeHostExecutor implements TurnExecutor { constructor(private readonly options: RuntimeHostExecutorOptions) {} async execute(args: TurnExecutionArgs) { - const host = await this.options.createHost(args.thread.cwd); + const mode = parseMode(args.input.mode); + const host = await this.options.createHost(args.thread.cwd, mode); const history = historyFromThread(args.thread); const baselineLength = history.length; const text = typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input); const streamingItemId = `${args.turn.id}-assistant`; const events: AgentEvent[] = []; + const interactionItems: TurnExecutionItem[] = []; + const effort = parseEffort(args.input.effort); + const effortParams = EFFORT_PARAMS[effort]; const result = await host.run({ cwd: args.thread.cwd, systemPrompt: this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, userMessage: text, history, - model: this.options.model ?? 'deepseek-chat', + model: + typeof args.input.model === 'string' + ? args.input.model + : (this.options.model ?? 'deepseek-chat'), + maxTokens: effortParams.maxTokens, + temperature: effortParams.temperature, signal: args.signal, systemReminders: false, - approval: async () => false, + approval: async (toolName, _input, verdict) => { + const decision = await args.requestApproval( + toolName, + verdict.reason ?? `Approve ${toolName}?`, + ); + interactionItems.push({ + type: 'approval', + payload: { toolName, decision, reason: verdict.reason }, + }); + return decision === 'always' ? 'always' : decision === 'allow'; + }, + askUser: async (request) => { + const answer = await args.requestUserInput(request); + interactionItems.push({ type: 'ask_user', payload: { ...request, answer } }); + return answer; + }, onEvent: (event) => { events.push(event); - if (event.type === 'text_delta') args.publishDelta(streamingItemId, event.text); + switch (event.type) { + case 'text_delta': + args.publishDelta(streamingItemId, event.text); + break; + case 'tool_use': + args.publishToolStarted(event.id, event.name, event.input); + break; + case 'tool_result': + args.publishToolCompleted(event.id, event.result); + break; + case 'usage': + args.publishUsage({ + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + reasoningTokens: event.reasoningTokens, + cacheReadTokens: event.cacheReadTokens, + }); + break; + } }, }); const newMessages = result.history.slice(baselineLength); - const items = completedItemsFromMessages(newMessages, text); + const items = [...interactionItems, ...completedItemsFromMessages(newMessages, text)]; if (result.stopReason === 'error') { const error = [...events].reverse().find((event) => event.type === 'error'); if (error?.type === 'error') items.push({ type: 'error', payload: { message: error.error } }); @@ -50,6 +99,24 @@ export class RuntimeHostExecutor implements TurnExecutor { } } +const MODES = new Set([ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'dontAsk', + 'bypassPermissions', +]); +const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + +function parseMode(value: unknown): Mode { + return typeof value === 'string' && MODES.has(value as Mode) ? (value as Mode) : 'default'; +} + +function parseEffort(value: unknown): Effort { + return typeof value === 'string' && EFFORTS.has(value as Effort) ? (value as Effort) : 'high'; +} + export function historyFromThread(thread: ThreadSnapshot): StoredMessage[] { const history: StoredMessage[] = []; for (const turn of thread.turns) { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index dc3c5ad..b441358 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -56,8 +56,11 @@ describe('AppServer', () => { it('persists completed items and terminal state while publishing deltas transiently', async () => { const events: ProtocolEvent[] = []; const executor: TurnExecutor = { - execute: async ({ publishDelta }) => { + execute: async ({ publishDelta, publishToolStarted, publishToolCompleted, publishUsage }) => { publishDelta('assistant-stream', 'hel'); + publishToolStarted('tool-1', 'Read', { file_path: 'README.md' }); + publishToolCompleted('tool-1', { content: 'contents' }); + publishUsage({ inputTokens: 1, outputTokens: 2 }); return { items: [{ type: 'assistant_message', payload: { text: 'hello' } }], }; @@ -94,6 +97,9 @@ describe('AppServer', () => { }), }); expect(events.map((event) => event.type)).toContain('item.delta'); + expect(events.map((event) => event.type)).toEqual( + expect.arrayContaining(['tool.started', 'tool.completed', 'usage.updated']), + ); expect((read.result as { turns: Array<{ items: unknown[] }> }).turns[0]?.items).toHaveLength(2); }); @@ -136,6 +142,102 @@ describe('AppServer', () => { expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(0); }); + it('round-trips approval and user-input requests through the active turn', async () => { + const events: ProtocolEvent[] = []; + const responses: string[] = []; + const executor: TurnExecutor = { + execute: async ({ requestApproval, requestUserInput }) => { + responses.push(await requestApproval('Bash', 'Run tests?')); + responses.push( + await requestUserInput({ + question: 'Choose scope', + options: [{ label: 'All', description: 'Run every test' }], + }), + ); + return {}; + }, + }; + const server = new AppServer({ executor, onEvent: (event) => events.push(event) }); + const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (thread.result as { id: string }).id; + const started = await server.handle( + request(2, 'turn/start', { threadId, input: { text: 'test' } }), + ); + const turnId = (started.result as { id: string }).id; + const approval = events.find((event) => event.type === 'approval.requested'); + expect(approval).toEqual( + expect.objectContaining({ type: 'approval.requested', threadId, turnId, toolName: 'Bash' }), + ); + + await expect( + server.handle( + request(3, 'approval/respond', { + threadId, + turnId, + requestId: approval?.type === 'approval.requested' ? approval.requestId : '', + decision: 'allow', + }), + ), + ).resolves.toEqual({ id: 3, result: { accepted: true } }); + await Promise.resolve(); + + const question = events.find((event) => event.type === 'user-input.requested'); + expect(question).toEqual( + expect.objectContaining({ type: 'user-input.requested', threadId, turnId }), + ); + await server.handle( + request(4, 'user-input/respond', { + threadId, + turnId, + requestId: question?.type === 'user-input.requested' ? question.requestId : '', + answer: 'All', + }), + ); + await server.waitForIdle(); + + expect(responses).toEqual(['allow', 'All']); + expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(1); + await expect( + server.handle( + request(5, 'approval/respond', { + threadId, + turnId, + requestId: approval?.type === 'approval.requested' ? approval.requestId : '', + decision: 'allow', + }), + ), + ).resolves.toEqual({ + id: 5, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + }); + + it('releases a pending interaction when its turn is interrupted', async () => { + let decision: string | undefined; + const executor: TurnExecutor = { + execute: async ({ requestApproval }) => { + decision = await requestApproval('Bash', 'Run forever?'); + return {}; + }, + }; + const server = new AppServer({ executor }); + const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (thread.result as { id: string }).id; + const started = await server.handle( + request(2, 'turn/start', { threadId, input: { text: 'wait' } }), + ); + const turnId = (started.result as { id: string }).id; + + await server.handle(request(3, 'turn/interrupt', { threadId, turnId })); + await server.waitForIdle(); + + expect(decision).toBe('deny'); + const read = await server.handle(request(4, 'thread/read', { threadId })); + expect(read.result).toEqual( + expect.objectContaining({ turns: [expect.objectContaining({ status: 'interrupted' })] }), + ); + }); + it('marks an orphaned active turn interrupted when a new process resumes it', async () => { const root = await mkdtemp(join(tmpdir(), 'deepcode-app-server-')); temporaryRoots.push(root); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9a6c4e8..f86c14a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -27,6 +27,20 @@ export interface TurnExecutionArgs { input: Record; signal: AbortSignal; publishDelta: (itemId: string, delta: string) => void; + publishToolStarted: (itemId: string, name: string, input: Record) => void; + publishToolCompleted: (itemId: string, result: { content: string; isError?: boolean }) => void; + publishUsage: (usage: { + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + cacheReadTokens?: number; + }) => void; + requestApproval: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; + requestUserInput: (request: { + question: string; + options: Array<{ label: string; description: string }>; + multiSelect?: boolean; + }) => Promise; } export interface TurnExecutor { @@ -47,12 +61,28 @@ interface ActiveTurn { task: Promise; } +type PendingInteraction = + | { + kind: 'approval'; + threadId: string; + turnId: string; + resolve: (decision: 'allow' | 'deny' | 'always') => void; + } + | { + kind: 'user-input'; + threadId: string; + turnId: string; + resolve: (answer: string) => void; + }; + class RequestValidationError extends Error {} export class AppServer { private readonly lifecycle: ProtocolRuntime; private readonly activeTurns = new Map(); private readonly terminalTransitions = new Map>(); + private readonly pendingInteractions = new Map(); + private interactionSequence = 0; constructor(private readonly options: AppServerOptions) { this.lifecycle = new ProtocolRuntime({ @@ -92,6 +122,7 @@ export class AppServer { await Promise.all( active.map(async ([turnId, turn]) => { turn.controller.abort(); + this.cancelInteractions(turnId); await this.finishOnce(turnId, () => this.lifecycle.interruptTurn(turn.threadId, turnId)); }), ); @@ -112,6 +143,10 @@ export class AppServer { return this.startTurn(request.params); case 'turn/interrupt': return this.interruptTurn(request.params); + case 'approval/respond': + return this.respondToApproval(request.params); + case 'user-input/respond': + return this.respondToUserInput(request.params); } } @@ -146,6 +181,7 @@ export class AppServer { if (active.threadId !== threadId) throw new RequestValidationError(`Turn ${turnId} does not belong to ${threadId}`); active.controller.abort(); + this.cancelInteractions(turnId); const terminal = await this.finishOnce(turnId, () => this.lifecycle.interruptTurn(threadId, turnId), ); @@ -172,6 +208,36 @@ export class AppServer { delta, }); }, + publishToolStarted: (itemId, name, input) => { + this.options.onEvent?.({ + type: 'tool.started', + threadId: thread.id, + turnId: turn.id, + itemId, + name, + input, + }); + }, + publishToolCompleted: (itemId, result) => { + this.options.onEvent?.({ + type: 'tool.completed', + threadId: thread.id, + turnId: turn.id, + itemId, + result, + }); + }, + publishUsage: (usage) => { + this.options.onEvent?.({ + type: 'usage.updated', + threadId: thread.id, + turnId: turn.id, + usage, + }); + }, + requestApproval: (toolName, reason) => + this.requestApproval(thread.id, turn.id, toolName, reason), + requestUserInput: (request) => this.requestUserInput(thread.id, turn.id, request), }); if (controller.signal.aborted) { await this.finishOnce(turn.id, () => this.lifecycle.interruptTurn(thread.id, turn.id)); @@ -195,10 +261,116 @@ export class AppServer { await this.finishOnce(turn.id, () => this.lifecycle.failTurn(thread.id, turn.id)); } } finally { + this.cancelInteractions(turn.id); this.activeTurns.delete(turn.id); } } + private requestApproval( + threadId: string, + turnId: string, + toolName: string, + reason: string, + ): Promise<'allow' | 'deny' | 'always'> { + const requestId = this.nextInteractionId(); + const response = new Promise<'allow' | 'deny' | 'always'>((resolve) => { + this.pendingInteractions.set(requestId, { + kind: 'approval', + threadId, + turnId, + resolve, + }); + }); + this.options.onEvent?.({ + type: 'approval.requested', + threadId, + turnId, + requestId, + toolName, + reason, + }); + return response; + } + + private requestUserInput( + threadId: string, + turnId: string, + request: { + question: string; + options: Array<{ label: string; description: string }>; + multiSelect?: boolean; + }, + ): Promise { + const requestId = this.nextInteractionId(); + const response = new Promise((resolve) => { + this.pendingInteractions.set(requestId, { + kind: 'user-input', + threadId, + turnId, + resolve, + }); + }); + this.options.onEvent?.({ + type: 'user-input.requested', + threadId, + turnId, + requestId, + ...request, + }); + return response; + } + + private respondToApproval(params: Record): { accepted: true } { + const interaction = this.requireInteraction(params, 'approval'); + const decision = requiredString(params, 'decision'); + if (decision !== 'allow' && decision !== 'deny' && decision !== 'always') { + throw new RequestValidationError('decision is invalid'); + } + this.pendingInteractions.delete(requiredId(params, 'requestId')); + interaction.resolve(decision); + return { accepted: true }; + } + + private respondToUserInput(params: Record): { accepted: true } { + const interaction = this.requireInteraction(params, 'user-input'); + const answer = requiredString(params, 'answer'); + this.pendingInteractions.delete(requiredId(params, 'requestId')); + interaction.resolve(answer); + return { accepted: true }; + } + + private requireInteraction( + params: Record, + kind: K, + ): Extract { + const requestId = requiredId(params, 'requestId'); + const threadId = requiredId(params, 'threadId'); + const turnId = requiredId(params, 'turnId'); + const interaction = this.pendingInteractions.get(requestId); + if (!interaction || interaction.kind !== kind) { + throw new RequestValidationError(`Pending ${kind} request not found: ${requestId}`); + } + if (interaction.threadId !== threadId || interaction.turnId !== turnId) { + throw new RequestValidationError( + `Request ${requestId} does not belong to ${threadId}/${turnId}`, + ); + } + return interaction as Extract; + } + + private cancelInteractions(turnId: string): void { + for (const [requestId, interaction] of this.pendingInteractions) { + if (interaction.turnId !== turnId) continue; + this.pendingInteractions.delete(requestId); + if (interaction.kind === 'approval') interaction.resolve('deny'); + else interaction.resolve(''); + } + } + + private nextInteractionId(): string { + return `request-${Date.now().toString(36)}-${++this.interactionSequence}`; + } + private finishOnce( turnId: string, transition: () => Promise, diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 97a45cd..e54646c 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -155,8 +155,8 @@ turn/interrupt turn/completed notification item/started notification item/completed notification -approval/request server request -user-input/request server request +approval/requested notification + approval/respond +user-input/requested notification + user-input/respond ``` delta 默认只流式传输、不落盘。fork/archive/search、agent graph 等在垂直切片稳定后再加入。实验字段必须显式 capability 协商;至少由两个客户端消费并经过兼容测试后才升为 stable。 @@ -288,6 +288,8 @@ model tool call - 按 ADR 把 runtime 移出 renderer,移除 WebView 中的 provider/API key。 - 已建立可构建的 CJS app-server、target runtime、Rust supervisor 与 renderer protocol client;迁移期 `mac-agent` 仅作 feature fallback。 +- app-server 已补齐按 active thread/turn 绑定的 approval、AskUserQuestion、tool 与 usage 事件;interrupt + 会解除所有待响应请求,避免 sidecar 因 UI 离线而悬挂。 - React 只消费协议事件;接入真实 interrupt、恢复与 structured items。 - 把 `preview-app.html` 变成自动化 fixture harness;收敛现有 Changes/Files/Inspector。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index d4e7501..71862d9 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -33,14 +33,16 @@ by expecting partial deltas to replay. ## Methods -| Method | Required parameters | Result | -| ---------------- | -------------------------- | --------------------------------------- | -| `initialize` | none | version and capabilities | -| `thread/start` | `cwd` | new thread snapshot | -| `thread/read` | `threadId` | thread snapshot or null | -| `thread/resume` | `threadId` | resumable snapshot | -| `turn/start` | `threadId`, object `input` | in-progress turn snapshot | -| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race | +| Method | Required parameters | Result | +| -------------------- | ------------------------------- | ------------------------------------------------- | +| `initialize` | none | version and capabilities | +| `thread/start` | `cwd` | new thread snapshot | +| `thread/read` | `threadId` | thread snapshot or null | +| `thread/resume` | `threadId` | resumable snapshot | +| `turn/start` | `threadId`, object `input` | in-progress turn snapshot | +| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race | +| `approval/respond` | thread, turn, request, decision | whether the pending request accepted the response | +| `user-input/respond` | thread, turn, request, answer | whether the pending request accepted the response | `turn/start` returns before model work finishes. The server emits transient deltas while the turn runs, then persists new provider-history messages as completed items before emitting exactly one @@ -58,9 +60,11 @@ same-directory temporary file and atomic rename. The app-server CLI stores these remain readable compatibility data until the client migration joins their indexes. `RuntimeHostExecutor` reconstructs exact stored provider messages from completed protocol items. -The default server runtime resolves credentials only in the trusted backend, uses the central -`RuntimeHost`, and denies interactive approvals because version 1 has not yet added an approval -request/response method. Consequently write or shell actions requiring approval fail closed. +The default server runtime resolves credentials only in the trusted backend and uses the central +`RuntimeHost`. Tool starts/results and usage are structured transient events. Approval and +AskUserQuestion prompts are emitted with opaque request ids; responses must match the active +thread, turn, request id, and request kind. Interrupt and shutdown resolve pending prompts before +waiting for the executor, so an abandoned UI cannot strand the server. ## Entrypoints @@ -76,8 +80,7 @@ headless output contracts remain unchanged during this experimental phase. ## Deferred from this slice -- approval and ask-user server requests; -- config provenance and per-turn model/effort options; +- config provenance; - thread listing, archive, fork, and search; - multi-client subscriptions or active-turn attachment; - joining the new thread snapshot index with legacy/canonical session listings; diff --git a/docs/design/runtime-protocol-v1.md b/docs/design/runtime-protocol-v1.md index fda1253..3d9ca15 100644 --- a/docs/design/runtime-protocol-v1.md +++ b/docs/design/runtime-protocol-v1.md @@ -40,9 +40,11 @@ Durable events describe state that can be reconstructed after a process restart: - `turn.interrupted` - `turn.failed` -`item.delta` is transient. A delta is suitable for live UI streaming, but it is neither saved by -the thread store nor included in protocol recordings. A client that reconnects reads the latest -completed-item snapshot instead of replaying partial text. +`item.delta`, tool start/result, usage, approval requests, and user-input requests are transient. +They are suitable for live UI updates but are neither saved by the thread store nor included in +protocol recordings. Approval and user-input outcomes become completed durable items when the turn +finishes. A client that reconnects reads the latest completed-item snapshot instead of replaying +partial state. State is saved before its corresponding durable event is emitted. A consumer may therefore read the referenced thread immediately after receiving an event. @@ -51,7 +53,7 @@ the referenced thread immediately after receiving an event. Clients call `initialize` before other methods and inspect both `protocolVersion` and advertised capabilities. Version 1 advertises thread resume, turn interruption, completed-item persistence, -and transient deltas. +transient deltas, structured tool events, and interactive requests. Unknown methods and non-object request parameters are rejected by the line-oriented JSON codec. Future incompatible lifecycle changes require a new protocol version; optional behavior should be diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 1b3f572..4e304d1 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -3,8 +3,9 @@ Experimental, transport-neutral lifecycle contracts for DeepCode runtimes and clients. The package deliberately has no Node.js, Tauri, React, or model-provider dependency. Durable -events describe thread, turn, and completed-item state; streaming deltas are transient and are -excluded from record/replay snapshots. +events describe thread, turn, and completed-item state. Streaming text, structured tool/usage +activity, and interactive approval/user-input requests are transient and excluded from +record/replay snapshots; their final outcomes are persisted as completed items. This is an internal experimental boundary. Consumers must negotiate `protocolVersion` through `initialize` instead of assuming backwards compatibility. diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts index bd9e9bd..601b6a6 100644 --- a/packages/protocol/src/codec.test.ts +++ b/packages/protocol/src/codec.test.ts @@ -33,6 +33,17 @@ describe('protocol codec', () => { ); }); + it.each(['approval/respond', 'user-input/respond'] as const)( + 'accepts the interactive response method %s', + (method) => { + expect(decodeProtocolRequest(JSON.stringify({ id: 2, method, params: {} }))).toEqual({ + id: 2, + method, + params: {}, + }); + }, + ); + it.each(['{}', '{"id":1,"method":"unknown"}', '{"id":1,"method":"initialize","params":[]}'])( 'rejects an invalid request: %s', (raw) => { diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index 23191dc..dea41ed 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -12,6 +12,8 @@ const protocolMethods = new Set([ 'thread/resume', 'turn/start', 'turn/interrupt', + 'approval/respond', + 'user-input/respond', ]); export function encodeProtocolMessage( diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts index 969f1f1..92f9092 100644 --- a/packages/protocol/src/runtime.test.ts +++ b/packages/protocol/src/runtime.test.ts @@ -33,6 +33,8 @@ describe('ProtocolRuntime', () => { turnInterrupt: true, completedItemPersistence: true, transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, }, }); }); @@ -121,6 +123,22 @@ describe('ProtocolRuntime', () => { itemId: 'item-streaming', delta: 'hel', }); + recorder.record({ + type: 'tool.started', + threadId: thread.id, + turnId: turn.id, + itemId: 'tool-1', + name: 'Read', + input: { file_path: 'README.md' }, + }); + recorder.record({ + type: 'approval.requested', + threadId: thread.id, + turnId: turn.id, + requestId: 'request-1', + toolName: 'Bash', + reason: 'Run command?', + }); await runtime.completeTurn(thread.id, turn.id); const replayed: ProtocolEvent[] = []; diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index 326a75e..45af68b 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -69,6 +69,8 @@ export class ProtocolRuntime { turnInterrupt: true, completedItemPersistence: true, transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, }, }; } @@ -204,7 +206,7 @@ export class ProtocolRecorder { private readonly records: DurableProtocolEvent[] = []; record(event: ProtocolEvent): void { - if (event.type !== 'item.delta') this.records.push(clone(event)); + if (isDurableEvent(event)) this.records.push(clone(event)); } replay(consumer: (event: DurableProtocolEvent) => void): void { @@ -215,3 +217,14 @@ export class ProtocolRecorder { return clone(this.records); } } + +function isDurableEvent(event: ProtocolEvent): event is DurableProtocolEvent { + return ( + event.type === 'thread.started' || + event.type === 'turn.started' || + event.type === 'item.completed' || + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ); +} diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 5ae796c..59bc870 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -42,6 +42,54 @@ export type DurableProtocolEvent = | { type: 'turn.interrupted'; threadId: string; turn: TurnSnapshot } | { type: 'turn.failed'; threadId: string; turn: TurnSnapshot }; +export interface ToolStartedEvent { + type: 'tool.started'; + threadId: string; + turnId: string; + itemId: string; + name: string; + input: Record; +} + +export interface ToolCompletedEvent { + type: 'tool.completed'; + threadId: string; + turnId: string; + itemId: string; + result: { content: string; isError?: boolean }; +} + +export interface UsageUpdatedEvent { + type: 'usage.updated'; + threadId: string; + turnId: string; + usage: { + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + cacheReadTokens?: number; + }; +} + +export interface ApprovalRequestedEvent { + type: 'approval.requested'; + threadId: string; + turnId: string; + requestId: string; + toolName: string; + reason: string; +} + +export interface UserInputRequestedEvent { + type: 'user-input.requested'; + threadId: string; + turnId: string; + requestId: string; + question: string; + options: Array<{ label: string; description: string }>; + multiSelect?: boolean; +} + export interface TransientDeltaEvent { type: 'item.delta'; threadId: string; @@ -50,7 +98,15 @@ export interface TransientDeltaEvent { delta: string; } -export type ProtocolEvent = DurableProtocolEvent | TransientDeltaEvent; +export type TransientProtocolEvent = + | TransientDeltaEvent + | ToolStartedEvent + | ToolCompletedEvent + | UsageUpdatedEvent + | ApprovalRequestedEvent + | UserInputRequestedEvent; + +export type ProtocolEvent = DurableProtocolEvent | TransientProtocolEvent; export interface InitializeResult { protocolVersion: typeof PROTOCOL_VERSION; @@ -59,6 +115,8 @@ export interface InitializeResult { turnInterrupt: true; completedItemPersistence: true; transientDeltas: true; + structuredToolEvents: true; + interactiveRequests: true; }; } @@ -68,7 +126,9 @@ export type ProtocolMethod = | 'thread/read' | 'thread/resume' | 'turn/start' - | 'turn/interrupt'; + | 'turn/interrupt' + | 'approval/respond' + | 'user-input/respond'; export interface ProtocolRequest { id: string | number; From 033f7e647d3faa21a2a3a8f9fb78649927699e8f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 14:40:55 +0800 Subject: [PATCH 14/23] feat: join protocol threads with canonical sessions --- apps/server/README.md | 3 + apps/server/src/run.ts | 7 +- apps/server/src/store.test.ts | 124 +++++++++++++++++++++ apps/server/src/store.ts | 107 ++++++++++++++++++ docs/CODEX_ALIGNMENT_PLAN.md | 2 + docs/design/app-server-v1.md | 12 +- packages/core/package.json | 4 + packages/core/src/sessions/index.ts | 1 + packages/core/src/sessions/manager.ts | 5 + packages/core/src/sessions/storage.test.ts | 28 +++++ packages/core/src/sessions/storage.ts | 26 +++++ 11 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/store.test.ts diff --git a/apps/server/README.md b/apps/server/README.md index f5dc8f4..a8dd7d1 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -8,6 +8,9 @@ Approval and user-input responses are bound to their active thread and turn. The is single-client stdio, matching the desktop packaging decision in `docs/adr/0001-desktop-runtime-sidecar.md`. +Lifecycle snapshots live under `threads-v1`; their message projection uses the same id in the +canonical session-v1 index. Legacy-only sessions are imported lazily on resume. + After a workspace build, run `node apps/server/dist/cli.js` and send one JSON request per line: ```json diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts index fc90649..cc0fe13 100644 --- a/apps/server/src/run.ts +++ b/apps/server/src/run.ts @@ -5,7 +5,7 @@ import type { ProtocolNotification } from '@deepcode/protocol'; import { createDefaultTurnExecutor } from './default-runtime.js'; import { AppServer, type TurnExecutor } from './server.js'; -import { FileThreadStore } from './store.js'; +import { CanonicalThreadStore } from './store.js'; import { ProtocolLineWriter, serveStdio } from './stdio.js'; export interface RunAppServerOptions { @@ -19,7 +19,10 @@ export async function runAppServer(options: RunAppServerOptions): Promise const writer = new ProtocolLineWriter(options.output); const server = new AppServer({ executor: options.executor ?? createDefaultTurnExecutor(), - store: new FileThreadStore(join(options.home, 'threads-v1')), + store: new CanonicalThreadStore( + join(options.home, 'threads-v1'), + join(options.home, 'sessions'), + ), onEvent: (event) => { const notification: ProtocolNotification = { method: 'event', params: event }; void writer.enqueue(notification); diff --git a/apps/server/src/store.test.ts b/apps/server/src/store.test.ts new file mode 100644 index 0000000..b608806 --- /dev/null +++ b/apps/server/src/store.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { SessionManager, writeMeta } from '@deepcode/core/sessions'; +import type { ThreadSnapshot } from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { CanonicalThreadStore } from './store.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'deepcode-thread-store-')); + roots.push(root); + const sessionsRoot = join(root, 'sessions'); + return { + store: new CanonicalThreadStore(join(root, 'threads-v1'), sessionsRoot), + sessions: new SessionManager({ root: sessionsRoot }), + }; +} + +describe('CanonicalThreadStore', () => { + it('materializes protocol history into the canonical session index', async () => { + const { store, sessions } = await fixture(); + const thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:02.000Z', + turns: [ + { + id: 'turn-1', + threadId: 'thread-1', + status: 'completed', + startedAt: '2026-08-01T00:00:01.000Z', + completedAt: '2026-08-01T00:00:02.000Z', + items: [ + { + id: 'item-1', + type: 'user_message', + payload: { text: 'Review the repository', model: 'deepseek-chat' }, + completedAt: '2026-08-01T00:00:01.000Z', + }, + { + id: 'item-2', + type: 'assistant_message', + payload: { + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Done' }], + }, + }, + completedAt: '2026-08-01T00:00:02.000Z', + }, + ], + }, + ], + }; + + await store.save(thread); + + await expect(sessions.list()).resolves.toEqual([ + expect.objectContaining({ + id: thread.id, + cwd: '/workspace', + title: 'Review the repository', + model: 'deepseek-chat', + }), + ]); + await expect(sessions.load(thread.id)).resolves.toEqual({ + meta: expect.objectContaining({ id: thread.id }), + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Review the repository' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Done' }] }, + ], + }); + + const current = await sessions.load(thread.id); + await writeMeta(sessions.root, { ...current!.meta, title: 'Renamed by user' }); + await store.save({ ...thread, updatedAt: '2026-08-01T00:00:03.000Z' }); + await expect(sessions.load(thread.id)).resolves.toEqual({ + meta: expect.objectContaining({ title: 'Renamed by user' }), + messages: expect.any(Array), + }); + }); + + it('lazily imports a canonical or legacy session as a resumable protocol thread', async () => { + const { store, sessions } = await fixture(); + const meta = await sessions.create('/legacy', { title: 'Existing chat' }); + await sessions.append(meta.id, { + role: 'user', + content: [{ type: 'text', text: 'Continue this' }], + }); + await sessions.append(meta.id, { + role: 'assistant', + content: [{ type: 'text', text: 'Ready' }], + }); + + const imported = await store.load(meta.id); + + expect(imported).toEqual( + expect.objectContaining({ + id: meta.id, + cwd: '/legacy', + turns: [ + expect.objectContaining({ + status: 'completed', + items: [ + expect.objectContaining({ type: 'user_message' }), + expect.objectContaining({ type: 'assistant_message' }), + ], + }), + ], + }), + ); + await expect(store.load(meta.id)).resolves.toEqual(imported); + }); +}); diff --git a/apps/server/src/store.ts b/apps/server/src/store.ts index 9235b1b..ee7599e 100644 --- a/apps/server/src/store.ts +++ b/apps/server/src/store.ts @@ -2,8 +2,12 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import process from 'node:process'; +import { type StoredMessage } from '@deepcode/core'; +import { SessionManager, type SessionMeta } from '@deepcode/core/sessions'; import type { ThreadSnapshot, ThreadStore } from '@deepcode/protocol'; +import { historyFromThread } from './runtime-executor.js'; + function validThreadId(threadId: string): boolean { return /^[a-zA-Z0-9._-]+$/.test(threadId); } @@ -36,3 +40,106 @@ export class FileThreadStore implements ThreadStore { return join(this.directory, `${threadId}.json`); } } + +/** + * Rich protocol snapshots plus a canonical session-v1 message projection. + * + * The protocol snapshot preserves lifecycle/items. The canonical projection + * keeps the existing CLI/desktop session index and legacy readers continuous + * during rollout. Both use the same id, and legacy-only sessions are imported + * lazily the first time the app-server resumes them. + */ +export class CanonicalThreadStore implements ThreadStore { + private readonly snapshots: FileThreadStore; + private readonly sessions: SessionManager; + + constructor(snapshotDirectory: string, sessionsDirectory: string) { + this.snapshots = new FileThreadStore(snapshotDirectory); + this.sessions = new SessionManager({ root: sessionsDirectory }); + } + + async load(threadId: string): Promise { + const snapshot = await this.snapshots.load(threadId); + if (snapshot) return snapshot; + const session = await this.sessions.load(threadId); + if (!session) return null; + const imported = threadFromSession(session.meta, session.messages); + await this.snapshots.save(imported); + return imported; + } + + async save(thread: ThreadSnapshot): Promise { + const messages = historyFromThread(thread); + await this.sessions.materialize(metaFromThread(thread), messages); + await this.snapshots.save(thread); + } +} + +function metaFromThread(thread: ThreadSnapshot): SessionMeta { + const firstInput = thread.turns + .flatMap((turn) => turn.items) + .find((item) => item.type === 'user_message'); + const text = typeof firstInput?.payload.text === 'string' ? firstInput.payload.text : ''; + const model = + typeof firstInput?.payload.model === 'string' ? firstInput.payload.model : undefined; + return { + id: thread.id, + cwd: thread.cwd, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + title: titleFrom(text), + model, + }; +} + +function titleFrom(text: string): string | undefined { + const firstLine = text + .split('\n') + .map((line) => line.trim()) + .find(Boolean); + return firstLine ? [...firstLine].slice(0, 60).join('') : undefined; +} + +function threadFromSession(meta: SessionMeta, messages: StoredMessage[]): ThreadSnapshot { + if (messages.length === 0) { + return { + id: meta.id, + cwd: meta.cwd, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + turns: [], + }; + } + return { + id: meta.id, + cwd: meta.cwd, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + turns: [ + { + id: `legacy-${meta.id}`, + threadId: meta.id, + status: 'completed', + startedAt: meta.createdAt, + completedAt: meta.updatedAt, + items: messages.map((message, index) => itemFromMessage(message, meta, index)), + }, + ], + }; +} + +function itemFromMessage(message: StoredMessage, meta: SessionMeta, index: number) { + const only = message.content.length === 1 ? message.content[0] : undefined; + const simpleUserText = message.role === 'user' && only?.type === 'text' ? only.text : undefined; + return { + id: `legacy-item-${index + 1}`, + type: + message.role === 'assistant' + ? ('assistant_message' as const) + : simpleUserText !== undefined + ? ('user_message' as const) + : ('tool_result' as const), + payload: simpleUserText !== undefined ? { text: simpleUserText } : { message }, + completedAt: message.timestamp ?? meta.updatedAt, + }; +} diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index e54646c..4a3e813 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -290,6 +290,8 @@ model tool call `mac-agent` 仅作 feature fallback。 - app-server 已补齐按 active thread/turn 绑定的 approval、AskUserQuestion、tool 与 usage 事件;interrupt 会解除所有待响应请求,避免 sidecar 因 UI 离线而悬挂。 +- protocol snapshot 与 canonical session-v1 共享 id;新 thread 会进入现有 session 索引,旧 session + 在首次 resume 时惰性投影为 compatibility turn,避免桌面迁移形成第二套不可见历史。 - React 只消费协议事件;接入真实 interrupt、恢复与 structured items。 - 把 `preview-app.html` 变成自动化 fixture harness;收敛现有 Changes/Files/Inspector。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index 71862d9..e4a3eef 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -54,10 +54,12 @@ tool process after a crash. ## Storage and security -The Node-specific `FileThreadStore` writes one mode-0600 JSON snapshot per thread through a -same-directory temporary file and atomic rename. The app-server CLI stores these under -`~/.deepcode/threads-v1` by default. This is the protocol rollout store; canonical session-v1 files -remain readable compatibility data until the client migration joins their indexes. +The Node-specific `CanonicalThreadStore` writes one mode-0600 lifecycle snapshot per thread under +`~/.deepcode/threads-v1` through a same-directory temporary file and atomic rename. It also +materializes the message history under the same id in canonical `~/.deepcode/sessions/*.v1.jsonl`, +using the shared cross-process writer lock. Existing CLI/desktop session lists therefore see new +protocol threads immediately. If only a canonical or legacy session exists, the store lazily +imports its messages into a completed compatibility turn without modifying the legacy file. `RuntimeHostExecutor` reconstructs exact stored provider messages from completed protocol items. The default server runtime resolves credentials only in the trusted backend and uses the central @@ -83,5 +85,3 @@ headless output contracts remain unchanged during this experimental phase. - config provenance; - thread listing, archive, fork, and search; - multi-client subscriptions or active-turn attachment; -- joining the new thread snapshot index with legacy/canonical session listings; -- a production-bundled CommonJS app-server artifact and pinned Node 22 runtime. diff --git a/packages/core/package.json b/packages/core/package.json index c3038c5..28927ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,6 +40,10 @@ "types": "./dist/runtime/index.d.ts", "import": "./dist/runtime/index.js" }, + "./sessions": { + "types": "./dist/sessions/index.d.ts", + "import": "./dist/sessions/index.js" + }, "./tools": { "types": "./dist/tools/index.d.ts", "import": "./dist/tools/index.js" diff --git a/packages/core/src/sessions/index.ts b/packages/core/src/sessions/index.ts index fd3667d..00d5211 100644 --- a/packages/core/src/sessions/index.ts +++ b/packages/core/src/sessions/index.ts @@ -8,6 +8,7 @@ export { defaultSessionsDir, newSessionId, readSessionRecords, + writeMeta, SessionCorruptionError, SessionWriterConflictError, type SessionMeta, diff --git a/packages/core/src/sessions/manager.ts b/packages/core/src/sessions/manager.ts index c789c1d..7f8146b 100644 --- a/packages/core/src/sessions/manager.ts +++ b/packages/core/src/sessions/manager.ts @@ -9,6 +9,7 @@ import { newSessionId, readMessages, readMeta, + replaceSession, writeMeta, type SessionMeta, } from './storage.js'; @@ -55,6 +56,10 @@ export class SessionManager { await appendMessage(this.root, sessionId, msg); } + async materialize(meta: SessionMeta, messages: StoredMessage[]): Promise { + await replaceSession(this.root, meta, messages); + } + async list(): Promise { return listSessionsLow(this.root); } diff --git a/packages/core/src/sessions/storage.test.ts b/packages/core/src/sessions/storage.test.ts index a7f09f4..8580365 100644 --- a/packages/core/src/sessions/storage.test.ts +++ b/packages/core/src/sessions/storage.test.ts @@ -9,6 +9,7 @@ import { readMessages, readMeta, readSessionRecords, + replaceSession, SessionCorruptionError, SessionWriterConflictError, sessionFiles, @@ -75,6 +76,33 @@ describe('session storage', () => { ]); }); + it('materializes an idempotent full projection while preserving an existing title', async () => { + const meta = { + id: 'protocol-thread', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:01.000Z', + title: 'User title', + }; + await replaceSession(root, meta, [ + { role: 'user', content: [{ type: 'text', text: 'first' }] }, + ]); + await replaceSession( + root, + { ...meta, updatedAt: '2026-08-01T00:00:02.000Z', title: undefined }, + [ + { role: 'user', content: [{ type: 'text', text: 'first' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'second' }] }, + ], + ); + + await expect(readMeta(root, meta.id)).resolves.toMatchObject({ + title: 'User title', + updatedAt: '2026-08-01T00:00:02.000Z', + }); + await expect(readMessages(root, meta.id)).resolves.toHaveLength(2); + }); + it('readMessages returns [] when jsonl missing', async () => { expect(await readMessages(root, 'nope')).toEqual([]); }); diff --git a/packages/core/src/sessions/storage.ts b/packages/core/src/sessions/storage.ts index 32f958b..5774739 100644 --- a/packages/core/src/sessions/storage.ts +++ b/packages/core/src/sessions/storage.ts @@ -121,6 +121,32 @@ export async function appendMessage( }); } +/** + * Atomically materialize the complete canonical session projection. + * + * Protocol stores use this idempotent full rewrite while their richer lifecycle + * snapshot remains the source of truth. A title written by another compatible + * client is always preserved; explicit rename continues to use `writeMeta`. + */ +export async function replaceSession( + root: string, + meta: SessionMeta, + messages: StoredMessage[], +): Promise { + const files = sessionFiles(root, meta.id); + await withWriterLock(files, meta.id, async () => { + const current = await readMeta(root, meta.id); + await writeCanonical( + files.jsonlPath, + { + ...meta, + title: current?.title ?? meta.title, + }, + messages, + ); + }); +} + export async function readMessages(root: string, sessionId: string): Promise { const result = await readSessionRecords(root, sessionId); const fatal = result.diagnostics.filter((diagnostic) => diagnostic.fatal); From e838092f6c63362e9d0e105aed4370d0143919de Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:02:50 +0800 Subject: [PATCH 15/23] feat: move desktop runtime behind app server --- apps/desktop/README.md | 12 +- apps/desktop/src-tauri/src/commands.rs | 87 +---- apps/desktop/src-tauri/src/credentials.rs | 41 +- apps/desktop/src-tauri/src/lib.rs | 31 +- apps/desktop/src-tauri/src/tools.rs | 26 +- apps/desktop/src/App.tsx | 8 +- .../desktop/src/components/InspectorPanel.tsx | 2 +- apps/desktop/src/lib/mac-agent.ts | 265 ------------- apps/desktop/src/lib/mac-session.ts | 10 +- apps/desktop/src/lib/mac-tools.test.ts | 89 ----- apps/desktop/src/lib/mac-tools.ts | 362 ------------------ apps/desktop/src/lib/protocol-agent.test.ts | 181 +++++++++ apps/desktop/src/lib/protocol-agent.ts | 306 +++++++++++++++ apps/desktop/src/lib/protocol-client.test.ts | 4 + apps/desktop/src/lib/protocol-client.ts | 16 + apps/desktop/src/lib/tauri-api.test.ts | 52 +-- apps/desktop/src/lib/tauri-api.ts | 25 +- apps/desktop/src/lib/window-shim.ts | 99 +---- apps/desktop/src/preview-app.tsx | 4 +- apps/desktop/src/screens/Repl.tsx | 6 +- apps/desktop/src/types/global.d.ts | 2 +- apps/desktop/vite.config.ts | 8 +- apps/server/src/default-runtime.ts | 29 +- apps/server/src/run.ts | 7 +- apps/server/src/runtime-executor.test.ts | 70 ++++ apps/server/src/runtime-executor.ts | 6 + apps/server/src/sidecar-entry.ts | 7 +- docs/CODEX_ALIGNMENT_PLAN.md | 6 +- docs/adr/0001-desktop-runtime-sidecar.md | 10 +- docs/design/app-server-v1.md | 7 + packages/core/package.json | 8 + packages/core/src/agent.ts | 14 +- packages/core/src/config/loader.test.ts | 5 + packages/core/src/config/loader.ts | 6 +- packages/core/src/credentials/index.test.ts | 8 + packages/core/src/credentials/index.ts | 8 +- packages/core/src/providers/deepseek.ts | 26 +- packages/core/src/providers/model-metadata.ts | 19 + 38 files changed, 836 insertions(+), 1036 deletions(-) delete mode 100644 apps/desktop/src/lib/mac-agent.ts delete mode 100644 apps/desktop/src/lib/mac-tools.test.ts delete mode 100644 apps/desktop/src/lib/mac-tools.ts create mode 100644 apps/desktop/src/lib/protocol-agent.test.ts create mode 100644 apps/desktop/src/lib/protocol-agent.ts create mode 100644 packages/core/src/providers/model-metadata.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index da03991..acef184 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -16,13 +16,13 @@ src/ renderer(React + Vite,无 Tailwind,手写设计系统 Plugins / Repl / Sessions / Settings / Skills components/ Sidebar / InspectorRail / ToolCard / UpdateBanner … lib/ tauri-api(renderer↔Rust IPC 封装)· protocol-client · - mac-agent(实验期 fallback)· repl-stream · updater … + protocol-agent · repl-stream · updater … src-tauri/ Rust 主进程 src/app_server.rs bundled runtime 启停、stdio 与 crash event src/commands.rs #[tauri::command] —— renderer 通过 invoke() 调用 - src/credentials.rs 凭据读写(原子写入) + src/credentials.rs 凭据保存与无密钥状态查询 src/settings.rs 设置持久化 - src/tools.rs 工具实现 + src/tools.rs legacy native helpers(renderer 仅暴露只读 file read) src/lib.rs Tauri builder / 插件注册 tauri.conf.json 窗口 + 构建 + 打包配置 capabilities/ 权限能力声明 @@ -32,10 +32,10 @@ src-tauri/ Rust 主进程 renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试见 `src/lib/tauri-api.test.ts`(#84)。 -实验 app-server 由 Tauri 作为 target-specific sidecar 监督。`apps/server` 会被打成单个 +app-server 由 Tauri 作为 target-specific sidecar 监督。`apps/server` 会被打成单个 `app-server.cjs` resource,Node runtime 通过 `bundle.externalBin` 进入 `.app`;renderer 只能通过 -Rust commands 与版本化协议通信,不能直接使用 shell plugin。现有 `mac-agent` 在迁移期保留为显式 -fallback,不能作为长期双架构。 +Rust commands 与版本化协议通信,不能直接使用 shell plugin。provider、agent loop、tools、权限、 +session materialization 和凭证明文都只存在于 sidecar;renderer 不再带有第二套运行时。 ## 开发 diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index eeb7bc0..728f82b 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -25,8 +25,8 @@ pub fn get_app_info() -> AppInfo { } #[tauri::command] -pub fn read_credentials() -> Result { - credentials::read() +pub fn credential_status() -> Result { + credentials::status() } #[tauri::command] @@ -116,71 +116,10 @@ pub fn append_allow_matcher(matcher: String) -> Result<(), String> { settings::write_user(&value) } -/// Create a new session JSONL with a metadata header line. Returns the -/// generated session id. The id format matches what @deepcode/core's -/// SessionManager produces: `YYYY-MM-DD-`. -#[tauri::command] -pub fn session_create(cwd: String) -> Result { - let Some(home) = dirs::home_dir() else { - return Err("no home directory".into()); - }; - let now = std::time::SystemTime::now(); - let secs = now - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| e.to_string())? - .as_secs(); - let date = format_date(secs); - // Lightweight unique suffix from time-nanos — no extra crate dep - let nanos = now - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| e.to_string())? - .subsec_nanos(); - let rand_id = format!("{:08x}", nanos); - let id = format!("{}-{}", date, rand_id); - let dir = home.join(".deepcode").join("sessions"); - std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?; - let path = dir.join(format!("{}.v1.jsonl", id)); - let header = serde_json::json!({ - "type": "session_meta", - "schema_version": 1, - "id": id, - "cwd": cwd, - "created_at": secs, - "client": "desktop" - }); - let line = format!("{}\n", header); - std::fs::write(&path, line).map_err(|e| format!("write {}: {}", path.display(), e))?; - Ok(id) -} - -/// Append a single JSON line to a session's JSONL file. -#[tauri::command] -pub fn session_append(id: String, message: serde_json::Value) -> Result<(), String> { - safe_session_id(&id)?; - let Some(home) = dirs::home_dir() else { - return Err("no home directory".into()); - }; - let dir = home.join(".deepcode").join("sessions"); - std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?; - let _lock = SessionWriterLock::acquire(&dir, &id)?; - let path = ensure_canonical_session(&dir, &id)?; - let mut normalized = message; - normalized["type"] = serde_json::Value::String("message".to_string()); - normalized["schema_version"] = serde_json::Value::Number(1.into()); - let line = format!("{}\n", normalized); - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .map_err(|e| format!("open {}: {}", path.display(), e))?; - f.write_all(line.as_bytes()) - .map_err(|e| format!("write {}: {}", path.display(), e)) -} - /// Read a session's JSONL and return its message lines (skipping the /// `session_meta` header and any unparseable lines). Each returned value is the -/// stored message object as written by session_append: `{ type, role, content, -/// timestamp }`. Returns an empty vec if the file doesn't exist. +/// canonical `{ type, role, content, timestamp }` object. Returns an empty vec +/// if the file doesn't exist. #[tauri::command] pub fn session_read(id: String) -> Result, String> { safe_session_id(&id)?; @@ -318,24 +257,6 @@ fn parse_session_messages(text: &str) -> Result, String> Ok(out) } -fn format_date(secs: u64) -> String { - // Simple YYYY-MM-DD; days since epoch math is enough for filename use. - let days = secs / 86_400; - // Reference: 1970-01-01 was a Thursday; we compute YMD via the - // standard "civil_from_days" algorithm by Howard Hinnant. - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{:04}-{:02}-{:02}", y, m, d) -} - /// List session files under ~/.deepcode/sessions/. Returns just metadata. #[derive(Serialize)] pub struct SessionMeta { diff --git a/apps/desktop/src-tauri/src/credentials.rs b/apps/desktop/src-tauri/src/credentials.rs index a2df4a0..a76d05b 100644 --- a/apps/desktop/src-tauri/src/credentials.rs +++ b/apps/desktop/src-tauri/src/credentials.rs @@ -14,6 +14,14 @@ pub struct Credentials { pub base_url: Option, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CredentialStatus { + pub has_key: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, +} + pub fn credentials_path() -> Option { let home = dirs::home_dir()?; Some(home.join(".deepcode").join("credentials.json")) @@ -30,6 +38,21 @@ pub fn read() -> Result { } } +pub fn status() -> Result { + let credentials = read()?; + Ok(CredentialStatus { + has_key: credentials + .api_key + .as_ref() + .is_some_and(|value| !value.is_empty()) + || credentials + .auth_token + .as_ref() + .is_some_and(|value| !value.is_empty()), + base_url: credentials.base_url, + }) +} + pub fn write(creds: &Credentials) -> Result<(), String> { let Some(path) = credentials_path() else { return Err("no home directory".into()); @@ -49,9 +72,8 @@ pub fn write(creds: &Credentials) -> Result<(), String> { } // ── Serde contract ───────────────────────────────────────────────────── -// tauri-api.ts#readCredentials reads `api_key`/`auth_token`/`base_url` (snake) -// and maps them to camelCase itself. Lock that shape + the skip-if-None omission -// the TS side relies on (missing field → undefined). See HANDOFF §8a. +// Credentials remain backend-only. The renderer receives CredentialStatus, +// while this shape stays compatible with the CLI's credentials.json. #[cfg(test)] mod contract_tests { use super::*; @@ -76,4 +98,17 @@ mod contract_tests { let v = serde_json::to_value(Credentials::default()).unwrap(); assert_eq!(v.as_object().unwrap().len(), 0, "None fields must be skipped: {v}"); } + + #[test] + fn status_never_serializes_credentials() { + let value = serde_json::to_value(CredentialStatus { + has_key: true, + base_url: Some("https://host/v1".into()), + }) + .unwrap(); + assert_eq!(value["hasKey"], true); + assert_eq!(value["baseUrl"], "https://host/v1"); + assert!(value.get("api_key").is_none()); + assert!(value.get("auth_token").is_none()); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index fcf92c0..25bc2a4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,17 +3,17 @@ // // Architecture: most of DeepCode's logic lives in @deepcode/core (TypeScript). // The Tauri backend's job is to host the webview and expose a few native -// commands that the frontend can't do (file dialogs, credentials read/write, -// settings file IO, child-process spawn for CLI integration). -// -// During the protocol rollout, Rust also supervises the bundled app-server -// sidecar. The renderer loop remains only as an explicit compatibility path. +// commands that the frontend can't do (file dialogs, credential save/status, +// settings/session index IO, and read-only file previews). Rust supervises the +// bundled app-server sidecar; runtime/tool execution never runs in the webview. mod app_server; mod commands; mod credentials; mod settings; +#[allow(dead_code)] // mutation-only snapshot helpers remain for compatibility tests mod snapshots; +#[allow(dead_code)] // legacy native mutation helpers are no longer renderer commands mod tools; mod voice; @@ -22,15 +22,13 @@ use app_server::{ }; use commands::{ append_allow_matcher, cli_path, get_app_info, get_settings_path, list_plugins, list_sessions, - list_skills, load_keybindings, load_settings_file, open_url, read_credentials, - save_credentials, save_keybindings, save_settings_file, session_append, session_archive, - session_create, session_delete, session_read, session_set_title, + credential_status, list_skills, load_keybindings, load_settings_file, open_url, + save_credentials, save_keybindings, save_settings_file, session_archive, session_delete, + session_read, session_set_title, }; use snapshots::session_snapshots; use tauri::Manager; -use tools::{ - tool_bash, tool_bash_cancel, tool_edit, tool_glob, tool_grep, tool_read, tool_write, BashState, -}; +use tools::tool_read; use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -43,7 +41,6 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(VoiceState::default()) - .manage(BashState::default()) .manage(AppServerState::default()) .invoke_handler(tauri::generate_handler![ get_app_info, @@ -51,7 +48,7 @@ pub fn run() { app_server_send, app_server_stop, app_server_status, - read_credentials, + credential_status, save_credentials, load_settings_file, save_settings_file, @@ -59,8 +56,6 @@ pub fn run() { append_allow_matcher, load_keybindings, save_keybindings, - session_create, - session_append, session_read, session_set_title, session_delete, @@ -71,12 +66,6 @@ pub fn run() { cli_path, open_url, tool_read, - tool_write, - tool_edit, - tool_bash, - tool_bash_cancel, - tool_glob, - tool_grep, session_snapshots, voice_status, voice_start, diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs index f2eee17..016ff35 100644 --- a/apps/desktop/src-tauri/src/tools.rs +++ b/apps/desktop/src-tauri/src/tools.rs @@ -87,7 +87,16 @@ pub async fn tool_read( offset: Option, limit: Option, ) -> Result { - let raw = tokio::fs::read_to_string(&file_path) + let resolved = tokio::fs::canonicalize(&file_path) + .await + .map_err(|e| format!("read {}: {}", file_path, e))?; + let credentials_path = if let Some(path) = crate::credentials::credentials_path() { + tokio::fs::canonicalize(path).await.ok() + } else { + None + }; + reject_credentials_path(&resolved, credentials_path.as_deref())?; + let raw = tokio::fs::read_to_string(&resolved) .await .map_err(|e| format!("read {}: {}", file_path, e))?; let lines: Vec<&str> = raw.split('\n').collect(); @@ -129,6 +138,14 @@ pub async fn tool_read( }) } +fn reject_credentials_path(resolved: &Path, credentials_path: Option<&Path>) -> Result<(), String> { + if credentials_path.is_some_and(|path| resolved == path) { + Err("credential files are backend-only".to_string()) + } else { + Ok(()) + } +} + // ────────────────────────────────────────────────────────────────────────── // Write // ────────────────────────────────────────────────────────────────────────── @@ -528,6 +545,13 @@ mod casing_tests { ); } + #[test] + fn renderer_read_rejects_backend_credentials() { + let credential = Path::new("/home/user/.deepcode/credentials.json"); + assert!(reject_credentials_path(credential, Some(credential)).is_err()); + assert!(reject_credentials_path(Path::new("/workspace/src.ts"), Some(credential)).is_ok()); + } + #[test] fn edit_ok_serializes_camel_case() { let v = serde_json::to_value(EditOk { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 109d763..f8d2c6b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,7 +3,7 @@ // Milestone: 0.1.2 — adds project-folder flow + inspector wiring + session refresh. import { useCallback, useEffect, useState } from 'react'; -import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js'; +import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js'; import { FilePanel } from './components/FilePanel.js'; import { InspectorPanel } from './components/InspectorPanel.js'; import { InspectorRail } from './components/InspectorRail.js'; @@ -12,7 +12,7 @@ import { SETTINGS_FAMILY, SettingsLayout } from './components/SettingsLayout.js' import { Sidebar } from './components/Sidebar.js'; import { UpdateBanner } from './components/UpdateBanner.js'; import { registerShortcut } from './lib/keyboard.js'; -import { clearHistory as clearAgentHistory } from './lib/mac-agent.js'; +import { clearProtocolThread as clearAgentHistory } from './lib/protocol-agent.js'; import { loadProjectPath, saveProjectPath } from './lib/project.js'; import { storedToMsgs, type Msg } from './lib/repl-stream.js'; import { onUpdateDownloaded, startUpdaterPolling } from './lib/updater.js'; @@ -243,6 +243,7 @@ export function App(): JSX.Element { setScreen, projectPath, () => setSessionEpoch((k) => k + 1), + setActiveSessionId, handleInspector, resumedMessages, openFile, @@ -290,6 +291,7 @@ function renderScreen( setScreen: (s: ScreenName) => void, projectPath: string, onTurnComplete: () => void, + onSessionStarted: (sessionId: string) => void, onInspector: (patch: Partial) => void, initialMessages?: Msg[], onOpenFile?: (path: string) => void, @@ -301,6 +303,7 @@ function renderScreen( l.trim()) - .find((l) => l.length > 0) ?? userMessage.trim(); - return firstLine.slice(0, 60); -} - -// Local minimal ToolRegistry — same shape as @deepcode/core's, without -// the BUILTIN_TOOLS top-level import that drags in fs. -class LocalToolRegistry { - private readonly tools = new Map(); - constructor(initial: ToolHandler[]) { - for (const t of initial) this.tools.set(t.name, t); - } - register(t: ToolHandler): void { - this.tools.set(t.name, t); - } - get(name: string): ToolHandler | undefined { - return this.tools.get(name); - } - list(): ToolHandler[] { - return [...this.tools.values()]; - } - definitions() { - return this.list().map((t) => t.definition); - } -} - -function buildSystemPrompt(cwd?: string): string { - return `You are DeepCode, an AI coding assistant powered by DeepSeek. -Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). -Be concise and accurate. When you modify files, briefly explain what you changed and why. - -${cwd ? `Working directory: ${cwd}\nAll relative paths resolve against this directory.` : 'NO project folder has been picked yet. Tell the user to pick one before asking for file edits.'} - -Tool input schemas use snake_case field names (e.g. file_path, old_string). -ALWAYS pass absolute paths or paths relative to the working directory above.`; -} - -/** A single in-flight turn. */ -interface ActiveTurn { - turnId: string; - abortController: AbortController; -} - -const turns = new Map(); -let history: import('@deepcode/core/dist/types.js').StoredMessage[] = []; -let provider: DeepSeekProvider | null = null; -// One active session id per app run — created lazily on first turn. -let currentSessionId: string | null = null; - -export function clearSession(): void { - currentSessionId = null; - setActiveSessionId(null); - history = []; -} - -/** - * Resume an existing session: adopt its id + loaded history so the next turn - * continues that conversation (with full context) and appends to its JSONL - * rather than starting a new file. - */ -export function resumeSession( - sessionId: string, - loadedHistory: import('@deepcode/core/dist/types.js').StoredMessage[], -): void { - currentSessionId = sessionId; - setActiveSessionId(sessionId); - history = loadedHistory; -} - -async function ensureProvider(): Promise { - if (provider) return provider; - const creds = await readCredentials(); - if (!creds.apiKey && !creds.authToken) { - throw new Error( - 'No DeepSeek credentials. Set your API key in onboarding or via ~/.deepcode/credentials.json.', - ); - } - provider = new DeepSeekProvider({ - apiKey: creds.apiKey ?? '', - authToken: creds.authToken, - baseURL: creds.baseURL, - }); - return provider; -} - -export interface StartTurnArgs { - userMessage: string; - model?: string; - mode?: Mode; - /** Effort tier — controls maxTokens + temperature. Default 'high'. */ - effort?: Effort; - /** Project folder absolute path. Tools resolve relative paths against this. - * When undefined, tools error because the agent can't safely guess. */ - cwd?: string; - onEvent: (e: AgentEvent) => void; - onDone: (reason: 'end_turn' | 'max_turns' | 'aborted' | 'error') => void; - /** Called when the agent needs user approval for a tool call. Resolves to: - * 'allow' — permit this one call - * 'deny' — reject - * 'always' — permit + persist a permissions.allow matcher - */ - onApproval?: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; - /** Called when the agent's AskUserQuestion tool needs an answer. Resolves to - * the chosen option label (or free text). */ - onAskUser?: (req: { - question: string; - options: Array<{ label: string; description: string }>; - multiSelect?: boolean; - }) => Promise; -} - -export interface StartTurnResult { - turnId: string; -} - -export async function startAgentTurn(args: StartTurnArgs): Promise { - const turnId = `mac-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; - const abort = new AbortController(); - turns.set(turnId, { turnId, abortController: abort }); - - // Lazily create a session JSONL on first turn, so the sidebar can - // surface it. Failures here are non-fatal — we just don't persist. - const isNewSession = !currentSessionId; - if (!currentSessionId) { - try { - currentSessionId = await sessionCreate(args.cwd ?? '/'); - // Publish so the tools snapshot under this id and the file panel can read them. - setActiveSessionId(currentSessionId); - } catch (err) { - console.warn('session_create failed (continuing without persistence):', err); - } - } - // Append the user message right away so the file shows non-zero activity. - if (currentSessionId) { - try { - await sessionAppend(currentSessionId, { - type: 'message', - role: 'user', - content: [{ type: 'text', text: args.userMessage }], - timestamp: new Date().toISOString(), - }); - } catch (err) { - console.warn('session_append (user) failed:', err); - } - // Title a brand-new session from its first user message (Claude-Code style), - // so the sidebar shows a human label immediately rather than the raw id. - if (isNewSession) { - try { - await sessionSetTitle(currentSessionId, sessionTitleFrom(args.userMessage)); - } catch (err) { - console.warn('session_set_title failed:', err); - } - } - } - - const prov = await ensureProvider(); - // Cast: nominal-typing on the private `tools` field makes TS reject the - // structural match. Runtime shape is identical. - const tools = new LocalToolRegistry(MAC_TOOLS) as unknown as Parameters< - typeof runAgent - >[0]['tools']; - - // Run the agent loop in the background. Errors are surfaced via onEvent. - (async () => { - try { - // Default to 'high' (6k output budget): the desktop's primary use is - // writing/editing files, and 'medium' (3k) routinely truncates a single - // multi-file write mid-tool-call. Users can still dial it down per-turn. - const effortParams = EFFORT_PARAMS[args.effort ?? 'high']; - const result = await runAgent({ - provider: prov, - tools, - systemPrompt: buildSystemPrompt(args.cwd), - userMessage: args.userMessage, - history, - model: args.model ?? 'deepseek-chat', - maxTokens: effortParams.maxTokens, - temperature: effortParams.temperature, - cwd: args.cwd ?? '/', - signal: abort.signal, - mode: args.mode ?? 'default', - // Disable system reminders in the renderer — they require node:fs - // (reads todos.json + stats files). The Mac UI surfaces those - // contextually elsewhere. - systemReminders: false, - approval: args.onApproval - ? async (toolName, _input, verdict) => { - const reason = verdict.reason ?? `Approve ${toolName}?`; - const decision = await args.onApproval!(toolName, reason); - if (decision === 'always') return 'always'; - return decision === 'allow'; - } - : undefined, - askUser: args.onAskUser ? async (req) => args.onAskUser!(req) : undefined, - onEvent: args.onEvent, - // No hook dispatcher, no sessions persistence, no autoCompact in v1 Mac MVP. - }); - history = result.history; - // Append the new assistant message(s) for persistence. - if (currentSessionId && history.length > 0) { - const newestAssistant = [...history].reverse().find((m) => m.role === 'assistant'); - if (newestAssistant) { - try { - await sessionAppend(currentSessionId, { - type: 'message', - ...newestAssistant, - }); - } catch (err) { - console.warn('session_append (assistant) failed:', err); - } - } - } - args.onDone(result.stopReason); - } catch (err) { - args.onEvent({ type: 'error', error: (err as Error).message ?? String(err) }); - args.onDone('error'); - } finally { - turns.delete(turnId); - } - })(); - - return { turnId }; -} - -export function abortAgentTurn(turnId: string): boolean { - const t = turns.get(turnId); - if (!t) return false; - t.abortController.abort(); - return true; -} - -export function clearHistory(): void { - history = []; - currentSessionId = null; - setActiveSessionId(null); -} - -export function getHistoryLength(): number { - return history.length; -} diff --git a/apps/desktop/src/lib/mac-session.ts b/apps/desktop/src/lib/mac-session.ts index 3930df8..1801380 100644 --- a/apps/desktop/src/lib/mac-session.ts +++ b/apps/desktop/src/lib/mac-session.ts @@ -1,12 +1,10 @@ -// The id of the session the agent is currently writing to. mac-agent owns the -// session lifecycle (lazy create on first turn, resume, clear) and publishes -// the active id here; mac-tools reads it to stamp file snapshots, and the file -// panel reads it to fetch those snapshots. Kept in its own tiny module so both -// sides depend on it without a mac-agent ↔ mac-tools import cycle. +// Compatibility bridge for panels that still address canonical sessions. +// The protocol agent publishes the active thread id here; canonical thread and +// session ids are identical during the rollout. let activeSessionId: string | null = null; -/** Set (or clear, with null) the session the tools should snapshot under. */ +/** Set (or clear, with null) the canonical session selected by the UI. */ export function setActiveSessionId(id: string | null): void { activeSessionId = id; } diff --git a/apps/desktop/src/lib/mac-tools.test.ts b/apps/desktop/src/lib/mac-tools.test.ts deleted file mode 100644 index 9175e26..0000000 --- a/apps/desktop/src/lib/mac-tools.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment node -// Sanity tests for the mac-tools key-pick helpers + tool schema entries. -// These cover the conversation-blocking bug from 0.1.1 where DeepSeek -// emitted camelCase keys against a snake_case schema and the wrappers -// passed undefined to Tauri, getting "missing required key …". -// -// We can't easily mock `invoke()` without an env shim, so this only -// exercises the helpers + tool definitions. The actual Tauri command -// round-trip is exercised manually + by the integration DMG smoke test. - -import { describe, expect, it } from 'vitest'; - -// Re-implement the helpers under test by extracting them. We can't -// import them directly because mac-tools imports @tauri-apps/api/core -// which can't load outside a Tauri webview. The helpers are pure so -// duplicating them in the test is fine; if either ever changes, both -// places must be updated. -function pickStr(input: Record, ...keys: string[]): string | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'string') return v; - } - return undefined; -} -function pickNum(input: Record, ...keys: string[]): number | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'number') return v; - } - return undefined; -} -function pickBool(input: Record, ...keys: string[]): boolean | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'boolean') return v; - } - return undefined; -} - -describe('mac-tools key pickers', () => { - it('pickStr returns the first matching string', () => { - expect(pickStr({ file_path: '/a' }, 'file_path', 'filePath')).toBe('/a'); - expect(pickStr({ filePath: '/b' }, 'file_path', 'filePath')).toBe('/b'); - expect(pickStr({ path: '/c' }, 'file_path', 'filePath', 'path')).toBe('/c'); - }); - - it('pickStr prefers earlier-listed keys (snake_case wins over camelCase)', () => { - expect(pickStr({ file_path: '/snake', filePath: '/camel' }, 'file_path', 'filePath')).toBe( - '/snake', - ); - }); - - it('pickStr returns undefined when no key matches', () => { - expect(pickStr({ foo: 'bar' }, 'file_path', 'filePath')).toBeUndefined(); - }); - - it('pickStr skips non-string values', () => { - expect(pickStr({ file_path: 42, filePath: '/ok' }, 'file_path', 'filePath')).toBe('/ok'); - expect(pickStr({ file_path: null, filePath: '/ok' }, 'file_path', 'filePath')).toBe('/ok'); - }); - - it('pickNum handles primitives correctly', () => { - expect(pickNum({ offset: 10 }, 'offset')).toBe(10); - expect(pickNum({ offset: '10' as unknown as number }, 'offset')).toBeUndefined(); - expect(pickNum({ offset: 0 }, 'offset')).toBe(0); // zero is valid - }); - - it('pickBool handles primitives correctly', () => { - expect(pickBool({ replace_all: true }, 'replace_all', 'replaceAll')).toBe(true); - expect(pickBool({ replaceAll: false }, 'replace_all', 'replaceAll')).toBe(false); - expect(pickBool({ replace_all: 'true' as unknown as boolean }, 'replace_all')).toBeUndefined(); - }); - - it('empty input returns undefined for all pickers', () => { - expect(pickStr({}, 'a', 'b')).toBeUndefined(); - expect(pickNum({}, 'a', 'b')).toBeUndefined(); - expect(pickBool({}, 'a', 'b')).toBeUndefined(); - }); - - it('rejects keys that contain matching value but with wrong type', () => { - // This is the original 0.1.1 bug: LLM sent the value under the - // "wrong" key, so we tolerate either alias. - const llmInput = { filePath: '/Users/foo/bar.txt', content: 'hello' }; - const filePath = pickStr(llmInput, 'file_path', 'filePath', 'path'); - const content = pickStr(llmInput, 'content', 'text', 'body'); - expect(filePath).toBe('/Users/foo/bar.txt'); - expect(content).toBe('hello'); - }); -}); diff --git a/apps/desktop/src/lib/mac-tools.ts b/apps/desktop/src/lib/mac-tools.ts deleted file mode 100644 index 7c69f23..0000000 --- a/apps/desktop/src/lib/mac-tools.ts +++ /dev/null @@ -1,362 +0,0 @@ -// Mac-flavored ToolHandler implementations. -// -// @deepcode/core's BUILTIN_TOOLS use node:fs / node:child_process which -// don't work in a Tauri webview. These wrappers expose the same -// ToolHandler interface but route through Tauri commands that execute -// fs / bash in the Rust main process. -// -// The agent loop (also from @deepcode/core) is provider-agnostic AND -// IO-agnostic — it just calls `tool.execute(input, ctx)` and the tool -// handles the rest. So substituting these tools is enough. - -import { invoke } from '@tauri-apps/api/core'; -import type { ToolHandler, ToolResult } from '@deepcode/core/dist/types.js'; -import { getActiveSessionId } from './mac-session.js'; - -let bashCommandSeq = 0; - -/** - * Tolerant key pick — accepts either snake_case or camelCase. DeepSeek - * occasionally normalizes JSON Schema keys to camelCase regardless of - * what we asked for; if the agent loop doesn't see the field by the - * exact name in the schema, the value is undefined and the Tauri call - * fails with "missing required key …". This helper lets us accept both. - */ -function pickStr(input: Record, ...keys: string[]): string | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'string') return v; - } - return undefined; -} -function pickNum(input: Record, ...keys: string[]): number | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'number') return v; - } - return undefined; -} -function pickBool(input: Record, ...keys: string[]): boolean | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'boolean') return v; - } - return undefined; -} - -/** - * Diagnostic suffix for "missing required arg" errors. An empty input almost - * always means the model's tool call was cut off at the output-token limit - * before it emitted any arguments (DeepSeek caps output at ~8k) — surface that - * clearly so the user (and the model, which sees this error) can react. - */ -function describeInput(input: Record): string { - const keys = Object.keys(input); - if (keys.length === 0) { - return ' — the call arrived with NO arguments. The model likely ran out of output tokens before emitting them; raise Effort (try Max) or write a smaller file / split into multiple writes.'; - } - return ` (received keys: ${keys.join(', ')})`; -} - -// ────────────────────────────────────────────────────────────────────────── -// Read -// ────────────────────────────────────────────────────────────────────────── - -export const MacReadTool: ToolHandler = { - name: 'Read', - definition: { - name: 'Read', - description: - 'Read a file from the filesystem. Returns line-numbered content. Use offset/limit for large files.', - inputSchema: { - type: 'object', - properties: { - file_path: { type: 'string', description: 'Absolute path or path relative to cwd.' }, - offset: { type: 'number', description: '1-indexed line to start at.' }, - limit: { type: 'number', description: 'Max lines to return (default 2000).' }, - }, - required: ['file_path'], - }, - }, - async execute(input: Record): Promise { - try { - const filePath = pickStr(input, 'file_path', 'filePath', 'path'); - if (!filePath) { - return { content: `Error: missing file_path${describeInput(input)}`, isError: true }; - } - const r = (await invoke('tool_read', { - filePath, - offset: pickNum(input, 'offset'), - limit: pickNum(input, 'limit'), - })) as { content: string; linesTotal: number; linesShown: number; offset: number }; - return { - content: r.content, - data: { - file: filePath, - lines_total: r.linesTotal, - lines_shown: r.linesShown, - offset: r.offset, - }, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Write -// ────────────────────────────────────────────────────────────────────────── - -export const MacWriteTool: ToolHandler = { - name: 'Write', - definition: { - name: 'Write', - description: - 'Write content to a file. Creates parent directories if needed. Overwrites if file exists.', - inputSchema: { - type: 'object', - properties: { - file_path: { type: 'string', description: 'Absolute path.' }, - content: { type: 'string', description: 'Full file contents to write.' }, - }, - required: ['file_path', 'content'], - }, - }, - async execute(input: Record): Promise { - try { - const filePath = pickStr(input, 'file_path', 'filePath', 'path'); - const content = pickStr(input, 'content', 'text', 'body') ?? ''; - if (!filePath) { - return { content: `Error: missing file_path${describeInput(input)}`, isError: true }; - } - // sessionId lets Rust snapshot the file (file panel Diff/History); omitted - // before the first turn creates a session — capture is best-effort. - await invoke('tool_write', { - filePath, - content, - sessionId: getActiveSessionId() ?? undefined, - }); - const lines = content.split('\n').length; - return { - content: `Wrote ${filePath} (${lines} lines).`, - data: { file: filePath, lines }, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Edit -// ────────────────────────────────────────────────────────────────────────── - -export const MacEditTool: ToolHandler = { - name: 'Edit', - definition: { - name: 'Edit', - description: - 'Replace exact `old_string` with `new_string` in a file. By default, old_string must be unique in the file (use replace_all=true to replace every occurrence).', - inputSchema: { - type: 'object', - properties: { - file_path: { type: 'string' }, - old_string: { type: 'string' }, - new_string: { type: 'string' }, - replace_all: { type: 'boolean', description: 'Default false.' }, - }, - required: ['file_path', 'old_string', 'new_string'], - }, - }, - async execute(input: Record): Promise { - try { - const filePath = pickStr(input, 'file_path', 'filePath', 'path'); - const oldStr = pickStr(input, 'old_string', 'oldString', 'old'); - const newStr = pickStr(input, 'new_string', 'newString', 'new'); - const replaceAll = pickBool(input, 'replace_all', 'replaceAll') ?? false; - if (!filePath || oldStr === undefined || newStr === undefined) { - return { - content: `Error: missing file_path / old_string / new_string${describeInput(input)}`, - isError: true, - }; - } - const r = (await invoke('tool_edit', { - input: { - file_path: filePath, - old_string: oldStr, - new_string: newStr, - replace_all: replaceAll, - }, - sessionId: getActiveSessionId() ?? undefined, - })) as { replaced: number; diffPreview: string }; - return { - content: `Replaced ${r.replaced} occurrence(s) in ${filePath}.\n${r.diffPreview}`, - data: { file: filePath, replaced: r.replaced }, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Bash -// ────────────────────────────────────────────────────────────────────────── - -export const MacBashTool: ToolHandler = { - name: 'Bash', - definition: { - name: 'Bash', - description: - 'Execute a shell command. Returns stdout + stderr + exit code. Default timeout 120s.', - inputSchema: { - type: 'object', - properties: { - command: { type: 'string' }, - cwd: { type: 'string', description: 'Optional working directory.' }, - timeout_ms: { type: 'number', description: 'Optional timeout in milliseconds.' }, - }, - required: ['command'], - }, - }, - async execute(input: Record, ctx): Promise { - try { - const command = pickStr(input, 'command', 'cmd'); - if (!command) { - return { content: 'Error: missing command', isError: true }; - } - if (ctx.signal?.aborted) { - return { content: 'aborted by user', isError: true }; - } - const commandId = `bash-${Date.now().toString(36)}-${bashCommandSeq++}`; - const onAbort = (): void => { - void invoke('tool_bash_cancel', { commandId }); - }; - ctx.signal?.addEventListener('abort', onAbort, { once: true }); - let r: { - stdout: string; - stderr: string; - exitCode: number; - timedOut: boolean; - cancelled: boolean; - }; - try { - r = (await invoke('tool_bash', { - commandId, - input: { - command, - cwd: pickStr(input, 'cwd', 'working_dir'), - timeout_ms: pickNum(input, 'timeout_ms', 'timeoutMs', 'timeout'), - }, - })) as typeof r; - } finally { - ctx.signal?.removeEventListener('abort', onAbort); - } - const combined = (r.stdout || '') + (r.stderr ? `\n[stderr]\n${r.stderr}` : ''); - return { - content: combined || `(no output, exit ${r.exitCode})`, - data: { exitCode: r.exitCode, timedOut: r.timedOut, cancelled: r.cancelled }, - isError: r.exitCode !== 0 || r.cancelled, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Glob -// ────────────────────────────────────────────────────────────────────────── - -export const MacGlobTool: ToolHandler = { - name: 'Glob', - definition: { - name: 'Glob', - description: 'Find files matching a glob pattern (e.g. `**/*.ts`).', - inputSchema: { - type: 'object', - properties: { - pattern: { type: 'string' }, - cwd: { type: 'string', description: 'Optional working directory; defaults to current.' }, - }, - required: ['pattern'], - }, - }, - async execute(input: Record): Promise { - try { - const pattern = pickStr(input, 'pattern', 'glob'); - if (!pattern) return { content: 'Error: missing pattern', isError: true }; - const r = (await invoke('tool_glob', { - pattern, - cwd: pickStr(input, 'cwd', 'path', 'working_dir'), - })) as { files: string[]; truncated: boolean }; - const body = - r.files.length === 0 - ? '(no matches)' - : r.files.join('\n') + (r.truncated ? `\n[...truncated at 1000]` : ''); - return { content: body, data: { count: r.files.length, truncated: r.truncated } }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Grep -// ────────────────────────────────────────────────────────────────────────── - -export const MacGrepTool: ToolHandler = { - name: 'Grep', - definition: { - name: 'Grep', - description: 'Search for a regex/string pattern recursively. Returns file:line:text.', - inputSchema: { - type: 'object', - properties: { - pattern: { type: 'string' }, - path: { type: 'string', description: 'Optional dir to search; defaults to cwd.' }, - include: { - type: 'string', - description: 'Optional file pattern (e.g. `*.ts`) to restrict matches.', - }, - case_insensitive: { type: 'boolean' }, - }, - required: ['pattern'], - }, - }, - async execute(input: Record): Promise { - try { - const pattern = pickStr(input, 'pattern', 'regex'); - if (!pattern) return { content: 'Error: missing pattern', isError: true }; - const r = (await invoke('tool_grep', { - input: { - pattern, - path: pickStr(input, 'path', 'cwd', 'dir'), - include: pickStr(input, 'include', 'glob'), - case_insensitive: - pickBool(input, 'case_insensitive', 'caseInsensitive', 'ignore_case') ?? false, - }, - })) as { - matches: Array<{ file: string; line: number; text: string }>; - truncated: boolean; - }; - if (r.matches.length === 0) return { content: '(no matches)' }; - const lines = r.matches.map((m) => `${m.file}:${m.line}: ${m.text}`); - if (r.truncated) lines.push('[...truncated at 500 matches]'); - return { content: lines.join('\n'), data: { count: r.matches.length } }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -/** All 6 Mac-flavored tools — pass as `tools` to `new ToolRegistry(MAC_TOOLS)`. */ -export const MAC_TOOLS: ToolHandler[] = [ - MacReadTool, - MacWriteTool, - MacEditTool, - MacBashTool, - MacGlobTool, - MacGrepTool, -]; diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts new file mode 100644 index 0000000..d3eae14 --- /dev/null +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -0,0 +1,181 @@ +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { DesktopProtocolAgent, type ProtocolTransport } from './protocol-agent.js'; + +class FakeTransport implements ProtocolTransport { + handler?: (event: ProtocolEvent) => void; + requests: Array<{ method: ProtocolMethod; params: Record }> = []; + + async connect(): Promise { + return { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + }, + }; + } + + subscribe(handler: (event: ProtocolEvent) => void): () => void { + this.handler = handler; + return () => { + this.handler = undefined; + }; + } + + async request(method: ProtocolMethod, params: Record = {}): Promise { + this.requests.push({ method, params }); + if (method === 'thread/start') return thread as T; + if (method === 'thread/resume') return thread as T; + if (method === 'turn/start') return turn as T; + if (method === 'turn/interrupt') return { interrupted: true } as T; + return { accepted: true } as T; + } +} + +const thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + turns: [], +}; + +const turn: TurnSnapshot = { + id: 'turn-1', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:01.000Z', + items: [], +}; + +describe('DesktopProtocolAgent', () => { + it('buffers fast server events until turn/start returns, then projects them in order', async () => { + vi.useFakeTimers(); + const transport = new FakeTransport(); + const events: unknown[] = []; + const agent = new DesktopProtocolAgent(transport, (event) => events.push(event)); + transport.request = async (method: ProtocolMethod, params = {}) => { + transport.requests.push({ method, params }); + if (method === 'thread/start') return thread as T; + if (method === 'turn/start') { + transport.handler?.({ type: 'turn.started', threadId: thread.id, turn }); + transport.handler?.({ + type: 'item.delta', + threadId: thread.id, + turnId: turn.id, + itemId: 'assistant', + delta: 'done', + }); + transport.handler?.({ + type: 'turn.completed', + threadId: thread.id, + turn: { ...turn, status: 'completed' }, + }); + return turn as T; + } + return { accepted: true } as T; + }; + + await expect( + agent.start({ userMessage: 'hello', cwd: '/workspace', effort: 'high' }), + ).resolves.toEqual({ turnId: turn.id, threadId: thread.id }); + expect(events).toEqual([]); + await vi.runAllTimersAsync(); + + expect(events).toEqual([ + expect.objectContaining({ type: 'text_delta', text: 'done' }), + expect.objectContaining({ kind: 'turn_done', stopReason: 'end_turn' }), + ]); + vi.useRealTimers(); + }); + + it('binds approval responses to the request context and maps tool activity', async () => { + const transport = new FakeTransport(); + const events: unknown[] = []; + const agent = new DesktopProtocolAgent(transport, (event) => events.push(event)); + await agent.resume(thread.id); + await agent.start({ userMessage: 'change it' }); + + transport.handler?.({ + type: 'tool.started', + threadId: thread.id, + turnId: turn.id, + itemId: 'tool-1', + name: 'Edit', + input: { file_path: 'a.ts' }, + }); + transport.handler?.({ + type: 'approval.requested', + threadId: thread.id, + turnId: turn.id, + requestId: 'request-1', + toolName: 'Edit', + reason: 'write needs approval', + }); + await agent.approve('request-1', 'always'); + + expect(events).toEqual([ + expect.objectContaining({ type: 'tool_use', id: 'tool-1', name: 'Edit' }), + expect.objectContaining({ type: 'permission_request', requestId: 'request-1' }), + ]); + expect(transport.requests.at(-1)).toEqual({ + method: 'approval/respond', + params: { + threadId: thread.id, + turnId: turn.id, + requestId: 'request-1', + decision: 'always', + }, + }); + await expect(agent.approve('request-1', 'allow')).rejects.toThrow('not found'); + }); + + it('interrupts only known active turns', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await agent.resume(thread.id); + await agent.start({ userMessage: 'wait' }); + + await expect(agent.abort(turn.id)).resolves.toBe(true); + await expect(agent.abort('unknown')).resolves.toBe(false); + expect(transport.requests.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: thread.id, turnId: turn.id }, + }); + }); + + it('drops late events after clearing an active thread', async () => { + const transport = new FakeTransport(); + const events: unknown[] = []; + const agent = new DesktopProtocolAgent(transport, (event) => events.push(event)); + await agent.resume(thread.id); + await agent.start({ userMessage: 'wait' }); + + agent.clear(); + transport.handler?.({ + type: 'item.delta', + threadId: thread.id, + turnId: turn.id, + itemId: 'assistant', + delta: 'too late', + }); + + expect(events).toEqual([]); + expect(transport.requests.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: thread.id, turnId: turn.id }, + }); + }); +}); diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts new file mode 100644 index 0000000..87662ea --- /dev/null +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -0,0 +1,306 @@ +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; + +import { setActiveSessionId } from './mac-session.js'; +import { DesktopProtocolClient } from './protocol-client.js'; + +export interface ProtocolTransport { + connect(): Promise; + request(method: ProtocolMethod, params?: Record): Promise; + subscribe(handler: (event: ProtocolEvent) => void): () => void; +} + +export interface StartProtocolTurnArgs { + userMessage: string; + cwd?: string; + mode?: string; + model?: string; + effort?: string; +} + +export interface DesktopAgentEvent { + kind: 'event' | 'turn_done'; + turnId: string; + [key: string]: unknown; +} + +type PendingInteraction = + | { kind: 'approval'; threadId: string; turnId: string } + | { kind: 'user-input'; threadId: string; turnId: string }; + +export class DesktopProtocolAgent { + private threadId: string | null = null; + private readonly activeTurns = new Map(); + private readonly pendingInteractions = new Map(); + private readonly queuedTurns = new Map(); + + constructor( + private readonly transport: ProtocolTransport, + private readonly emit: (event: DesktopAgentEvent) => void, + ) { + transport.subscribe((event) => this.receive(event)); + } + + async start(args: StartProtocolTurnArgs): Promise<{ turnId: string; threadId: string }> { + await this.transport.connect(); + if (!this.threadId) { + const thread = await this.transport.request('thread/start', { + cwd: args.cwd ?? '/', + }); + this.adoptThread(thread.id); + } + const threadId = this.threadId; + if (!threadId) throw new Error('app-server did not create a thread'); + const turn = await this.transport.request('turn/start', { + threadId, + input: { + text: args.userMessage, + ...(args.mode ? { mode: args.mode } : {}), + ...(args.model ? { model: args.model } : {}), + ...(args.effort ? { effort: args.effort } : {}), + }, + }); + this.activeTurns.set(turn.id, threadId); + // The server can emit a complete fast turn before its start response reaches + // the renderer. Flush on the next task so React records the returned turn id + // before a terminal notification clears it. + setTimeout(() => this.flushTurn(turn.id), 0); + return { turnId: turn.id, threadId }; + } + + async resume(threadId: string): Promise { + await this.transport.connect(); + if (this.threadId && this.threadId !== threadId) { + await this.interruptActiveTurns(); + } + const thread = await this.transport.request('thread/resume', { threadId }); + this.adoptThread(thread.id); + return thread; + } + + clear(): void { + void this.interruptActiveTurns(); + this.threadId = null; + setActiveSessionId(null); + } + + async abort(turnId: string): Promise { + const threadId = this.activeTurns.get(turnId); + if (!threadId) return false; + const result = await this.transport.request<{ interrupted: boolean }>('turn/interrupt', { + threadId, + turnId, + }); + return result.interrupted; + } + + async approve(requestId: string, decision: 'allow' | 'deny' | 'always'): Promise { + const pending = this.requireInteraction(requestId, 'approval'); + await this.transport.request('approval/respond', { + threadId: pending.threadId, + turnId: pending.turnId, + requestId, + decision, + }); + this.pendingInteractions.delete(requestId); + } + + async answer(requestId: string, answer: string): Promise { + const pending = this.requireInteraction(requestId, 'user-input'); + await this.transport.request('user-input/respond', { + threadId: pending.threadId, + turnId: pending.turnId, + requestId, + answer, + }); + this.pendingInteractions.delete(requestId); + } + + private adoptThread(threadId: string): void { + this.threadId = threadId; + setActiveSessionId(threadId); + } + + private receive(event: ProtocolEvent): void { + const turnId = turnIdFrom(event); + if (event.type === 'turn.started' && !this.activeTurns.has(turnId!)) { + // A turn can finish before the response to turn/start reaches us. Buffer + // only notifications for the currently adopted thread; late events from + // an interrupted or previously selected thread must never reach the UI. + if (event.threadId === this.threadId) this.queuedTurns.set(turnId!, [event]); + return; + } + if (turnId && this.queuedTurns.has(turnId)) { + this.queuedTurns.get(turnId)!.push(event); + return; + } + if (turnId && !this.activeTurns.has(turnId)) return; + this.project(event); + } + + private flushTurn(turnId: string): void { + const events = this.queuedTurns.get(turnId) ?? []; + this.queuedTurns.delete(turnId); + for (const event of events) this.project(event); + } + + private project(event: ProtocolEvent): void { + switch (event.type) { + case 'item.delta': + this.emit({ kind: 'event', turnId: event.turnId, type: 'text_delta', text: event.delta }); + break; + case 'tool.started': + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'tool_use', + id: event.itemId, + name: event.name, + input: event.input, + }); + break; + case 'tool.completed': + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'tool_result', + id: event.itemId, + result: event.result, + }); + break; + case 'usage.updated': + this.emit({ kind: 'event', turnId: event.turnId, type: 'usage', ...event.usage }); + break; + case 'approval.requested': + this.pendingInteractions.set(event.requestId, { + kind: 'approval', + threadId: event.threadId, + turnId: event.turnId, + }); + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'permission_request', + requestId: event.requestId, + toolName: event.toolName, + reason: event.reason, + }); + break; + case 'user-input.requested': + this.pendingInteractions.set(event.requestId, { + kind: 'user-input', + threadId: event.threadId, + turnId: event.turnId, + }); + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'ask_user', + requestId: event.requestId, + question: event.question, + options: event.options, + multiSelect: event.multiSelect, + }); + break; + case 'turn.completed': + this.finish(event.turn.id, 'end_turn'); + break; + case 'turn.interrupted': + this.finish(event.turn.id, 'aborted'); + break; + case 'turn.failed': { + const error = [...event.turn.items].reverse().find((item) => item.type === 'error') + ?.payload.message; + if (typeof error === 'string') { + this.emit({ kind: 'event', turnId: event.turn.id, type: 'error', error }); + } + this.finish(event.turn.id, 'error'); + break; + } + } + } + + private finish(turnId: string, stopReason: 'end_turn' | 'aborted' | 'error'): void { + if (!this.activeTurns.delete(turnId)) return; + for (const [requestId, pending] of this.pendingInteractions) { + if (pending.turnId === turnId) this.pendingInteractions.delete(requestId); + } + this.emit({ kind: 'turn_done', turnId, stopReason }); + } + + private requireInteraction( + requestId: string, + kind: K, + ): Extract { + const pending = this.pendingInteractions.get(requestId); + if (!pending || pending.kind !== kind) { + throw new Error(`Pending ${kind} request not found: ${requestId}`); + } + return pending as Extract; + } + + private async interruptActiveTurns(): Promise { + const turns = [...this.activeTurns].map(([turnId, threadId]) => ({ turnId, threadId })); + // Detach first so late deltas and terminal notifications from the previous + // selection are ignored even if the interrupt response is delayed. + this.activeTurns.clear(); + this.pendingInteractions.clear(); + this.queuedTurns.clear(); + await Promise.allSettled( + turns.map(({ turnId, threadId }) => + this.transport.request('turn/interrupt', { threadId, turnId }), + ), + ); + } +} + +function turnIdFrom(event: ProtocolEvent): string | undefined { + if (event.type === 'thread.started') return undefined; + if (event.type === 'turn.started') return event.turn.id; + if ( + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ) { + return event.turn.id; + } + return event.turnId; +} + +let emitToRenderer: (event: DesktopAgentEvent) => void = () => undefined; +const defaultAgent = new DesktopProtocolAgent(new DesktopProtocolClient(), (event) => + emitToRenderer(event), +); + +export function installProtocolAgentEmitter(emit: (event: DesktopAgentEvent) => void): void { + emitToRenderer = emit; +} + +export function startProtocolTurn(args: StartProtocolTurnArgs) { + return defaultAgent.start(args); +} + +export function resumeProtocolThread(threadId: string) { + return defaultAgent.resume(threadId); +} + +export function clearProtocolThread(): void { + defaultAgent.clear(); +} + +export function abortProtocolTurn(turnId: string) { + return defaultAgent.abort(turnId); +} + +export function approveProtocolRequest(requestId: string, decision: 'allow' | 'deny' | 'always') { + return defaultAgent.approve(requestId, decision); +} + +export function answerProtocolRequest(requestId: string, answer: string) { + return defaultAgent.answer(requestId, answer); +} diff --git a/apps/desktop/src/lib/protocol-client.test.ts b/apps/desktop/src/lib/protocol-client.test.ts index 8ff16e4..ef9a32c 100644 --- a/apps/desktop/src/lib/protocol-client.test.ts +++ b/apps/desktop/src/lib/protocol-client.test.ts @@ -41,6 +41,8 @@ class FakeBridge implements ProtocolClientBridge { turnInterrupt: true, completedItemPersistence: true, transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, }, } : { ok: true }, @@ -64,6 +66,8 @@ describe('DesktopProtocolClient', () => { ); expect(bridge.started).toBe(1); expect(bridge.requests[0]).toEqual({ id: 1, method: 'initialize', params: {} }); + await client.connect(); + expect(bridge.started).toBe(1); await client.close(); expect(bridge.stopped).toBe(1); }); diff --git a/apps/desktop/src/lib/protocol-client.ts b/apps/desktop/src/lib/protocol-client.ts index 91ab30b..dadb197 100644 --- a/apps/desktop/src/lib/protocol-client.ts +++ b/apps/desktop/src/lib/protocol-client.ts @@ -44,6 +44,8 @@ export class DesktopProtocolClient { private readonly subscribers = new Set<(event: ProtocolEvent) => void>(); private nextId = 1; private unlisten?: () => void; + private initialized?: InitializeResult; + private connecting?: Promise; constructor( private readonly bridge: ProtocolClientBridge = tauriBridge, @@ -51,6 +53,17 @@ export class DesktopProtocolClient { ) {} async connect(): Promise { + if (this.initialized) return this.initialized; + if (this.connecting) return this.connecting; + this.connecting = this.open(); + try { + return await this.connecting; + } finally { + this.connecting = undefined; + } + } + + private async open(): Promise { if (!this.unlisten) this.unlisten = await this.bridge.listen((output) => this.receive(output)); await this.bridge.start(); const initialized = await this.request('initialize'); @@ -58,6 +71,7 @@ export class DesktopProtocolClient { await this.close(); throw new Error(`Unsupported app-server protocol version: ${initialized.protocolVersion}`); } + this.initialized = initialized; return initialized; } @@ -92,11 +106,13 @@ export class DesktopProtocolClient { this.rejectAll(new Error('app-server client closed')); this.unlisten?.(); this.unlisten = undefined; + this.initialized = undefined; await this.bridge.stop(); } private receive(output: AppServerOutput): void { if (output.stream === 'terminated') { + this.initialized = undefined; this.rejectAll( new Error( `app-server terminated (code=${output.code ?? 'none'}, signal=${output.signal ?? 'none'})`, diff --git a/apps/desktop/src/lib/tauri-api.test.ts b/apps/desktop/src/lib/tauri-api.test.ts index 1b390ff..6e9b63d 100644 --- a/apps/desktop/src/lib/tauri-api.test.ts +++ b/apps/desktop/src/lib/tauri-api.test.ts @@ -15,15 +15,13 @@ import { appServerStatus, appServerStop, appendAllowMatcher, + credentialStatus, getAppInfo, listPlugins, listSkills, loadSettingsFile, - readCredentials, saveCredentials, saveSettingsFile, - sessionAppend, - sessionCreate, } from './tauri-api.js'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); @@ -33,26 +31,14 @@ beforeEach(() => { invokeMock.mockReset(); }); -describe('readCredentials', () => { - it('maps Rust snake_case → renderer camelCase (the §8a direction)', async () => { - invokeMock.mockResolvedValue({ - api_key: 'sk-123', - auth_token: 'tok-9', - base_url: 'https://api.deepseek.com/v1', - }); - const creds = await readCredentials(); - expect(invokeMock).toHaveBeenCalledWith('read_credentials'); - expect(creds).toEqual({ - apiKey: 'sk-123', - authToken: 'tok-9', +describe('credentialStatus', () => { + it('returns only presence and endpoint metadata to the renderer', async () => { + invokeMock.mockResolvedValue({ hasKey: true, baseUrl: 'https://api.deepseek.com/v1' }); + await expect(credentialStatus()).resolves.toEqual({ + hasKey: true, baseURL: 'https://api.deepseek.com/v1', }); - }); - - it('leaves missing fields undefined (does not invent empty strings)', async () => { - invokeMock.mockResolvedValue({ api_key: 'only-key' }); - const creds = await readCredentials(); - expect(creds).toEqual({ apiKey: 'only-key', authToken: undefined, baseURL: undefined }); + expect(invokeMock).toHaveBeenCalledWith('credential_status'); }); }); @@ -64,16 +50,6 @@ describe('saveCredentials', () => { creds: { api_key: 'sk-x', auth_token: 'tok', base_url: 'https://h/v1' }, }); }); - - it('round-trips with readCredentials (save shape decodes back to the same camelCase)', async () => { - invokeMock.mockResolvedValue(undefined); - const input = { apiKey: 'a', authToken: 'b', baseURL: 'c' }; - await saveCredentials(input); - const sent = invokeMock.mock.calls[0]![1] as { creds: Record }; - // Simulate the backend echoing those stored fields back on read. - invokeMock.mockResolvedValue(sent.creds); - expect(await readCredentials()).toEqual(input); - }); }); describe('command name + argument contracts', () => { @@ -118,20 +94,6 @@ describe('command name + argument contracts', () => { await appendAllowMatcher('Write'); expect(invokeMock).toHaveBeenCalledWith('append_allow_matcher', { matcher: 'Write' }); }); - - it('sessionCreate → session_create with { cwd } and returns the id', async () => { - invokeMock.mockResolvedValue('sess-abc'); - const id = await sessionCreate('/proj'); - expect(invokeMock).toHaveBeenCalledWith('session_create', { cwd: '/proj' }); - expect(id).toBe('sess-abc'); - }); - - it('sessionAppend → session_append with { id, message }', async () => { - invokeMock.mockResolvedValue(undefined); - const msg = { type: 'message', role: 'user', content: [] }; - await sessionAppend('sess-abc', msg); - expect(invokeMock).toHaveBeenCalledWith('session_append', { id: 'sess-abc', message: msg }); - }); }); describe('listPlugins', () => { diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index b5d56c7..dacaf28 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -51,18 +51,9 @@ export async function appServerStatus(): Promise { return invoke('app_server_status'); } -export async function readCredentials(): Promise { - // Backend uses snake_case Rust fields; convert. - const raw = (await invoke('read_credentials')) as { - api_key?: string; - auth_token?: string; - base_url?: string; - }; - return { - apiKey: raw.api_key, - authToken: raw.auth_token, - baseURL: raw.base_url, - }; +export async function credentialStatus(): Promise<{ hasKey: boolean; baseURL?: string }> { + const raw = (await invoke('credential_status')) as { hasKey: boolean; baseUrl?: string }; + return { hasKey: raw.hasKey, baseURL: raw.baseUrl }; } export async function saveCredentials(creds: Credentials): Promise { @@ -178,11 +169,6 @@ export async function listSkills(cwd?: string): Promise { return (await invoke('list_skills', { cwd })) as SkillInfo[]; } -/** Create a new session JSONL file. Returns the generated id. */ -export async function sessionCreate(cwd: string): Promise { - return (await invoke('session_create', { cwd })) as string; -} - /** Set (or clear, with '') a session's manual title. */ export async function sessionSetTitle(id: string, title: string): Promise { await invoke('session_set_title', { id, title }); @@ -198,11 +184,6 @@ export async function sessionArchive(id: string): Promise { await invoke('session_archive', { id }); } -/** Append one JSON message line to a session's JSONL file. */ -export async function sessionAppend(id: string, message: Record): Promise { - await invoke('session_append', { id, message }); -} - /** A stored message line as written to a session's JSONL. */ export interface StoredMessageLine { type?: string; diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index 0d4e206..db81581 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -2,25 +2,30 @@ // Keeps the existing React screens working after the Electron → Tauri pivot. // Canonical type lives in src/types/global.d.ts (DeepCodeAPI). -import type { AgentEvent, Mode } from '@deepcode/core/dist/types.js'; import type { DeepCodeAPI } from '../types/global.js'; -import { abortAgentTurn, clearHistory, resumeSession, startAgentTurn } from './mac-agent.js'; import { loadProjectPath } from './project.js'; import { - appendAllowMatcher, + abortProtocolTurn, + answerProtocolRequest, + approveProtocolRequest, + installProtocolAgentEmitter, + resumeProtocolThread, + startProtocolTurn, +} from './protocol-agent.js'; +import { + credentialStatus, getAppInfo, listPlugins, listSessions, listSkills, loadSettingsFile, openUrl, - readCredentials, saveCredentials, sessionRead, } from './tauri-api.js'; // In-memory event bus: every agent.start() call ID maps to an array of -// listeners. We fan-out the AgentEvents from mac-agent to every listener. +// listeners. We fan out stable protocol projections to every listener. type Listener = (e: unknown) => void; const listeners: Listener[] = []; @@ -34,20 +39,8 @@ function emitEvent(e: unknown): void { } } -// Approval round-trips: mac-agent calls onApproval with a promise; we emit -// a `permission_request` event carrying a unique requestId and stash the -// resolver here. The UI calls api.agent.approve({ requestId, decision }) -// which pops the resolver and resolves the original promise. -const pendingApprovals = new Map void>(); -// AskUserQuestion round-trips: same pattern — emit an `ask_user` event, stash -// the resolver, resolve it when the UI calls api.agent.answer({ requestId, answer }). -const pendingQuestions = new Map void>(); - -function nextRequestId(): string { - return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; -} - export function installTauriShim(): void { + installProtocolAgentEmitter(emitEvent); const api: DeepCodeAPI = { async version() { const info = await getAppInfo(); @@ -55,8 +48,7 @@ export function installTauriShim(): void { }, creds: { async load() { - const c = await readCredentials(); - return { hasKey: !!(c.apiKey || c.authToken), baseURL: c.baseURL }; + return credentialStatus(); }, async save({ apiKey, baseURL }) { await saveCredentials({ apiKey, baseURL }); @@ -79,15 +71,13 @@ export function installTauriShim(): void { })); }, async resume({ id }) { - // Read the session's stored messages and adopt them into the agent so - // the conversation continues with full context + appends to this file. + await resumeProtocolThread(id); const lines = await sessionRead(id); const history = lines.map((l) => ({ role: l.role, content: l.content, timestamp: l.timestamp ?? '', })) as unknown as import('@deepcode/core/dist/types.js').StoredMessage[]; - resumeSession(id, history); return { history, sessionId: id }; }, }, @@ -148,72 +138,23 @@ export function installTauriShim(): void { }, agent: { async start({ userMessage, model, mode, effort, cwd }) { - // Pre-allocate turn ID so onEvent callbacks can reference it - // without waiting for the promise to resolve. - let pendingTurnId = `pending-${Date.now()}`; - const result = await startAgentTurn({ + const result = await startProtocolTurn({ userMessage, model, - mode: mode as Mode | undefined, + mode, cwd, - effort: effort as 'low' | 'medium' | 'high' | 'xhigh' | 'max' | undefined, - onEvent: (e: AgentEvent) => emitEvent({ kind: 'event', turnId: pendingTurnId, ...e }), - onDone: (reason) => - emitEvent({ kind: 'turn_done', turnId: pendingTurnId, stopReason: reason }), - onApproval: (toolName, reason) => { - // Mint a request ID, emit it as a synthetic event, and return - // a promise the UI resolves via agent.approve(). - const requestId = nextRequestId(); - return new Promise<'allow' | 'deny' | 'always'>((resolve) => { - pendingApprovals.set(requestId, resolve); - emitEvent({ - kind: 'event', - turnId: pendingTurnId, - type: 'permission_request', - requestId, - toolName, - reason, - }); - }); - }, - onAskUser: (req) => { - const requestId = nextRequestId(); - return new Promise((resolve) => { - pendingQuestions.set(requestId, resolve); - emitEvent({ - kind: 'event', - turnId: pendingTurnId, - type: 'ask_user', - requestId, - question: req.question, - options: req.options, - multiSelect: req.multiSelect, - }); - }); - }, + effort, }); - pendingTurnId = result.turnId; - return result; + return { turnId: result.turnId, sessionId: result.threadId }; }, async abort({ turnId }) { - return abortAgentTurn(turnId); + return abortProtocolTurn(turnId); }, async approve({ requestId, decision }) { - // Persistence note: when `decision === 'always'`, the caller is - // expected to also have called `appendAllowMatcher(toolName)` so - // the rule survives the next session. We don't do it here because - // the shim no longer has access to the toolName by the time the - // user decides. See ReplScreen.tsx where this is wired. - const resolver = pendingApprovals.get(requestId); - if (!resolver) return; // no-op if already resolved (e.g. stale click) - pendingApprovals.delete(requestId); - resolver(decision); + await approveProtocolRequest(requestId, decision); }, async answer({ requestId, answer }) { - const resolver = pendingQuestions.get(requestId); - if (!resolver) return; // stale / already answered - pendingQuestions.delete(requestId); - resolver(answer); + await answerProtocolRequest(requestId, answer); }, onEvent(cb: (e: unknown) => void): () => void { listeners.push(cb); diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index 89e0a7a..7bf1e76 100644 --- a/apps/desktop/src/preview-app.tsx +++ b/apps/desktop/src/preview-app.tsx @@ -146,8 +146,8 @@ const MOCK_MESSAGES = [ switch (cmd) { case 'load_settings_file': return { projectPath: '/Users/oratis/Projects/DeepCode/test' }; - case 'read_credentials': - return { api_key: 'sk-mock', base_url: 'https://api.deepseek.com/v1' }; + case 'credential_status': + return { hasKey: true, baseUrl: 'https://api.deepseek.com/v1' }; case 'get_app_info': return { version: '0.1.6', platform: 'macos', home_dir: '/Users/oratis' }; case 'get_settings_path': diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index bf25d70..5be107a 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -24,7 +24,7 @@ import { type KeyBinding, type VimMode, } from '@deepcode/core/dist/keybindings/vim.js'; -import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js'; +import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js'; import { estimateCost } from '@deepcode/core/dist/providers/pricing.js'; import { Dropdown, type DropdownOption } from '../components/Dropdown.js'; import { Pill } from '../components/Pill.js'; @@ -55,6 +55,8 @@ interface ReplScreenProps { projectPath: string; /** Called after each turn ends so the parent can refresh the sidebar. */ onTurnComplete?: () => void; + /** Called once the backend creates/adopts the canonical thread id. */ + onSessionStarted?: (sessionId: string) => void; /** * Pre-seed the chat with a resumed session's reconstructed messages. The * parent remounts ReplScreen (via key) when this changes, so it's only read @@ -211,6 +213,7 @@ interface PendingQuestion { export function ReplScreen({ projectPath, onTurnComplete, + onSessionStarted, initialMessages, onInspector, onOpenFile, @@ -585,6 +588,7 @@ export function ReplScreen({ cwd: projectPath, }); setActiveTurnId(r.turnId); + if (r.sessionId) onSessionStarted?.(r.sessionId); } catch (err) { setBusy(false); setMessages((m) => [ diff --git a/apps/desktop/src/types/global.d.ts b/apps/desktop/src/types/global.d.ts index 5aff599..2283b13 100644 --- a/apps/desktop/src/types/global.d.ts +++ b/apps/desktop/src/types/global.d.ts @@ -74,7 +74,7 @@ export interface DeepCodeAPI { /** Absolute project folder path. When unset, tools error. */ cwd?: string; allowedTools?: string[]; - }) => Promise<{ turnId: string }>; + }) => Promise<{ turnId: string; sessionId?: string }>; abort: (args: { turnId: string }) => Promise; /** Resolve an in-flight permission_request event. `decision === 'always'` * also persists a matcher to ~/.deepcode/settings.json. */ diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 5f7ae9d..73bfe0f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -38,16 +38,14 @@ export default defineConfig({ resolve: { alias: [ // Subpath imports — load directly from compiled dist/. The renderer - // can't bundle some core modules (node:fs deps), so we cherry-pick - // (only agent.js / providers/deepseek.js / types.js are referenced - // from the renderer code). + // can't bundle Node-backed core modules, so UI-only helpers are + // cherry-picked from compiled subpaths. The agent runtime is a sidecar. { find: /^@deepcode\/core\/dist\/(.+)$/, replacement: resolve(__dirname, '..', '..', 'packages', 'core', 'dist') + '/$1', }, // Bare import — anything that resolves through the index. We avoid - // doing this in the renderer (use mac-tools/mac-agent which import - // from subpaths) but keep the alias so types still resolve. + // doing this in the renderer, but keep the alias so types still resolve. { find: '@deepcode/core', replacement: resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'index.ts'), diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 01bbc2f..771bb60 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -1,14 +1,32 @@ import { CredentialsStore, resolveCredentials } from '@deepcode/core/credentials'; +import { loadSettings } from '@deepcode/core/config'; import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; import { RuntimeHost, SAFE_READONLY_TOOLS } from '@deepcode/core/runtime'; +import { SessionManager } from '@deepcode/core/sessions'; import { BUILTIN_TOOLS, ToolRegistry } from '@deepcode/core/tools'; import { RuntimeHostExecutor } from './runtime-executor.js'; -export function createDefaultTurnExecutor(): RuntimeHostExecutor { +export function createDefaultTurnExecutor( + home?: string, + options: { forceFileCredentials?: boolean } = {}, +): RuntimeHostExecutor { + const sessionManager = new SessionManager({ + root: home ? `${home}/sessions` : undefined, + }); return new RuntimeHostExecutor({ createHost: async (cwd, mode) => { - const credentials = await resolveCredentials({ store: new CredentialsStore() }); + const loaded = await loadSettings({ cwd, directory: home }); + // Until trust provenance moves into RuntimeHost, only user-level settings + // may widen the desktop sidecar's permissions or sandbox profile. + const settings = loaded.layers.user ?? {}; + const credentials = await resolveCredentials({ + store: new CredentialsStore({ + directory: home, + forceFile: options.forceFileCredentials, + }), + apiKeyHelper: settings.apiKeyHelper, + }); if (!credentials.apiKey && !credentials.authToken) { throw new Error( 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', @@ -18,13 +36,16 @@ export function createDefaultTurnExecutor(): RuntimeHostExecutor { provider: new DeepSeekProvider({ apiKey: credentials.apiKey ?? '', authToken: credentials.authToken, - baseURL: credentials.baseURL, + baseURL: credentials.baseURL ?? settings.baseURL, }), tools: new ToolRegistry(BUILTIN_TOOLS), cwd, mode, - permissions: { allow: [...SAFE_READONLY_TOOLS] }, + permissions: settings.permissions ?? { allow: [...SAFE_READONLY_TOOLS] }, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, }); }, + sessionManager, }); } diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts index cc0fe13..eb59030 100644 --- a/apps/server/src/run.ts +++ b/apps/server/src/run.ts @@ -13,12 +13,17 @@ export interface RunAppServerOptions { output: Writable; home: string; executor?: TurnExecutor; + forceFileCredentials?: boolean; } export async function runAppServer(options: RunAppServerOptions): Promise { const writer = new ProtocolLineWriter(options.output); const server = new AppServer({ - executor: options.executor ?? createDefaultTurnExecutor(), + executor: + options.executor ?? + createDefaultTurnExecutor(options.home, { + forceFileCredentials: options.forceFileCredentials, + }), store: new CanonicalThreadStore( join(options.home, 'threads-v1'), join(options.home, 'sessions'), diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts index 4f115b0..3e7c8a8 100644 --- a/apps/server/src/runtime-executor.test.ts +++ b/apps/server/src/runtime-executor.test.ts @@ -1,5 +1,10 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { RuntimeHost, + SessionManager, ToolRegistry, type Provider, type ProviderResult, @@ -217,4 +222,69 @@ describe('RuntimeHostExecutor', () => { ]), ); }); + + it('keeps session snapshots without becoming a second message writer', async () => { + const root = await mkdtemp(join(tmpdir(), 'deepcode-executor-session-')); + try { + const workspace = join(root, 'workspace'); + const filePath = join(workspace, 'file.txt'); + await mkdir(workspace); + await writeFile(filePath, 'before'); + const provider = new ToolProvider(); + const tools = new ToolRegistry([]); + // The core snapshot pipeline recognizes canonical Write/Edit names. + provider.runTurn = async (options) => { + provider.calls++; + if (provider.calls === 1) { + return { + content: [ + { type: 'tool_use', id: 'tool-1', name: 'Write', input: { file_path: filePath } }, + ], + stopReason: 'tool_use', + usage: { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + } + options.handlers?.onTextDelta?.('done'); + return { + content: [{ type: 'text', text: 'done' }], + stopReason: 'end_turn', + usage: { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + }; + tools.register({ + name: 'Write', + definition: { name: 'Write', description: 'write', inputSchema: { type: 'object' } }, + execute: async () => { + await writeFile(filePath, 'after'); + return { content: 'written' }; + }, + }); + const sessions = new SessionManager({ root: join(root, 'sessions') }); + const host = new RuntimeHost({ provider, tools, cwd: workspace, mode: 'default' }); + const executor = new RuntimeHostExecutor({ + createHost: () => host, + sessionManager: sessions, + }); + await executor.execute({ + thread: { ...thread, id: 'thread-snapshots', cwd: workspace, turns: [] }, + turn: { + id: 'turn-snapshots', + threadId: 'thread-snapshots', + status: 'in_progress', + startedAt: '2026-08-01T00:00:00.000Z', + items: [], + }, + input: { text: 'write' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + requestApproval: async () => 'allow', + }); + + await expect(sessions.load('thread-snapshots')).resolves.toBeNull(); + await expect(sessions.snapshots('thread-snapshots')).resolves.toHaveLength(2); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts index a27a8b9..c97e1bd 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -3,6 +3,7 @@ import { type Effort, type Mode, type RuntimeHost, + type SessionManager, type StoredMessage, } from '@deepcode/core'; import { EFFORT_PARAMS } from '@deepcode/core/dist/providers/deepseek.js'; @@ -14,6 +15,7 @@ export interface RuntimeHostExecutorOptions { createHost: (cwd: string, mode: Mode) => Promise | RuntimeHost; systemPrompt?: string; model?: string; + sessionManager?: SessionManager; } const DEFAULT_SYSTEM_PROMPT = @@ -45,6 +47,10 @@ export class RuntimeHostExecutor implements TurnExecutor { maxTokens: effortParams.maxTokens, temperature: effortParams.temperature, signal: args.signal, + session: this.options.sessionManager + ? { manager: this.options.sessionManager, id: args.thread.id } + : undefined, + persistSessionMessages: false, systemReminders: false, approval: async (toolName, _input, verdict) => { const decision = await args.requestApproval( diff --git a/apps/server/src/sidecar-entry.ts b/apps/server/src/sidecar-entry.ts index 90f0536..446af74 100644 --- a/apps/server/src/sidecar-entry.ts +++ b/apps/server/src/sidecar-entry.ts @@ -4,7 +4,12 @@ import { runAppServer } from './run.js'; const home = process.env.DEEPCODE_HOME ?? `${process.env.HOME ?? process.cwd()}/.deepcode`; -runAppServer({ input: process.stdin, output: process.stdout, home }).catch((error) => { +runAppServer({ + input: process.stdin, + output: process.stdout, + home, + forceFileCredentials: true, +}).catch((error) => { process.stderr.write(`DeepCode app-server fatal: ${(error as Error).message ?? String(error)}\n`); process.exitCode = 1; }); diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 4a3e813..9790a3e 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -286,12 +286,14 @@ model tool call ### PR 6 — Desktop runtime migration - 按 ADR 把 runtime 移出 renderer,移除 WebView 中的 provider/API key。 -- 已建立可构建的 CJS app-server、target runtime、Rust supervisor 与 renderer protocol client;迁移期 - `mac-agent` 仅作 feature fallback。 +- 已建立可构建的 CJS app-server、target runtime、Rust supervisor 与 renderer protocol client;桌面 + chat 默认且唯一使用 sidecar,旧 `mac-agent`/`mac-tools` renderer runtime 已删除。 - app-server 已补齐按 active thread/turn 绑定的 approval、AskUserQuestion、tool 与 usage 事件;interrupt 会解除所有待响应请求,避免 sidecar 因 UI 离线而悬挂。 - protocol snapshot 与 canonical session-v1 共享 id;新 thread 会进入现有 session 索引,旧 session 在首次 resume 时惰性投影为 compatibility turn,避免桌面迁移形成第二套不可见历史。 +- renderer 只能查询 credential presence/base URL,不能读取 API key/auth token;原生 mutation/bash + commands 已从 Tauri invoke surface 移除,tool 执行统一经过 RuntimeHost。 - React 只消费协议事件;接入真实 interrupt、恢复与 structured items。 - 把 `preview-app.html` 变成自动化 fixture harness;收敛现有 Changes/Files/Inspector。 diff --git a/docs/adr/0001-desktop-runtime-sidecar.md b/docs/adr/0001-desktop-runtime-sidecar.md index 29ffb6a..067be44 100644 --- a/docs/adr/0001-desktop-runtime-sidecar.md +++ b/docs/adr/0001-desktop-runtime-sidecar.md @@ -77,9 +77,9 @@ The first implementation build on the same host adds the production-shaped artif | Measurement | Result | | ------------------------------------- | ---------------------- | -| Bundled CommonJS app-server | 214,155 bytes | +| Bundled CommonJS app-server | 229,175 bytes | | Thin/stripped bundled runtime | 108,412,096 bytes | -| Complete sidecar-enabled `.app` | 115,355,648 bytes | +| Complete sidecar-enabled `.app` | 115,134,464 bytes | | Handshake from packaged paths | passed with empty PATH | | Nested-then-outer ad-hoc verification | strict deep pass | @@ -87,6 +87,12 @@ The release workflow now pins Node 22.23.1 and verifies the official archive SHA Tauri build. Local ad-hoc signing proves bundle structure and signing order only; Developer ID, notarization, stapling, and compressed DMG size remain release gates. +The production renderer now consumes this boundary exclusively: provider/agent/tool execution and +credential plaintext were removed from the WebView bundle, and Rust no longer exposes native +Write/Edit/Bash/Glob/Grep commands to renderer IPC. Protocol resume, interrupt, approvals, +AskUserQuestion, tool events, usage, snapshots, and canonical session projection are wired through +the supervised sidecar. + ## Options considered ### Keep the agent loop in the WebView diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index e4a3eef..4c0dffc 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -68,6 +68,13 @@ AskUserQuestion prompts are emitted with opaque request ids; responses must matc thread, turn, request id, and request kind. Interrupt and shutdown resolve pending prompts before waiting for the executor, so an abandoned UI cannot strand the server. +The desktop sidecar loads credentials from its private data directory in file-only mode because +Tauri onboarding writes that file and never returns its secret fields to the webview. It consumes +only user-level permissions/sandbox settings until project trust provenance moves into +`RuntimeHost`; project files cannot widen the desktop runtime boundary in the meantime. The +canonical SessionManager remains attached for pre/post file snapshots, with message appends +disabled because `CanonicalThreadStore` is the single message materializer. + ## Entrypoints After `pnpm build`, either command starts the same handler: diff --git a/packages/core/package.json b/packages/core/package.json index 28927ae..235552e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,10 @@ "types": "./dist/providers/deepseek.d.ts", "import": "./dist/providers/deepseek.js" }, + "./dist/providers/model-metadata.js": { + "types": "./dist/providers/model-metadata.d.ts", + "import": "./dist/providers/model-metadata.js" + }, "./dist/providers/pricing.js": { "types": "./dist/providers/pricing.d.ts", "import": "./dist/providers/pricing.js" @@ -36,6 +40,10 @@ "types": "./dist/credentials/index.d.ts", "import": "./dist/credentials/index.js" }, + "./config": { + "types": "./dist/config/index.d.ts", + "import": "./dist/config/index.js" + }, "./runtime": { "types": "./dist/runtime/index.d.ts", "import": "./dist/runtime/index.js" diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 6f386cf..6b85f51 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -59,6 +59,8 @@ export interface RunAgentOptions { onEvent?: (event: AgentEvent) => void; /** Optional: persist each turn to a session. */ session?: { manager: SessionManager; id: string }; + /** Keep session-backed snapshots while another owner materializes messages. */ + persistSessionMessages?: boolean; /** Optional: snapshot files before/after Edit/Write tool calls. */ enableSnapshots?: boolean; /** Required dispatch mode. Every tool call goes through the central gate. */ @@ -239,7 +241,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { timestamp: new Date().toISOString(), }; history.push(userMsg); - if (opts.session) await opts.session.manager.append(opts.session.id, userMsg); + if (opts.session && opts.persistSessionMessages !== false) { + await opts.session.manager.append(opts.session.id, userMsg); + } } // modeSignal is mutable — EnterPlanMode / ExitPlanMode flip these; the agent @@ -507,7 +511,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { timestamp: new Date().toISOString(), }; history.push(assistantMsg); - if (opts.session) await opts.session.manager.append(opts.session.id, assistantMsg); + if (opts.session && opts.persistSessionMessages !== false) { + await opts.session.manager.append(opts.session.id, assistantMsg); + } opts.onEvent?.({ type: 'model_step_complete', step: turnsUsed, message: assistantMsg }); @@ -697,7 +703,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { timestamp: new Date().toISOString(), }; history.push(resultMsg); - if (opts.session) await opts.session.manager.append(opts.session.id, resultMsg); + if (opts.session && opts.persistSessionMessages !== false) { + await opts.session.manager.append(opts.session.id, resultMsg); + } // M3c: auto-compact if the *current* context crossed the threshold. // diff --git a/packages/core/src/config/loader.test.ts b/packages/core/src/config/loader.test.ts index 9413a22..00ea1ca 100644 --- a/packages/core/src/config/loader.test.ts +++ b/packages/core/src/config/loader.test.ts @@ -100,6 +100,11 @@ describe('settings loader', () => { expect(p.localPath).toBe('/proj/.deepcode/settings.local.json'); }); + it('settingsPaths accepts a direct DeepCode data directory', () => { + const paths = settingsPaths({ cwd: '/proj', directory: '/custom/deepcode' }); + expect(paths.userPath).toBe('/custom/deepcode/settings.json'); + }); + it('reports parse errors loudly', async () => { const path = join(home, '.deepcode', 'settings.json'); await fs.mkdir(join(home, '.deepcode'), { recursive: true }); diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 302ad98..0d19416 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -32,14 +32,16 @@ export interface LoadSettingsOpts { cwd: string; /** Override $HOME for tests. */ home?: string; + /** Direct DeepCode data directory override (contains settings.json). */ + directory?: string; /** `--settings `: a settings file that wins over all discovered layers. */ settingsPath?: string; } export function settingsPaths(opts: LoadSettingsOpts): LoadedSettings['sources'] { - const home = opts.home ?? homedir(); + const directory = opts.directory ?? join(opts.home ?? homedir(), '.deepcode'); return { - userPath: join(home, '.deepcode', 'settings.json'), + userPath: join(directory, 'settings.json'), projectPath: resolve(opts.cwd, '.deepcode', 'settings.json'), localPath: resolve(opts.cwd, '.deepcode', 'settings.local.json'), }; diff --git a/packages/core/src/credentials/index.test.ts b/packages/core/src/credentials/index.test.ts index 3fe67de..355089a 100644 --- a/packages/core/src/credentials/index.test.ts +++ b/packages/core/src/credentials/index.test.ts @@ -28,6 +28,14 @@ describe('CredentialsStore (file backend)', () => { expect(got.baseURL).toBe('https://x'); }); + it('accepts a direct DeepCode data directory', async () => { + const directory = join(home, 'custom-data'); + const store = new CredentialsStore({ directory, forceFile: true }); + await store.save({ apiKey: 'direct' }); + expect(store.filePath()).toBe(join(directory, 'credentials.json')); + await expect(store.load()).resolves.toEqual({ apiKey: 'direct' }); + }); + it('file has mode 0600 after save', async () => { const s = new CredentialsStore({ home, forceFile: true }); await s.save({ apiKey: 'sk-test' }); diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index 4ba1bb4..6f79b1e 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -24,21 +24,23 @@ export interface Credentials { export interface CredentialsStoreOpts { home?: string; + /** Direct DeepCode data directory override (contains credentials.json). */ + directory?: string; /** Force file-backend (skip Keychain) — useful for tests. */ forceFile?: boolean; } export class CredentialsStore { - private readonly home: string; + private readonly directory: string; private readonly useKeychain: boolean; constructor(opts: CredentialsStoreOpts = {}) { - this.home = opts.home ?? homedir(); + this.directory = opts.directory ?? join(opts.home ?? homedir(), '.deepcode'); this.useKeychain = !opts.forceFile && platform() === 'darwin'; } filePath(): string { - return join(this.home, '.deepcode', 'credentials.json'); + return join(this.directory, 'credentials.json'); } async load(): Promise { diff --git a/packages/core/src/providers/deepseek.ts b/packages/core/src/providers/deepseek.ts index a0290a8..aecda45 100644 --- a/packages/core/src/providers/deepseek.ts +++ b/packages/core/src/providers/deepseek.ts @@ -3,8 +3,9 @@ // Effort numbers: docs/design/effort-levels.md §3.2 import OpenAI from 'openai'; -import type { ContentBlock, DeepSeekModel, Effort, StoredMessage, ToolUseBlock } from '../types.js'; +import type { ContentBlock, Effort, StoredMessage, ToolUseBlock } from '../types.js'; import type { Provider, ProviderResult, ProviderRunOpts } from './types.js'; +export { DEEPSEEK_MODELS, DEFAULT_CONTEXT_WINDOW, contextWindowFor } from './model-metadata.js'; export interface DeepSeekProviderOpts { apiKey: string; @@ -15,29 +16,6 @@ export interface DeepSeekProviderOpts { fetch?: typeof globalThis.fetch; } -// Validated against real DeepSeek API 2026-05-28: max_tokens hard limit is 8192, -// context window 128k. The two "logical" model names are stable API aliases that -// currently route to the V4 family. -export const DEEPSEEK_MODELS: Record = { - 'deepseek-chat': { ctx: 128_000, maxOutput: 8_192 }, - 'deepseek-reasoner': { ctx: 128_000, maxOutput: 8_192 }, - 'deepseek-v4-flash': { ctx: 128_000, maxOutput: 8_192 }, - 'deepseek-v4-pro': { ctx: 128_000, maxOutput: 8_192 }, -}; - -/** Fallback context window for an unrecognized model id. */ -export const DEFAULT_CONTEXT_WINDOW = 128_000; - -/** - * Context-window size (tokens) for a model id. Single source of truth for the - * context-bar + auto-compact threshold across CLI and desktop — avoids the - * 128_000 literal drifting out of sync with DEEPSEEK_MODELS. Falls back to - * DEFAULT_CONTEXT_WINDOW for unknown (e.g. user-typed) model ids. - */ -export function contextWindowFor(model: string): number { - return DEEPSEEK_MODELS[model as DeepSeekModel]?.ctx ?? DEFAULT_CONTEXT_WINDOW; -} - /** * Effort → DeepSeek API parameters. * Numbers from docs/design/effort-levels.md §3.2. diff --git a/packages/core/src/providers/model-metadata.ts b/packages/core/src/providers/model-metadata.ts new file mode 100644 index 0000000..ce2ef56 --- /dev/null +++ b/packages/core/src/providers/model-metadata.ts @@ -0,0 +1,19 @@ +import type { DeepSeekModel } from '../types.js'; + +// Keep model metadata in a dependency-free module. Renderer surfaces may use +// these values without pulling the provider implementation or OpenAI SDK into +// their production bundle. +export const DEEPSEEK_MODELS: Record = { + 'deepseek-chat': { ctx: 128_000, maxOutput: 8_192 }, + 'deepseek-reasoner': { ctx: 128_000, maxOutput: 8_192 }, + 'deepseek-v4-flash': { ctx: 128_000, maxOutput: 8_192 }, + 'deepseek-v4-pro': { ctx: 128_000, maxOutput: 8_192 }, +}; + +/** Fallback context window for an unrecognized model id. */ +export const DEFAULT_CONTEXT_WINDOW = 128_000; + +/** Context-window size for a model, with a safe fallback for custom ids. */ +export function contextWindowFor(model: string): number { + return DEEPSEEK_MODELS[model as DeepSeekModel]?.ctx ?? DEFAULT_CONTEXT_WINDOW; +} From 10c73549463c57c515ea53c932d30c0fd5b56d95 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:19:51 +0800 Subject: [PATCH 16/23] test: automate desktop protocol journey --- .github/workflows/ci.yml | 28 ++++ .gitignore | 2 + apps/desktop/e2e/desktop-preview.spec.ts | 81 ++++++++++ apps/desktop/package.json | 2 + apps/desktop/playwright.config.ts | 25 +++ apps/desktop/src/App.tsx | 28 ++-- apps/desktop/src/lib/repl-stream.test.ts | 27 ++++ apps/desktop/src/lib/repl-stream.ts | 54 +++++-- apps/desktop/src/preview-app.tsx | 191 ++++++++++++++++++++++- docs/CODEX_ALIGNMENT_PLAN.md | 3 +- pnpm-lock.yaml | 38 +++++ 11 files changed, 450 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/e2e/desktop-preview.spec.ts create mode 100644 apps/desktop/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 151b181..9509470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,34 @@ jobs: - name: Verify current documentation run: node scripts/check-docs.mjs + desktop-preview: + name: Desktop protocol journey + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'pnpm' + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Install Chromium + run: pnpm --filter @deepcode/desktop exec playwright install --with-deps chromium + - name: Exercise the desktop protocol fixture + run: pnpm --filter @deepcode/desktop test:e2e + - name: Upload browser diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: desktop-playwright-report + path: | + apps/desktop/playwright-report + apps/desktop/test-results + if-no-files-found: ignore + retention-days: 7 + desktop-rust: name: Desktop Rust check + test runs-on: macos-latest diff --git a/.gitignore b/.gitignore index 31adebe..4b03661 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ yarn-error.log # Test outputs coverage/ .nyc_output/ +apps/desktop/playwright-report/ +apps/desktop/test-results/ # Electron build outputs (pre-Tauri pivot — kept for historical artifacts) apps/desktop/release/ diff --git a/apps/desktop/e2e/desktop-preview.spec.ts b/apps/desktop/e2e/desktop-preview.spec.ts new file mode 100644 index 0000000..7efc2a7 --- /dev/null +++ b/apps/desktop/e2e/desktop-preview.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; + +const composerPlaceholder = '问点什么… @ 引用文件 · / 命令 · # 写入 DEEPCODE.md'; + +test.beforeEach(async ({ page }) => { + await page.goto('/preview-app.html'); + await expect(page.locator('.app-shell')).toBeVisible(); +}); + +test('keeps the Codex-style three-column shell inside the viewport', async ({ page }, testInfo) => { + const sidebar = await page.locator('.sidebar').boundingBox(); + const main = await page.locator('.chat-main').boundingBox(); + const rail = await page.locator('.inspector-rail').boundingBox(); + + expect(sidebar).not.toBeNull(); + expect(main).not.toBeNull(); + expect(rail).not.toBeNull(); + expect(Math.round(sidebar!.width)).toBe(240); + expect(Math.round(rail!.width)).toBe(64); + expect(Math.round(main!.x)).toBe(Math.round(sidebar!.x + sidebar!.width)); + expect(Math.round(rail!.x + rail!.width)).toBe(1280); + + const overflow = await page.evaluate(() => ({ + horizontal: document.documentElement.scrollWidth - window.innerWidth, + vertical: document.documentElement.scrollHeight - window.innerHeight, + })); + expect(overflow.horizontal).toBeLessThanOrEqual(0); + expect(overflow.vertical).toBeLessThanOrEqual(0); + + await testInfo.attach('desktop-shell.png', { + body: await page.screenshot(), + contentType: 'image/png', + }); +}); + +test('resumes a thread and completes an approval-gated protocol turn', async ({ page }) => { + await page.locator('[title*="2026-06-02-aaa111"]').click(); + const main = page.getByRole('main'); + await expect( + main.getByText('Resumed session — earlier conversation loaded below.'), + ).toBeVisible(); + await expect(main.getByText('制作一个打飞机的小游戏', { exact: true })).toBeVisible(); + + const composer = page.getByPlaceholder(composerPlaceholder, { exact: true }); + await composer.fill('Add a boss phase'); + await composer.press('Enter'); + + const approve = page.getByRole('button', { name: /^Approve \(↵\)$/ }); + await expect(approve).toBeVisible(); + await expect(main.getByText(/I’ll update the game safely\./)).toBeVisible(); + await expect(main.locator('.tool-card').filter({ hasText: 'Edit' }).last()).toBeVisible(); + + await approve.click(); + + await expect(main.getByText(/The boss encounter is ready\./)).toBeVisible(); + await expect(main.getByText('Updated the boss encounter.', { exact: true }).last()).toBeVisible(); + await expect(main.getByText('2,304 / 128,000', { exact: true })).toBeVisible(); + await expect(approve).toBeHidden(); + await expect(composer).toBeEnabled(); + await expect(main.getByText('Add a boss phase', { exact: true })).toBeVisible(); + const toolCards = main.locator('.tool-card'); + await expect(toolCards).toHaveCount(2); + await expect(toolCards.first()).toContainText('running'); + await expect(toolCards.last()).toContainText('done'); +}); + +test('opens source, diff, and history from the file activity rail', async ({ page }) => { + await page.locator('[title*="2026-06-02-aaa111"]').click(); + await page.getByRole('button', { name: 'Files', exact: true }).click(); + + const panel = page.getByTestId('file-panel'); + await expect(panel).toBeVisible(); + await expect(panel.getByText('打飞机.html', { exact: true })).toBeVisible(); + await expect(panel.getByText('', { exact: true })).toBeVisible(); + + await panel.getByRole('button', { name: 'Diff', exact: true }).click(); + await expect(panel.locator('.fp-diff')).toBeVisible(); + + await panel.getByRole('button', { name: 'History', exact: true }).click(); + await expect(panel.locator('.fp-hist-row')).toHaveCount(3); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a733102..5823fb5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,6 +17,7 @@ "tauri:build:universal": "tauri build --target universal-apple-darwin", "typecheck": "tsc -b", "test": "vitest run --passWithNoTests", + "test:e2e": "playwright test", "lint": "echo 'lint: configured at repo root' && exit 0", "clean": "rm -rf dist src-tauri/target *.tsbuildinfo" }, @@ -35,6 +36,7 @@ "react-dom": "^18.3.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@tauri-apps/cli": "^2.0.0", "@types/node": "^22.10.0", "@types/react": "^18.3.0", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000..0cefcea --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; + +const port = 4173; +const origin = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: origin, + viewport: { width: 1280, height: 800 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: { + command: `pnpm dev --host 127.0.0.1 --port ${port}`, + url: `${origin}/preview-app.html`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f8d2c6b..ff58361 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -36,7 +36,12 @@ export function App(): JSX.Element { const [update, setUpdate] = useState(null); const [screen, setScreen] = useState('repl'); const [activeSessionId, setActiveSessionId] = useState(null); - const [sessionEpoch, setSessionEpoch] = useState(0); + // Sidebar refreshes must not remount the active REPL: a completed turn is + // persisted asynchronously and refreshing the session list used to erase + // the just-streamed transcript. Only explicit session/project transitions + // advance the REPL epoch. + const [sidebarEpoch, setSidebarEpoch] = useState(0); + const [replEpoch, setReplEpoch] = useState(0); // Reconstructed messages for a resumed session; seeded into ReplScreen on its // next remount. Cleared when starting a fresh session. const [resumedMessages, setResumedMessages] = useState(undefined); @@ -112,7 +117,8 @@ export function App(): JSX.Element { setResumedMessages(undefined); setActiveSessionId(null); setScreen('repl'); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }); const offComma = registerShortcut('meta+,', () => setScreen('settings')); const offSlash = registerShortcut('meta+/', () => setScreen('about')); @@ -194,7 +200,7 @@ export function App(): JSX.Element { )} {update && } { @@ -208,7 +214,8 @@ export function App(): JSX.Element { } setActiveSessionId(id); setScreen('repl'); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} onNewSession={() => { clearAgentHistory(); @@ -216,7 +223,8 @@ export function App(): JSX.Element { setActiveSessionId(null); setScreen('repl'); // Force ReplScreen to remount with a clean message history - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} onSwitchProject={async () => { // Force-show the picker again by clearing state. Also clear @@ -226,7 +234,8 @@ export function App(): JSX.Element { setResumedMessages(undefined); setProjectPath(null); setActiveSessionId(null); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} onSessionRemoved={() => { // The active session was archived/deleted — reset to a fresh chat. @@ -234,15 +243,16 @@ export function App(): JSX.Element { setResumedMessages(undefined); setActiveSessionId(null); setScreen('repl'); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} /> -
+
{renderScreen( screen, setScreen, projectPath, - () => setSessionEpoch((k) => k + 1), + () => setSidebarEpoch((k) => k + 1), setActiveSessionId, handleInspector, resumedMessages, diff --git a/apps/desktop/src/lib/repl-stream.test.ts b/apps/desktop/src/lib/repl-stream.test.ts index 3381f29..2240b85 100644 --- a/apps/desktop/src/lib/repl-stream.test.ts +++ b/apps/desktop/src/lib/repl-stream.test.ts @@ -68,6 +68,33 @@ describe('repl-stream mutators', () => { }); }); + it('falls back to only the newest running tool across resumed turns', () => { + const m: Msg[] = [ + { + role: 'assistant', + turn: { text: 'old', tools: [tool('old', 'Write')], streaming: false }, + }, + { role: 'user', text: 'next turn' }, + { + role: 'assistant', + turn: { text: 'new', tools: [tool('new', 'Edit')], streaming: true }, + }, + ]; + + const out = attachToolResult(m, 'provider-changed-id', 'updated', 'ok'); + const oldTurn = out[0]; + const newTurn = out[2]; + if (oldTurn?.role !== 'assistant' || newTurn?.role !== 'assistant') { + throw new Error('expected assistant turns'); + } + expect(oldTurn.turn.tools[0]).toMatchObject({ toolId: 'old', status: 'running' }); + expect(newTurn.turn.tools[0]).toMatchObject({ + toolId: 'new', + status: 'ok', + resultText: 'updated', + }); + }); + it('finalizeStreaming clears the flag on ALL assistant turns', () => { // Even if a prior turn was left streaming (defensive), finalize clears it. const m: Msg[] = [ diff --git a/apps/desktop/src/lib/repl-stream.ts b/apps/desktop/src/lib/repl-stream.ts index 0997550..bb08641 100644 --- a/apps/desktop/src/lib/repl-stream.ts +++ b/apps/desktop/src/lib/repl-stream.ts @@ -89,24 +89,50 @@ export function attachToolResult( content: string, status: 'ok' | 'err', ): Msg[] { - return msgs.map((m): Msg => { - if (m.role !== 'assistant') return m; - let idx = m.turn.tools.findIndex((t) => t.toolId === toolId); - if (idx === -1) { - for (let j = m.turn.tools.length - 1; j >= 0; j--) { - if (m.turn.tools[j]!.status === 'running') { - idx = j; - break; - } - } + let messageIndex = -1; + let toolIndex = -1; + + // Prefer an exact id, newest first. If a legacy provider omitted/mutated the + // id, fall back once to the globally newest running tool — never once per + // assistant message, which would rewrite unrelated resumed history. + for (let i = msgs.length - 1; i >= 0 && toolIndex === -1; i--) { + const message = msgs[i]!; + if (message.role !== 'assistant') continue; + const candidate = lastToolIndex(message.turn.tools, (tool) => tool.toolId === toolId); + if (candidate !== -1) { + messageIndex = i; + toolIndex = candidate; } - if (idx === -1) return m; - const tools = [...m.turn.tools]; - tools[idx] = { ...tools[idx]!, status, resultText: content }; - return { ...m, turn: { ...m.turn, tools } }; + } + for (let i = msgs.length - 1; i >= 0 && toolIndex === -1; i--) { + const message = msgs[i]!; + if (message.role !== 'assistant') continue; + const candidate = lastToolIndex(message.turn.tools, (tool) => tool.status === 'running'); + if (candidate !== -1) { + messageIndex = i; + toolIndex = candidate; + } + } + if (messageIndex === -1 || toolIndex === -1) return msgs; + + return msgs.map((message, index): Msg => { + if (index !== messageIndex || message.role !== 'assistant') return message; + const tools = [...message.turn.tools]; + tools[toolIndex] = { ...tools[toolIndex]!, status, resultText: content }; + return { ...message, turn: { ...message.turn, tools } }; }); } +function lastToolIndex( + tools: ToolInvocation[], + predicate: (tool: ToolInvocation) => boolean, +): number { + for (let i = tools.length - 1; i >= 0; i--) { + if (predicate(tools[i]!)) return i; + } + return -1; +} + /** Clear the streaming flag on ALL assistant turns (not just the last one). */ export function finalizeStreaming(msgs: Msg[]): Msg[] { return msgs.map( diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index 7bf1e76..62b35e5 100644 --- a/apps/desktop/src/preview-app.tsx +++ b/apps/desktop/src/preview-app.tsx @@ -3,6 +3,14 @@ // a plain browser — lets us screenshot + iterate on the layout without the // Tauri backend or a rebuild. Not in the prod bundle (build input = index.html). +import type { + ProtocolEvent, + ProtocolRequest, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; +import { emit } from '@tauri-apps/api/event'; +import { mockIPC } from '@tauri-apps/api/mocks'; import { createRoot } from 'react-dom/client'; import { App } from './App.js'; import { installTauriShim } from './lib/window-shim.js'; @@ -140,10 +148,166 @@ const MOCK_MESSAGES = [ { type: 'message', role: 'user', content: [{ type: 'text', text: '加一个 boss 关卡' }] }, ]; -// Mock the Tauri invoke bridge before the app calls it (no invoke runs at import). -(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { - invoke: async (cmd: string) => { +let nextThread = 1; +let nextTurn = 1; +let activeThreadId = MOCK_SESSIONS[0]!.id; +let activeTurn: TurnSnapshot | null = null; +const protocolRequests: ProtocolRequest[] = []; + +function threadSnapshot(id: string): ThreadSnapshot { + return { + id, + cwd: '/Users/oratis/Projects/DeepCode/test', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + turns: [], + }; +} + +async function sendProtocol(message: unknown): Promise { + await emit('app-server-output', { + stream: 'stdout', + line: JSON.stringify(message), + }); +} + +async function sendEvent(event: ProtocolEvent): Promise { + await sendProtocol({ method: 'event', params: event }); +} + +async function handleProtocolRequest(request: ProtocolRequest): Promise { + protocolRequests.push(request); + const respond = (result: unknown) => sendProtocol({ id: request.id, result }); + switch (request.method) { + case 'initialize': + await respond({ + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + }, + }); + break; + case 'thread/start': { + activeThreadId = `preview-thread-${nextThread++}`; + const thread = threadSnapshot(activeThreadId); + await sendEvent({ type: 'thread.started', thread }); + await respond(thread); + break; + } + case 'thread/read': + case 'thread/resume': { + activeThreadId = String(request.params.threadId); + await respond(threadSnapshot(activeThreadId)); + break; + } + case 'turn/start': { + const turnId = `preview-turn-${nextTurn++}`; + activeTurn = { + id: turnId, + threadId: activeThreadId, + status: 'in_progress', + startedAt: '2026-08-01T00:00:01.000Z', + items: [], + }; + // Emit before the response to exercise the renderer's fast-turn buffer. + await sendEvent({ type: 'turn.started', threadId: activeThreadId, turn: activeTurn }); + await respond(activeTurn); + await sendEvent({ + type: 'item.delta', + threadId: activeThreadId, + turnId, + itemId: 'assistant', + delta: 'I’ll update the game safely. ', + }); + await sendEvent({ + type: 'tool.started', + threadId: activeThreadId, + turnId, + itemId: 'fixture-edit', + name: 'Edit', + input: { file_path: '/Users/oratis/Projects/DeepCode/test/打飞机.html' }, + }); + await sendEvent({ + type: 'approval.requested', + threadId: activeThreadId, + turnId, + requestId: 'fixture-approval', + toolName: 'Edit', + reason: 'The fixture verifies an approval-gated write.', + }); + break; + } + case 'approval/respond': { + await respond({ accepted: true }); + if (!activeTurn) break; + const { id: turnId, threadId } = activeTurn; + await sendEvent({ + type: 'tool.completed', + threadId, + turnId, + itemId: 'fixture-edit', + result: { content: 'Updated the boss encounter.' }, + }); + await sendEvent({ + type: 'item.delta', + threadId, + turnId, + itemId: 'assistant', + delta: 'The boss encounter is ready.', + }); + await sendEvent({ + type: 'usage.updated', + threadId, + turnId, + usage: { inputTokens: 2_048, outputTokens: 256, cacheReadTokens: 1_024 }, + }); + activeTurn = { ...activeTurn, status: 'completed', completedAt: '2026-08-01T00:00:02.000Z' }; + await sendEvent({ type: 'turn.completed', threadId, turn: activeTurn }); + break; + } + case 'user-input/respond': + await respond({ accepted: true }); + break; + case 'turn/interrupt': { + await respond({ interrupted: activeTurn !== null }); + if (!activeTurn) break; + activeTurn = { + ...activeTurn, + status: 'interrupted', + completedAt: '2026-08-01T00:00:02.000Z', + }; + await sendEvent({ + type: 'turn.interrupted', + threadId: activeTurn.threadId, + turn: activeTurn, + }); + break; + } + } +} + +// Use Tauri's official frontend mock, including event listener registration, +// so the preview exercises the same app-server bridge as the production UI. +mockIPC( + async (cmd: string, args?: unknown) => { + const payload = + args !== null && typeof args === 'object' && !Array.isArray(args) + ? (args as Record) + : {}; switch (cmd) { + case 'app_server_start': + case 'app_server_status': + return { running: true, pid: 4242 }; + case 'app_server_stop': + return null; + case 'app_server_send': + await handleProtocolRequest(JSON.parse(String(payload.message)) as ProtocolRequest); + return null; case 'load_settings_file': return { projectPath: '/Users/oratis/Projects/DeepCode/test' }; case 'credential_status': @@ -184,13 +348,30 @@ const MOCK_MESSAGES = [ return null; case 'voice_stop': return 'add a dark mode toggle to the settings screen'; + case 'save_settings_file': + case 'save_credentials': + case 'append_allow_matcher': + case 'session_set_title': + case 'session_archive': + case 'session_delete': + case 'plugin:updater|check': + return null; default: console.warn('[preview] unmocked invoke:', cmd); return null; } }, - transformCallback: (cb: unknown) => cb, -}; + { shouldMockEvents: true }, +); + +Object.defineProperty(window, '__DEEPCODE_FIXTURE__', { + configurable: true, + value: { + get protocolRequests() { + return [...protocolRequests]; + }, + }, +}); installTauriShim(); // Pretend a session is active so the file panel fetches the mock snapshots above. diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 9790a3e..7e680ba 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -295,7 +295,8 @@ model tool call - renderer 只能查询 credential presence/base URL,不能读取 API key/auth token;原生 mutation/bash commands 已从 Tauri invoke surface 移除,tool 执行统一经过 RuntimeHost。 - React 只消费协议事件;接入真实 interrupt、恢复与 structured items。 -- 把 `preview-app.html` 变成自动化 fixture harness;收敛现有 Changes/Files/Inspector。 +- `preview-app.html` 已使用官方 Tauri event mock 变成协议 fixture harness;Playwright CI 覆盖三栏 + 几何、session resume、快速 turn、approval、tool/usage 完成事件以及 Files 的 Source/Diff/History。 验收:签名 app、凭证边界、Tauri IPC、重启恢复、浏览器 fixture 与视觉测试。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e1e9b2..85139f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: specifier: ^18.3.0 version: 18.3.1(react@18.3.1) devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@tauri-apps/cli': specifier: ^2.0.0 version: 2.11.2 @@ -519,6 +522,11 @@ packages: '@cfworker/json-schema': optional: true + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1173,6 +1181,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1437,6 +1450,16 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -2004,6 +2027,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.4': @@ -2680,6 +2707,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2880,6 +2910,14 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.15: dependencies: nanoid: 3.3.12 From a43c08e3cb4056e64d6db5af2ad9b98667f1a1e7 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:32:21 +0800 Subject: [PATCH 17/23] ci: build workspace before desktop journey --- apps/desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5823fb5..fd9355b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,7 +17,7 @@ "tauri:build:universal": "tauri build --target universal-apple-darwin", "typecheck": "tsc -b", "test": "vitest run --passWithNoTests", - "test:e2e": "playwright test", + "test:e2e": "pnpm --workspace-root build && playwright test", "lint": "echo 'lint: configured at repo root' && exit 0", "clean": "rm -rf dist src-tauri/target *.tsbuildinfo" }, From 27d1a825f4b46d910572d525db32cb1e39e936d6 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:24:05 +0800 Subject: [PATCH 18/23] refactor: share app server protocol client --- apps/desktop/src/lib/protocol-client.ts | 150 ++++++------------------ docs/design/app-server-v1.md | 5 + packages/protocol/src/client.test.ts | 113 ++++++++++++++++++ packages/protocol/src/client.ts | 147 +++++++++++++++++++++++ packages/protocol/src/index.ts | 1 + 5 files changed, 301 insertions(+), 115 deletions(-) create mode 100644 packages/protocol/src/client.test.ts create mode 100644 packages/protocol/src/client.ts diff --git a/apps/desktop/src/lib/protocol-client.ts b/apps/desktop/src/lib/protocol-client.ts index dadb197..5af0eaa 100644 --- a/apps/desktop/src/lib/protocol-client.ts +++ b/apps/desktop/src/lib/protocol-client.ts @@ -1,12 +1,5 @@ import { listen } from '@tauri-apps/api/event'; -import { - encodeProtocolMessage, - type InitializeResult, - type ProtocolEvent, - type ProtocolMethod, - type ProtocolRequest, - type ProtocolResponse, -} from '@deepcode/protocol'; +import { ProtocolClient, type ProtocolClientConnection } from '@deepcode/protocol'; import { appServerSend, appServerStart, appServerStop } from './tauri-api.js'; @@ -33,127 +26,54 @@ const tauriBridge: ProtocolClientBridge = { stop: appServerStop, }; -interface PendingRequest { - resolve(value: unknown): void; - reject(error: Error): void; - timeout: ReturnType; -} - -export class DesktopProtocolClient { - private readonly pending = new Map(); - private readonly subscribers = new Set<(event: ProtocolEvent) => void>(); - private nextId = 1; +class TauriProtocolConnection implements ProtocolClientConnection { private unlisten?: () => void; - private initialized?: InitializeResult; - private connecting?: Promise; - - constructor( - private readonly bridge: ProtocolClientBridge = tauriBridge, - private readonly timeoutMs = 30_000, - ) {} - - async connect(): Promise { - if (this.initialized) return this.initialized; - if (this.connecting) return this.connecting; - this.connecting = this.open(); - try { - return await this.connecting; - } finally { - this.connecting = undefined; - } - } - private async open(): Promise { - if (!this.unlisten) this.unlisten = await this.bridge.listen((output) => this.receive(output)); - await this.bridge.start(); - const initialized = await this.request('initialize'); - if (initialized.protocolVersion !== 1) { - await this.close(); - throw new Error(`Unsupported app-server protocol version: ${initialized.protocolVersion}`); - } - this.initialized = initialized; - return initialized; - } - - subscribe(handler: (event: ProtocolEvent) => void): () => void { - this.subscribers.add(handler); - return () => this.subscribers.delete(handler); - } + constructor(private readonly bridge: ProtocolClientBridge) {} - async request(method: ProtocolMethod, params: Record = {}): Promise { - const id = this.nextId++; - const request: ProtocolRequest = { id, method, params }; - const response = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`app-server request timed out: ${method}`)); - }, this.timeoutMs); - this.pending.set(id, { - resolve: (value) => resolve(value as T), - reject, - timeout, - }); + async open(onMessage: (message: string) => void, onDisconnect: (error: Error) => void) { + this.unlisten = await this.bridge.listen((output) => { + if (output.stream === 'stdout') { + onMessage(output.line); + return; + } + if (output.stream === 'terminated' || output.stream === 'error') { + this.detach(); + onDisconnect( + output.stream === 'terminated' + ? new Error( + `app-server terminated (code=${output.code ?? 'none'}, signal=${output.signal ?? 'none'})`, + ) + : new Error(output.line || 'app-server bridge failed'), + ); + } }); try { - await this.bridge.send(encodeProtocolMessage(request)); + await this.bridge.start(); } catch (error) { - this.rejectRequest(id, error as Error); + this.detach(); + throw error; } - return response; } - async close(): Promise { - this.rejectAll(new Error('app-server client closed')); - this.unlisten?.(); - this.unlisten = undefined; - this.initialized = undefined; - await this.bridge.stop(); + send(message: string): Promise { + return this.bridge.send(message); } - private receive(output: AppServerOutput): void { - if (output.stream === 'terminated') { - this.initialized = undefined; - this.rejectAll( - new Error( - `app-server terminated (code=${output.code ?? 'none'}, signal=${output.signal ?? 'none'})`, - ), - ); - return; - } - if (output.stream !== 'stdout') return; - let message: ProtocolResponse | { method: 'event'; params: ProtocolEvent }; - try { - message = JSON.parse(output.line) as - | ProtocolResponse - | { method: 'event'; params: ProtocolEvent }; - } catch { - this.rejectAll(new Error('app-server emitted invalid JSON')); - return; - } - if ('method' in message) { - if (message.method === 'event') { - for (const subscriber of this.subscribers) subscriber(message.params); - } - return; - } - if (message.id === null || typeof message.id !== 'number') return; - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - clearTimeout(pending.timeout); - if (message.error) pending.reject(new Error(`${message.error.code}: ${message.error.message}`)); - else pending.resolve(message.result); + async close(): Promise { + this.detach(); + await this.bridge.stop(); } - private rejectRequest(id: number, error: Error): void { - const pending = this.pending.get(id); - if (!pending) return; - this.pending.delete(id); - clearTimeout(pending.timeout); - pending.reject(error); + private detach(): void { + this.unlisten?.(); + this.unlisten = undefined; } +} - private rejectAll(error: Error): void { - for (const id of this.pending.keys()) this.rejectRequest(id, error); +/** Tauri connection adapter over the shared provider-neutral protocol client. */ +export class DesktopProtocolClient extends ProtocolClient { + constructor(bridge: ProtocolClientBridge = tauriBridge, timeoutMs = 30_000) { + super(new TauriProtocolConnection(bridge), timeoutMs); } } diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index 4c0dffc..9b8273a 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -87,6 +87,11 @@ node apps/cli/dist/cli.js app-server The second form is exposed as `deepcode app-server` in packaged CLI builds. Existing REPL and headless output contracts remain unchanged during this experimental phase. +`@deepcode/protocol` also exports the transport-neutral `ProtocolClient`. It owns initialization, +request correlation, timeouts, disconnect rejection, reconnection, and event fan-out; each host +supplies only an ordered message connection. The desktop implementation is now a thin Tauri +adapter, and editor clients use the same client state machine instead of duplicating RPC logic. + ## Deferred from this slice - config provenance; diff --git a/packages/protocol/src/client.test.ts b/packages/protocol/src/client.test.ts new file mode 100644 index 0000000..74993e3 --- /dev/null +++ b/packages/protocol/src/client.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ProtocolClient, type ProtocolClientConnection } from './client.js'; +import type { ProtocolRequest } from './types.js'; + +class FakeConnection implements ProtocolClientConnection { + onMessage?: (message: string) => void; + onDisconnect?: (error: Error) => void; + opened = 0; + closed = 0; + requests: ProtocolRequest[] = []; + + async open(onMessage: (message: string) => void, onDisconnect: (error: Error) => void) { + this.opened++; + this.onMessage = onMessage; + this.onDisconnect = onDisconnect; + } + + async send(raw: string) { + const request = JSON.parse(raw) as ProtocolRequest; + this.requests.push(request); + queueMicrotask(() => { + this.onMessage?.( + JSON.stringify({ + id: request.id, + result: + request.method === 'initialize' + ? { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + }, + } + : { ok: true }, + }), + ); + }); + } + + async close() { + this.closed++; + } +} + +describe('ProtocolClient', () => { + it('opens once, negotiates v1, and correlates requests', async () => { + const connection = new FakeConnection(); + const client = new ProtocolClient(connection); + + await expect(client.connect()).resolves.toEqual( + expect.objectContaining({ protocolVersion: 1 }), + ); + await client.connect(); + await expect(client.request('thread/read', { threadId: 'thread-1' })).resolves.toEqual({ + ok: true, + }); + + expect(connection.opened).toBe(1); + expect(connection.requests.map((request) => request.method)).toEqual([ + 'initialize', + 'thread/read', + ]); + await client.close(); + expect(connection.closed).toBe(1); + }); + + it('fans protocol events out to subscribers', async () => { + const connection = new FakeConnection(); + const client = new ProtocolClient(connection); + const subscriber = vi.fn(); + client.subscribe(subscriber); + await client.connect(); + + connection.onMessage?.( + JSON.stringify({ + method: 'event', + params: { + type: 'item.delta', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + delta: 'hello', + }, + }), + ); + + expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ type: 'item.delta' })); + }); + + it('rejects pending requests on disconnect and can reconnect', async () => { + const connection = new FakeConnection(); + const client = new ProtocolClient(connection, 1_000); + await client.connect(); + connection.send = async (raw) => { + connection.requests.push(JSON.parse(raw) as ProtocolRequest); + }; + + const pending = client.request('thread/read', { threadId: 'thread-1' }); + connection.onDisconnect?.(new Error('sidecar exited')); + await expect(pending).rejects.toThrow('sidecar exited'); + + connection.send = FakeConnection.prototype.send.bind(connection); + await expect(client.connect()).resolves.toEqual( + expect.objectContaining({ protocolVersion: 1 }), + ); + expect(connection.opened).toBe(2); + }); +}); diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts new file mode 100644 index 0000000..d6c0a9a --- /dev/null +++ b/packages/protocol/src/client.ts @@ -0,0 +1,147 @@ +import { encodeProtocolMessage } from './codec.js'; +import { + PROTOCOL_VERSION, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ProtocolRequest, + type ProtocolResponse, +} from './types.js'; + +/** A transport owns one ordered, newline-free protocol message stream. */ +export interface ProtocolClientConnection { + open(onMessage: (message: string) => void, onDisconnect: (error: Error) => void): Promise; + send(message: string): Promise; + close(): Promise; +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timeout: ReturnType; +} + +/** + * Provider- and host-neutral app-server client. Desktop, editors, and tests + * supply only their connection adapter; request correlation, initialization, + * disconnect behavior, and event fan-out stay identical across surfaces. + */ +export class ProtocolClient { + private readonly pending = new Map(); + private readonly subscribers = new Set<(event: ProtocolEvent) => void>(); + private nextId = 1; + private opened = false; + private initialized?: InitializeResult; + private connecting?: Promise; + + constructor( + private readonly connection: ProtocolClientConnection, + private readonly timeoutMs = 30_000, + ) {} + + async connect(): Promise { + if (this.initialized) return this.initialized; + if (this.connecting) return this.connecting; + this.connecting = this.open(); + try { + return await this.connecting; + } finally { + this.connecting = undefined; + } + } + + subscribe(handler: (event: ProtocolEvent) => void): () => void { + this.subscribers.add(handler); + return () => this.subscribers.delete(handler); + } + + async request(method: ProtocolMethod, params: Record = {}): Promise { + if (!this.opened) throw new Error('app-server client is not connected'); + const id = this.nextId++; + const request: ProtocolRequest = { id, method, params }; + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`app-server request timed out: ${method}`)); + }, this.timeoutMs); + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + timeout, + }); + }); + try { + await this.connection.send(encodeProtocolMessage(request)); + } catch (error) { + this.rejectRequest(id, asError(error)); + } + return response; + } + + async close(): Promise { + this.disconnect(new Error('app-server client closed')); + await this.connection.close(); + } + + private async open(): Promise { + if (!this.opened) { + await this.connection.open( + (message) => this.receive(message), + (error) => this.disconnect(error), + ); + this.opened = true; + } + const initialized = await this.request('initialize'); + if (initialized.protocolVersion !== PROTOCOL_VERSION) { + await this.close(); + throw new Error(`Unsupported app-server protocol version: ${initialized.protocolVersion}`); + } + this.initialized = initialized; + return initialized; + } + + private receive(raw: string): void { + let message: ProtocolResponse | { method: 'event'; params: ProtocolEvent }; + try { + message = JSON.parse(raw) as ProtocolResponse | { method: 'event'; params: ProtocolEvent }; + } catch { + this.disconnect(new Error('app-server emitted invalid JSON')); + return; + } + if ('method' in message) { + if (message.method === 'event') { + for (const subscriber of this.subscribers) subscriber(message.params); + } + return; + } + if (typeof message.id !== 'number') return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timeout); + if (message.error) pending.reject(new Error(`${message.error.code}: ${message.error.message}`)); + else pending.resolve(message.result); + } + + private disconnect(error: Error): void { + this.opened = false; + this.initialized = undefined; + this.rejectAll(error); + } + + private rejectRequest(id: number, error: Error): void { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timeout); + pending.reject(error); + } + + private rejectAll(error: Error): void { + for (const id of [...this.pending.keys()]) this.rejectRequest(id, error); + } +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 9679568..e4ea5b0 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,3 +1,4 @@ export * from './types.js'; export * from './runtime.js'; export * from './codec.js'; +export * from './client.js'; From b81da0495297783842108d29d81ba3e683716657 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:30:28 +0800 Subject: [PATCH 19/23] feat: move LSP behind app server --- apps/lsp/README.md | 49 ++-- apps/lsp/package.json | 4 +- apps/lsp/src/handler.test.ts | 383 ++++++++++++++++++++---------- apps/lsp/src/handler.ts | 413 +++++++++++++++++++++++---------- apps/lsp/tsconfig.json | 6 +- apps/server/package.json | 4 + apps/server/src/client.test.ts | 51 ++++ apps/server/src/client.ts | 118 ++++++++++ apps/server/src/index.ts | 1 + docs/CODEX_ALIGNMENT_PLAN.md | 2 + docs/design/app-server-v1.md | 3 + pnpm-lock.yaml | 6 + 12 files changed, 772 insertions(+), 268 deletions(-) create mode 100644 apps/server/src/client.test.ts create mode 100644 apps/server/src/client.ts diff --git a/apps/lsp/README.md b/apps/lsp/README.md index 75f4a24..297f62e 100644 --- a/apps/lsp/README.md +++ b/apps/lsp/README.md @@ -1,30 +1,40 @@ # @deepcode/lsp — LSP bridge (v1.1) -Exposes DeepCode's agent loop as Language-Server-Protocol commands, so +Exposes DeepCode's app-server protocol as Language-Server-Protocol commands, so any LSP-capable editor (Neovim, Emacs lsp-mode, Sublime, JetBrains via LSP plugin) can drive DeepCode via `workspace/executeCommand`. ## Custom commands -| Command | Args | Returns | -| --------------------- | -------------------- | ------------------------------------- | -| `deepcode.runAgent` | `{ prompt: string }` | `{ turnId: string }` + streams events | -| `deepcode.abort` | `{ turnId: string }` | `{ aborted: boolean }` | -| `deepcode.listSkills` | none | `{ skills: SkillRow[] }` | +| Command | Args | Returns | +| --------------------------- | ----------------------------------------------- | ------------------------- | +| `deepcode.runAgent` | `{ prompt, threadId?, model?, effort?, mode? }` | `{ threadId, turnId }` | +| `deepcode.abort` | `{ turnId }` | `{ aborted }` | +| `deepcode.readThread` | `{ threadId }` | protocol thread snapshot | +| `deepcode.resumeThread` | `{ threadId }` | resumed protocol snapshot | +| `deepcode.respondApproval` | `{ turnId, requestId, decision }` | `{ accepted }` | +| `deepcode.respondUserInput` | `{ turnId, requestId, answer }` | `{ accepted }` | +| `deepcode.listSkills` | none | `{ skills: SkillRow[] }` | -Streamed events are sent as `deepcode/agentEvent` notifications: +Lifecycle, structured tool, usage, approval, and user-input events are sent unchanged as +`deepcode/protocolEvent` notifications: ```json { "jsonrpc": "2.0", - "method": "deepcode/agentEvent", - "params": { "turnId": "lsp-...", "kind": "text_delta", "text": "..." } + "method": "deepcode/protocolEvent", + "params": { + "type": "item.delta", + "threadId": "thread-...", + "turnId": "turn-...", + "itemId": "item-...", + "delta": "hello" + } } ``` -The `kind` field mirrors the AgentStreamEvent union from -`@deepcode/core/src/ipc/protocol.ts` (started / text_delta / tool_use / -tool_result / usage / turn_complete / turn_done / error). +The schema is the same provider-neutral `@deepcode/protocol` contract used by desktop and the +app-server. A `turn.completed`, `turn.interrupted`, or `turn.failed` event is the terminal signal. ## Install & run @@ -98,12 +108,13 @@ In `Preferences → Package Settings → LSP → Settings`: - Pure stdio LSP server. Framing: `Content-Length: N\r\n\r\n`. - Notifications (no `id`) silently dropped if unknown. - Requests (with `id`) errored with `-32603` if unknown method. -- Agent loop runs in-process; long turns spawn a child to keep the LSP - loop responsive (TODO in v1.1-rest). +- One app-server child owns runtime, credentials, tools, canonical sessions, and active turns. +- LSP uses the shared protocol client for initialize, correlation, disconnects, and event fan-out; + it never constructs a provider or reads credential secrets. +- Events that beat the `turn/start` response are buffered by turn id, so fast turns remain ordered. -## Skeleton vs ready-to-ship +## Current scope -This release ships the protocol skeleton (3 commands, 4 LSP boilerplate -handlers, stream events). The actual `runAgent` invocation emits a -placeholder event to confirm the channel — wiring to the real -`@deepcode/core` agent loop lands with the v1.1 release. +The bridge covers thread start/read/resume, turn start/interrupt, structured events, approvals, and +AskUserQuestion. Multi-client attachment and shared-daemon authentication remain intentionally out +of scope for protocol v1. diff --git a/apps/lsp/package.json b/apps/lsp/package.json index 5674424..1d2aaee 100644 --- a/apps/lsp/package.json +++ b/apps/lsp/package.json @@ -15,7 +15,9 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { - "@deepcode/core": "workspace:*" + "@deepcode/app-server": "workspace:*", + "@deepcode/core": "workspace:*", + "@deepcode/protocol": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index d365019..b1bd65e 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -1,161 +1,300 @@ -import { describe, expect, it } from 'vitest'; -import { __test, handleMessage, type LspMessage } from './handler.js'; +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ProtocolRequest, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { __test, handleMessage, type LspMessage, type SendFn } from './handler.js'; + +const capabilities: InitializeResult = { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + }, +}; + +class FakeClient { + subscribers = new Set<(event: ProtocolEvent) => void>(); + requests: ProtocolRequest[] = []; + thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/tmp/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + turns: [], + }; + turn: TurnSnapshot = { + id: 'turn-1', + threadId: 'thread-1', + status: 'in_progress', + startedAt: '2026-08-01T00:00:01.000Z', + items: [], + }; + completeTurns = true; + closed = 0; + + async connect() { + return capabilities; + } + + subscribe(handler: (event: ProtocolEvent) => void) { + this.subscribers.add(handler); + return () => this.subscribers.delete(handler); + } + + async request(method: ProtocolMethod, params: Record = {}): Promise { + this.requests.push({ id: this.requests.length + 1, method, params }); + switch (method) { + case 'thread/start': + this.emit({ type: 'thread.started', thread: this.thread }); + return this.thread as T; + case 'thread/read': + case 'thread/resume': + return this.thread as T; + case 'turn/start': { + // Deliberately precedes the response to exercise the LSP fast-turn queue. + this.emit({ type: 'turn.started', threadId: this.thread.id, turn: this.turn }); + queueMicrotask(() => { + this.emit({ + type: 'item.delta', + threadId: this.thread.id, + turnId: this.turn.id, + itemId: 'assistant', + delta: 'hello', + }); + if (this.completeTurns) { + this.turn = { + ...this.turn, + status: 'completed', + completedAt: '2026-08-01T00:00:02.000Z', + }; + this.emit({ type: 'turn.completed', threadId: this.thread.id, turn: this.turn }); + } + }); + return this.turn as T; + } + case 'turn/interrupt': + this.turn = { + ...this.turn, + status: 'interrupted', + completedAt: '2026-08-01T00:00:02.000Z', + }; + this.emit({ type: 'turn.interrupted', threadId: this.thread.id, turn: this.turn }); + return { interrupted: true } as T; + case 'approval/respond': + case 'user-input/respond': + return { accepted: true } as T; + default: + throw new Error(`Unexpected method: ${method}`); + } + } + + async close() { + this.closed++; + } + + emit(event: ProtocolEvent) { + for (const subscriber of this.subscribers) subscriber(event); + } +} + +afterEach(async () => { + await __test.reset(); +}); describe('handleMessage — initialize', () => { - it('returns capabilities + serverInfo + supported commands', async () => { + it('advertises lifecycle and interactive protocol commands', async () => { const out: LspMessage[] = []; await handleMessage( - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { rootUri: 'file:///tmp/x' }, - }, - (m) => out.push(m), + { jsonrpc: '2.0', id: 1, method: 'initialize', params: { rootUri: 'file:///tmp/x' } }, + (message) => out.push(message), ); - expect(out).toHaveLength(1); - const r = out[0]!.result as { + const result = out[0]!.result as { capabilities: { executeCommandProvider: { commands: string[] } }; serverInfo: { name: string }; }; - expect(r.serverInfo.name).toBe('deepcode-lsp'); - expect(r.capabilities.executeCommandProvider.commands).toContain('deepcode.runAgent'); - expect(r.capabilities.executeCommandProvider.commands).toContain('deepcode.abort'); - expect(r.capabilities.executeCommandProvider.commands).toContain('deepcode.listSkills'); + expect(result.serverInfo.name).toBe('deepcode-lsp'); + expect(result.capabilities.executeCommandProvider.commands).toEqual( + expect.arrayContaining([ + 'deepcode.runAgent', + 'deepcode.abort', + 'deepcode.readThread', + 'deepcode.resumeThread', + 'deepcode.respondApproval', + 'deepcode.respondUserInput', + ]), + ); }); }); -describe('handleMessage — executeCommand', () => { - it('returns a turnId for deepcode.runAgent and streams events', async () => { +describe('handleMessage — protocol commands', () => { + it('starts a canonical thread and emits native protocol events in order', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - // Resolve as soon as the real completion signal (turn_done) is emitted, - // rather than polling on a fixed timer — the agent run streams events - // asynchronously after lazily importing @deepcode/core, which can take - // arbitrarily long on a loaded CI runner. - let signalDone!: () => void; - const done = new Promise((resolve) => { - signalDone = resolve; - }); - const send = (m: LspMessage) => { - out.push(m); - if ( - m.method === 'deepcode/agentEvent' && - (m.params as { kind: string }).kind === 'turn_done' - ) { - signalDone(); - } - }; - await handleMessage( - { - jsonrpc: '2.0', - id: 2, - method: 'workspace/executeCommand', - params: { command: 'deepcode.runAgent', arguments: [{ prompt: 'hi' }] }, - }, - send, - ); - // Synchronous: started event + reply - expect(out.some((m) => m.method === 'deepcode/agentEvent')).toBe(true); - const reply = out.find((m) => m.id === 2); - expect(reply).toBeDefined(); - expect((reply!.result as { turnId: string }).turnId).toMatch(/^lsp-/); - - // Async: wait for the agent run to finish (will error in test env - // because no DEEPSEEK_API_KEY is set — that's the expected path, which - // still emits turn_done). Wait on the real signal, bounded only by the - // test timeout below. - await done; - - const events = out.filter((m) => m.method === 'deepcode/agentEvent'); - const kinds = events.map((e) => (e.params as { kind: string }).kind); - expect(kinds).toContain('started'); - expect(kinds).toContain('turn_done'); - }, 15000); - - it('errors on missing prompt', async () => { - const out: LspMessage[] = []; - await handleMessage( - { - jsonrpc: '2.0', - id: 3, - method: 'workspace/executeCommand', - params: { command: 'deepcode.runAgent', arguments: [{}] }, - }, - (m) => out.push(m), + await execute(2, 'deepcode.runAgent', { prompt: 'hi', effort: 'high' }, (message) => + out.push(message), ); - expect(out[0]!.error).toBeDefined(); - expect(out[0]!.error!.message).toMatch(/prompt is required/); + await Promise.resolve(); + + const reply = out.find((message) => message.id === 2); + expect(reply?.result).toEqual({ threadId: 'thread-1', turnId: 'turn-1' }); + const events = out + .filter((message) => message.method === 'deepcode/protocolEvent') + .map((message) => (message.params as ProtocolEvent).type); + expect(events).toEqual(['thread.started', 'turn.started', 'item.delta', 'turn.completed']); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/start', + 'turn/start', + ]); + expect(client.requests[1]?.params.input).toEqual({ text: 'hi', effort: 'high' }); }); - it('deepcode.abort returns false for unknown turnId', async () => { + it('interrupts the app-server turn instead of a local controller', async () => { + const client = new FakeClient(); + client.completeTurns = false; + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage( - { - jsonrpc: '2.0', - id: 4, - method: 'workspace/executeCommand', - params: { command: 'deepcode.abort', arguments: [{ turnId: 'no-such' }] }, - }, - (m) => out.push(m), - ); - expect((out[0]!.result as { aborted: boolean }).aborted).toBe(false); + const send = (message: LspMessage) => out.push(message); + + await execute(3, 'deepcode.runAgent', { prompt: 'wait' }, send); + await execute(4, 'deepcode.abort', { turnId: 'turn-1' }, send); + + expect(out.find((message) => message.id === 4)?.result).toEqual({ aborted: true }); + expect(client.requests.at(-1)).toMatchObject({ + method: 'turn/interrupt', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }); + expect( + out.some( + (message) => + message.method === 'deepcode/protocolEvent' && + (message.params as ProtocolEvent).type === 'turn.interrupted', + ), + ).toBe(true); }); - it('deepcode.abort aborts the active turn controller', async () => { - const controller = new AbortController(); - __test.state.activeTurns.set('active-turn', controller); + it('binds approval and user-input responses to the active thread and turn', async () => { + const client = new FakeClient(); + client.completeTurns = false; + __test.setClientFactory(() => client); const out: LspMessage[] = []; + const send = (message: LspMessage) => out.push(message); - await handleMessage( - { - jsonrpc: '2.0', - id: 41, - method: 'workspace/executeCommand', - params: { command: 'deepcode.abort', arguments: [{ turnId: 'active-turn' }] }, - }, - (m) => out.push(m), + await execute(5, 'deepcode.runAgent', { prompt: 'edit' }, send); + client.emit({ + type: 'approval.requested', + threadId: 'thread-1', + turnId: 'turn-1', + requestId: 'approval-1', + toolName: 'Edit', + reason: 'write', + }); + await execute( + 6, + 'deepcode.respondApproval', + { turnId: 'turn-1', requestId: 'approval-1', decision: 'allow' }, + send, + ); + await execute( + 7, + 'deepcode.respondUserInput', + { turnId: 'turn-1', requestId: 'question-1', answer: 'All' }, + send, ); - expect((out[0]!.result as { aborted: boolean }).aborted).toBe(true); - expect(controller.signal.aborted).toBe(true); - __test.state.activeTurns.delete('active-turn'); + expect(client.requests.slice(-2)).toEqual([ + expect.objectContaining({ + method: 'approval/respond', + params: expect.objectContaining({ threadId: 'thread-1', requestId: 'approval-1' }), + }), + expect.objectContaining({ + method: 'user-input/respond', + params: expect.objectContaining({ threadId: 'thread-1', answer: 'All' }), + }), + ]); }); - it('errors on unknown command', async () => { + it('reads and resumes protocol snapshots', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage( - { - jsonrpc: '2.0', - id: 5, - method: 'workspace/executeCommand', - params: { command: 'evil.command', arguments: [] }, - }, - (m) => out.push(m), - ); - expect(out[0]!.error).toBeDefined(); - expect(out[0]!.error!.message).toMatch(/Unknown command/); + const send = (message: LspMessage) => out.push(message); + + await execute(8, 'deepcode.resumeThread', { threadId: 'thread-1' }, send); + await execute(9, 'deepcode.readThread', { threadId: 'thread-1' }, send); + + expect(out.find((message) => message.id === 8)?.result).toMatchObject({ id: 'thread-1' }); + expect(out.find((message) => message.id === 9)?.result).toMatchObject({ id: 'thread-1' }); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/resume', + 'thread/read', + ]); }); -}); -describe('handleMessage — unknown method', () => { - it('returns -32603 internal error', async () => { + it('rejects missing prompts and unknown turns', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage({ jsonrpc: '2.0', id: 6, method: 'unknown/method' }, (m) => out.push(m)); - expect(out[0]!.error).toBeDefined(); + const send = (message: LspMessage) => out.push(message); + + await execute(10, 'deepcode.runAgent', {}, send); + await execute(11, 'deepcode.abort', { turnId: 'unknown' }, send); + + expect(out.find((message) => message.id === 10)?.error?.message).toMatch(/prompt is required/); + expect(out.find((message) => message.id === 11)?.result).toEqual({ aborted: false }); }); }); -describe('handleMessage — notifications', () => { - it('silently drops unknown notification', async () => { +describe('handleMessage — lifecycle', () => { + it('closes the app-server client on shutdown', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage({ jsonrpc: '2.0', method: 'unknown/notif' }, (m) => out.push(m)); - expect(out).toHaveLength(0); + await execute(12, 'deepcode.resumeThread', { threadId: 'thread-1' }, (message) => + out.push(message), + ); + + await handleMessage({ jsonrpc: '2.0', id: 13, method: 'shutdown' }, (message) => + out.push(message), + ); + + expect(client.closed).toBe(1); + expect(out.find((message) => message.id === 13)?.result).toBeNull(); }); - it('accepts initialized notification (no reply)', async () => { + it('silently drops unknown notifications and reports unsupported requests', async () => { const out: LspMessage[] = []; - await handleMessage({ jsonrpc: '2.0', method: 'initialized' }, (m) => out.push(m)); + await handleMessage({ jsonrpc: '2.0', method: 'unknown/notif' }, (message) => + out.push(message), + ); expect(out).toHaveLength(0); + + await handleMessage({ jsonrpc: '2.0', id: 14, method: 'unknown/method' }, (message) => + out.push(message), + ); + expect(out[0]?.error?.message).toMatch(/Method not supported/); }); }); + +async function execute(id: number, command: string, args: unknown, send: SendFn) { + await handleMessage( + { + jsonrpc: '2.0', + id, + method: 'workspace/executeCommand', + params: { command, arguments: [args] }, + }, + send, + ); +} diff --git a/apps/lsp/src/handler.ts b/apps/lsp/src/handler.ts index b642321..31393e1 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -1,5 +1,16 @@ -// LSP message handler — dispatches JSON-RPC methods to DeepCode actions. -// Separated from server.ts for testability. +// LSP compatibility handler backed by the shared app-server protocol client. + +import { fileURLToPath } from 'node:url'; + +import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; +import { + ProtocolClient, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ThreadSnapshot, + type TurnSnapshot, +} from '@deepcode/protocol'; export interface LspMessage { jsonrpc: '2.0'; @@ -12,17 +23,32 @@ export interface LspMessage { export type SendFn = (msg: LspMessage) => void; +interface AppServerClient { + connect(): Promise; + request(method: ProtocolMethod, params?: Record): Promise; + subscribe(handler: (event: ProtocolEvent) => void): () => void; + close(): Promise; +} + interface ServerState { initialized: boolean; - /** Workspace root URI from initialize. */ rootUri?: string; - /** In-flight turn controllers so /abort cancels provider and tools. */ - activeTurns: Map; + threadId?: string; + client?: AppServerClient; + unsubscribe?: () => void; + clientFactory: () => AppServerClient; + activeTurns: Map; + turnSinks: Map; + queuedEvents: Map; + latestSend?: SendFn; } const state: ServerState = { initialized: false, + clientFactory: () => new ProtocolClient(new SpawnedAppServerConnection()), activeTurns: new Map(), + turnSinks: new Map(), + queuedEvents: new Map(), }; const SERVER_INFO = { @@ -30,36 +56,44 @@ const SERVER_INFO = { version: '0.0.0', }; +const COMMANDS = [ + 'deepcode.runAgent', + 'deepcode.abort', + 'deepcode.readThread', + 'deepcode.resumeThread', + 'deepcode.respondApproval', + 'deepcode.respondUserInput', + 'deepcode.listSkills', +]; + export async function handleMessage(msg: LspMessage, send: SendFn): Promise { - // Notifications (no id) — no response expected. if (msg.id === undefined || msg.id === null) { - await handleNotification(msg, send); + await handleNotification(msg); return; } try { const result = await dispatch(msg, send); send({ jsonrpc: '2.0', id: msg.id, result }); - } catch (err) { - const e = err as Error; + } catch (error) { send({ jsonrpc: '2.0', id: msg.id, - error: { code: -32603, message: e.message }, + error: { code: -32603, message: (error as Error).message }, }); } } -async function handleNotification(msg: LspMessage, _send: SendFn): Promise { +async function handleNotification(msg: LspMessage): Promise { switch (msg.method) { case 'initialized': state.initialized = true; return; case 'exit': + await closeClient(); process.exit(state.initialized ? 0 : 1); return; default: - // Silently drop unknown notifications per LSP spec return; } } @@ -69,6 +103,7 @@ async function dispatch(msg: LspMessage, send: SendFn): Promise { case 'initialize': return handleInitialize(msg.params as { rootUri?: string }); case 'shutdown': + await closeClient(); return null; case 'workspace/executeCommand': return handleExecuteCommand(msg.params as ExecuteCommandParams, send); @@ -81,11 +116,7 @@ function handleInitialize(params: { rootUri?: string }): unknown { state.rootUri = params?.rootUri; return { capabilities: { - // We don't implement any LSP language features; we use the protocol - // as a transport for our custom commands. - executeCommandProvider: { - commands: ['deepcode.runAgent', 'deepcode.abort', 'deepcode.listSkills'], - }, + executeCommandProvider: { commands: COMMANDS }, textDocumentSync: 0, }, serverInfo: SERVER_INFO, @@ -98,11 +129,41 @@ interface ExecuteCommandParams { } async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): Promise { + state.latestSend = send; switch (params.command) { case 'deepcode.runAgent': - return handleRunAgent((params.arguments?.[0] ?? {}) as { prompt?: string }, send); + return handleRunAgent( + (params.arguments?.[0] ?? {}) as { + prompt?: string; + model?: string; + effort?: string; + mode?: string; + threadId?: string; + }, + send, + ); case 'deepcode.abort': return handleAbort((params.arguments?.[0] ?? {}) as { turnId?: string }); + case 'deepcode.readThread': + return handleReadThread((params.arguments?.[0] ?? {}) as { threadId?: string }); + case 'deepcode.resumeThread': + return handleResumeThread((params.arguments?.[0] ?? {}) as { threadId?: string }); + case 'deepcode.respondApproval': + return handleApproval( + (params.arguments?.[0] ?? {}) as { + turnId?: string; + requestId?: string; + decision?: 'allow' | 'deny' | 'always'; + }, + ); + case 'deepcode.respondUserInput': + return handleUserInput( + (params.arguments?.[0] ?? {}) as { + turnId?: string; + requestId?: string; + answer?: string; + }, + ); case 'deepcode.listSkills': return handleListSkills(); default: @@ -111,131 +172,233 @@ async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): } async function handleRunAgent( - args: { prompt?: string; model?: string }, + args: { + prompt?: string; + model?: string; + effort?: string; + mode?: string; + threadId?: string; + }, send: SendFn, -): Promise<{ turnId: string }> { +): Promise<{ threadId: string; turnId: string }> { if (!args.prompt) throw new Error('prompt is required'); - const turnId = `lsp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; - const abortController = new AbortController(); - state.activeTurns.set(turnId, abortController); - - // Stream events back via JSON-RPC notifications. - // Wired to the real agent loop — same code that drives the CLI / Mac client. - send({ - jsonrpc: '2.0', - method: 'deepcode/agentEvent', - params: { turnId, kind: 'started', prompt: args.prompt }, + const client = await getClient(); + const thread = await ensureThread(client, args.threadId); + const turn = await client.request('turn/start', { + threadId: thread.id, + input: { + text: args.prompt, + ...(args.model ? { model: args.model } : {}), + ...(args.effort ? { effort: args.effort } : {}), + ...(args.mode ? { mode: args.mode } : {}), + }, }); + state.activeTurns.set(turn.id, thread.id); + state.turnSinks.set(turn.id, send); + flushEvents(turn.id); + return { threadId: thread.id, turnId: turn.id }; +} - // Run async; we return turnId immediately so the LSP client can - // call deepcode.abort while it's in-flight. - void (async () => { - try { - const [ - { RuntimeHost }, - { DeepSeekProvider }, - { ToolRegistry, BUILTIN_TOOLS, SAFE_READONLY_TOOLS }, - { resolveCredentials, CredentialsStore }, - ] = await Promise.all([ - import('@deepcode/core').then((m) => ({ RuntimeHost: m.RuntimeHost })), - import('@deepcode/core').then((m) => ({ DeepSeekProvider: m.DeepSeekProvider })), - import('@deepcode/core').then((m) => ({ - ToolRegistry: m.ToolRegistry, - BUILTIN_TOOLS: m.BUILTIN_TOOLS, - SAFE_READONLY_TOOLS: m.SAFE_READONLY_TOOLS, - })), - import('@deepcode/core').then((m) => ({ - resolveCredentials: m.resolveCredentials, - CredentialsStore: m.CredentialsStore, - })), - ]); - - const creds = await resolveCredentials({ store: new CredentialsStore() }); - if (!creds.apiKey && !creds.authToken) { - throw new Error( - 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', - ); - } - - const provider = new DeepSeekProvider({ - apiKey: creds.apiKey ?? '', - authToken: creds.authToken, - baseURL: creds.baseURL, - }); - - const runtime = new RuntimeHost({ - provider, - tools: new ToolRegistry(BUILTIN_TOOLS), - cwd: state.rootUri ? new URL(state.rootUri).pathname : process.cwd(), - mode: 'default', - permissions: { allow: [...SAFE_READONLY_TOOLS] }, - }); - const result = await runtime.run({ - systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', - userMessage: args.prompt!, - model: args.model ?? 'deepseek-chat', - signal: abortController.signal, - onEvent: (e) => { - send({ - jsonrpc: '2.0', - method: 'deepcode/agentEvent', - params: { turnId, kind: e.type, ...e }, - }); - }, - }); - - send({ - jsonrpc: '2.0', - method: 'deepcode/agentEvent', - params: { turnId, kind: 'turn_done', stopReason: result.stopReason }, - }); - } catch (err) { - send({ - jsonrpc: '2.0', - method: 'deepcode/agentEvent', - params: { - turnId, - kind: 'error', - error: (err as Error).message ?? String(err), - }, - }); - send({ - jsonrpc: '2.0', - method: 'deepcode/agentEvent', - params: { turnId, kind: 'turn_done', stopReason: 'error' }, - }); - } finally { - state.activeTurns.delete(turnId); - } - })(); +async function handleAbort(args: { turnId?: string }): Promise<{ aborted: boolean }> { + if (!args.turnId) throw new Error('turnId is required'); + const threadId = state.activeTurns.get(args.turnId); + if (!threadId) return { aborted: false }; + const client = await getClient(); + const result = await client.request<{ interrupted: boolean }>('turn/interrupt', { + threadId, + turnId: args.turnId, + }); + return { aborted: result.interrupted }; +} + +async function handleReadThread(args: { threadId?: string }): Promise { + if (!args.threadId) throw new Error('threadId is required'); + return (await getClient()).request('thread/read', { threadId: args.threadId }); +} - return { turnId }; +async function handleResumeThread(args: { threadId?: string }): Promise { + if (!args.threadId) throw new Error('threadId is required'); + const thread = await ( + await getClient() + ).request('thread/resume', { + threadId: args.threadId, + }); + state.threadId = thread.id; + return thread; +} + +async function handleApproval(args: { + turnId?: string; + requestId?: string; + decision?: 'allow' | 'deny' | 'always'; +}): Promise<{ accepted: boolean }> { + const { threadId, turnId, requestId } = interactionContext(args); + if (!args.decision) throw new Error('decision is required'); + return (await getClient()).request('approval/respond', { + threadId, + turnId, + requestId, + decision: args.decision, + }); } -function handleAbort(args: { turnId?: string }): { aborted: boolean } { +async function handleUserInput(args: { + turnId?: string; + requestId?: string; + answer?: string; +}): Promise<{ accepted: boolean }> { + const { threadId, turnId, requestId } = interactionContext(args); + if (args.answer === undefined) throw new Error('answer is required'); + return (await getClient()).request('user-input/respond', { + threadId, + turnId, + requestId, + answer: args.answer, + }); +} + +function interactionContext(args: { turnId?: string; requestId?: string }) { if (!args.turnId) throw new Error('turnId is required'); - const controller = state.activeTurns.get(args.turnId); - if (!controller) return { aborted: false }; - controller.abort(); - return { aborted: true }; + if (!args.requestId) throw new Error('requestId is required'); + const threadId = state.activeTurns.get(args.turnId); + if (!threadId) throw new Error(`Active turn not found: ${args.turnId}`); + return { threadId, turnId: args.turnId, requestId: args.requestId }; +} + +async function getClient(): Promise { + if (!state.client) { + const client = state.clientFactory(); + state.client = client; + state.unsubscribe = client.subscribe(routeEvent); + } + await state.client.connect(); + return state.client; +} + +async function ensureThread( + client: AppServerClient, + requestedThreadId?: string, +): Promise { + if (requestedThreadId && requestedThreadId !== state.threadId) { + const thread = await client.request('thread/resume', { + threadId: requestedThreadId, + }); + state.threadId = thread.id; + return thread; + } + if (state.threadId) { + return client.request('thread/read', { threadId: state.threadId }); + } + const thread = await client.request('thread/start', { cwd: workspacePath() }); + state.threadId = thread.id; + return thread; +} + +function routeEvent(event: ProtocolEvent): void { + const turnId = turnIdFrom(event); + if (!turnId) { + state.latestSend?.({ jsonrpc: '2.0', method: 'deepcode/protocolEvent', params: event }); + return; + } + const send = state.turnSinks.get(turnId); + if (!send) { + const queued = state.queuedEvents.get(turnId) ?? []; + queued.push(event); + state.queuedEvents.set(turnId, queued); + return; + } + sendProtocolEvent(send, event); +} + +function flushEvents(turnId: string): void { + const send = state.turnSinks.get(turnId); + if (!send) return; + const events = state.queuedEvents.get(turnId) ?? []; + state.queuedEvents.delete(turnId); + for (const event of events) sendProtocolEvent(send, event); +} + +function sendProtocolEvent(send: SendFn, event: ProtocolEvent): void { + send({ jsonrpc: '2.0', method: 'deepcode/protocolEvent', params: event }); + if (isTerminal(event)) { + const turnId = event.turn.id; + state.activeTurns.delete(turnId); + state.turnSinks.delete(turnId); + state.queuedEvents.delete(turnId); + } +} + +function turnIdFrom(event: ProtocolEvent): string | undefined { + if (event.type === 'thread.started') return undefined; + if ( + event.type === 'turn.started' || + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ) { + return event.turn.id; + } + return event.turnId; +} + +function isTerminal( + event: ProtocolEvent, +): event is Extract< + ProtocolEvent, + { type: 'turn.completed' | 'turn.interrupted' | 'turn.failed' } +> { + return ( + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ); +} + +function workspacePath(): string { + if (!state.rootUri) return process.cwd(); + try { + return fileURLToPath(state.rootUri); + } catch { + return process.cwd(); + } +} + +async function closeClient(): Promise { + state.unsubscribe?.(); + state.unsubscribe = undefined; + const client = state.client; + state.client = undefined; + state.threadId = undefined; + state.activeTurns.clear(); + state.turnSinks.clear(); + state.queuedEvents.clear(); + if (client) await client.close(); } async function handleListSkills(): Promise<{ skills: unknown[] }> { - // Lazy import so server.ts type-checks without @deepcode/core resolved. const { loadSkills } = await import('@deepcode/core'); - const skills = await loadSkills({ cwd: process.cwd() }); + const skills = await loadSkills({ cwd: workspacePath() }); return { - skills: skills.map((s) => ({ - name: s.qualifiedName, - description: s.frontmatter.description, - source: s.source, - path: s.path, + skills: skills.map((skill) => ({ + name: skill.qualifiedName, + description: skill.frontmatter.description, + source: skill.source, + path: skill.path, })), }; } -// Test exports export const __test = { state, dispatch, + setClientFactory(factory: () => AppServerClient) { + state.clientFactory = factory; + }, + async reset() { + await closeClient(); + state.initialized = false; + state.rootUri = undefined; + state.latestSend = undefined; + state.clientFactory = () => new ProtocolClient(new SpawnedAppServerConnection()); + }, }; diff --git a/apps/lsp/tsconfig.json b/apps/lsp/tsconfig.json index d717494..2ba0e80 100644 --- a/apps/lsp/tsconfig.json +++ b/apps/lsp/tsconfig.json @@ -11,5 +11,9 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], - "references": [{ "path": "../../packages/core" }] + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/protocol" }, + { "path": "../server" } + ] } diff --git a/apps/server/package.json b/apps/server/package.json index 36821da..10e424b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" } }, "scripts": { diff --git a/apps/server/src/client.test.ts b/apps/server/src/client.test.ts new file mode 100644 index 0000000..f6fe9c9 --- /dev/null +++ b/apps/server/src/client.test.ts @@ -0,0 +1,51 @@ +import process from 'node:process'; + +import { ProtocolClient } from '@deepcode/protocol'; +import { describe, expect, it } from 'vitest'; + +import { SpawnedAppServerConnection } from './client.js'; + +const fixture = String.raw` +const readline = require('node:readline'); +const lines = readline.createInterface({ input: process.stdin }); +lines.on('line', (line) => { + const request = JSON.parse(line); + const result = request.method === 'initialize' + ? { protocolVersion: 1, capabilities: { + threadResume: true, turnInterrupt: true, completedItemPersistence: true, + transientDeltas: true, structuredToolEvents: true, interactiveRequests: true + } } + : { echoed: request.method }; + process.stdout.write(JSON.stringify({ id: request.id, result }) + '\n'); +}); +`; + +describe('SpawnedAppServerConnection', () => { + it('carries correlated protocol requests over a real child stdio stream', async () => { + const client = new ProtocolClient( + new SpawnedAppServerConnection({ command: process.execPath, args: ['-e', fixture] }), + ); + + await expect(client.connect()).resolves.toEqual( + expect.objectContaining({ protocolVersion: 1 }), + ); + await expect(client.request('thread/read', { threadId: 'thread-1' })).resolves.toEqual({ + echoed: 'thread/read', + }); + await client.close(); + }); + + it('surfaces child termination with bounded stderr context', async () => { + const connection = new SpawnedAppServerConnection({ + command: process.execPath, + args: ['-e', "process.stderr.write('fixture failed'); process.exit(7)"], + }); + const disconnected = new Promise((resolve) => { + void connection.open(() => undefined, resolve); + }); + + await expect(disconnected).resolves.toEqual( + expect.objectContaining({ message: expect.stringMatching(/code=7.*fixture failed/) }), + ); + }); +}); diff --git a/apps/server/src/client.ts b/apps/server/src/client.ts new file mode 100644 index 0000000..70f0b00 --- /dev/null +++ b/apps/server/src/client.ts @@ -0,0 +1,118 @@ +import { once } from 'node:events'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; +import { createInterface, type Interface as ReadlineInterface } from 'node:readline'; + +import type { ProtocolClientConnection } from '@deepcode/protocol'; + +export interface SpawnedAppServerOptions { + command?: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + home?: string; + closeGraceMs?: number; +} + +/** Node stdio adapter for a single-owner app-server child process. */ +export class SpawnedAppServerConnection implements ProtocolClientConnection { + private child?: ChildProcessWithoutNullStreams; + private lines?: ReadlineInterface; + private closing = false; + private stderr = ''; + + constructor(private readonly options: SpawnedAppServerOptions = {}) {} + + async open(onMessage: (message: string) => void, onDisconnect: (error: Error) => void) { + if (this.child) throw new Error('app-server connection is already open'); + this.closing = false; + this.stderr = ''; + const entrypoint = fileURLToPath(new URL('./cli.js', import.meta.url)); + const child = spawn( + this.options.command ?? process.execPath, + this.options.args ?? [entrypoint], + { + cwd: this.options.cwd, + env: { + ...process.env, + ...this.options.env, + ...(this.options.home ? { DEEPCODE_HOME: this.options.home } : {}), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + this.child = child; + this.lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + this.lines.on('line', onMessage); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + this.stderr = `${this.stderr}${chunk}`.slice(-8_192); + }); + + try { + await new Promise((resolve, reject) => { + const handleSpawn = () => { + child.off('error', handleError); + resolve(); + }; + const handleError = (error: Error) => { + child.off('spawn', handleSpawn); + reject(error); + }; + child.once('spawn', handleSpawn); + child.once('error', handleError); + }); + } catch (error) { + this.detach(); + throw error; + } + + child.once('error', (error) => { + if (!this.closing) onDisconnect(error); + this.detach(); + }); + child.once('exit', (code, signal) => { + const detail = this.stderr.trim(); + if (!this.closing) { + onDisconnect( + new Error( + `app-server terminated (code=${code ?? 'none'}, signal=${signal ?? 'none'})${detail ? `: ${detail}` : ''}`, + ), + ); + } + this.detach(); + }); + } + + async send(message: string): Promise { + const child = this.child; + if (!child || child.stdin.destroyed) throw new Error('app-server connection is not open'); + if (!child.stdin.write(`${message}\n`)) await once(child.stdin, 'drain'); + } + + async close(): Promise { + const child = this.child; + if (!child) return; + this.closing = true; + child.stdin.end(); + if (child.exitCode === null && child.signalCode === null) { + const grace = this.options.closeGraceMs ?? 5_000; + const closed = once(child, 'close').then(() => true); + const timedOut = new Promise((resolve) => { + setTimeout(() => resolve(false), grace).unref(); + }); + if (!(await Promise.race([closed, timedOut]))) { + child.kill('SIGTERM'); + await once(child, 'close').catch(() => undefined); + } + } + this.detach(); + } + + private detach(): void { + this.lines?.close(); + this.lines = undefined; + this.child = undefined; + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d6beb9a..1ea2807 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -4,3 +4,4 @@ export * from './runtime-executor.js'; export * from './default-runtime.js'; export * from './stdio.js'; export * from './run.js'; +export * from './client.js'; diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 7e680ba..10fcf78 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -303,6 +303,8 @@ model tool call ### PR 7 — VS Code 与 LSP 收敛 - VS Code 改用同一 runtime/protocol,删除重复 provider/runtime 组装。 +- LSP 已改为 shared `ProtocolClient` + 独立 app-server 子进程;不再读取凭证或组装 + `DeepSeekProvider`/`RuntimeHost`,并公开 read/resume/interrupt/approval/user-input 命令与原生事件。 - 支持 read/resume、structured tool items、approval、interrupt 与 diff context。 - LSP 只承担编辑器兼容;移除 `passWithNoTests`,增加真正测试。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index 9b8273a..c4763df 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -91,6 +91,9 @@ headless output contracts remain unchanged during this experimental phase. request correlation, timeouts, disconnect rejection, reconnection, and event fan-out; each host supplies only an ordered message connection. The desktop implementation is now a thin Tauri adapter, and editor clients use the same client state machine instead of duplicating RPC logic. +Node hosts use `SpawnedAppServerConnection`, which resolves the packaged app-server entrypoint, +honors stdio backpressure, bounds stderr diagnostics, treats exit as a protocol disconnect, and +closes stdin first so the server can interrupt and persist active turns before a timed SIGTERM. ## Deferred from this slice diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85139f7..58c190a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,9 +124,15 @@ importers: apps/lsp: dependencies: + '@deepcode/app-server': + specifier: workspace:* + version: link:../server '@deepcode/core': specifier: workspace:* version: link:../../packages/core + '@deepcode/protocol': + specifier: workspace:* + version: link:../../packages/protocol devDependencies: '@types/node': specifier: ^22.10.0 From 76a852888b9ef33c5dcb03bf4f8535b979664acd Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:43:57 +0800 Subject: [PATCH 20/23] feat: move VS Code behind app server --- .github/workflows/ci.yml | 4 + README.md | 2 +- apps/server/src/client.ts | 22 +- apps/server/src/editor-entry.ts | 10 + apps/vscode/.vscodeignore | 9 + apps/vscode/LICENSE | 21 + apps/vscode/README.md | 57 +- apps/vscode/media/icon.svg | 3 + apps/vscode/package.json | 27 +- apps/vscode/scripts/build.mjs | 49 + apps/vscode/src/extension.ts | 286 ++-- apps/vscode/src/protocol-runtime.test.ts | 141 ++ apps/vscode/src/protocol-runtime.ts | 187 ++ apps/vscode/tsconfig.json | 6 +- apps/vscode/vitest.config.ts | 2 - docs/CODEX_ALIGNMENT_PLAN.md | 8 +- pnpm-lock.yaml | 1982 +++++++++++++++++++++- 17 files changed, 2633 insertions(+), 183 deletions(-) create mode 100644 apps/server/src/editor-entry.ts create mode 100644 apps/vscode/.vscodeignore create mode 100644 apps/vscode/LICENSE create mode 100644 apps/vscode/media/icon.svg create mode 100644 apps/vscode/scripts/build.mjs create mode 100644 apps/vscode/src/protocol-runtime.test.ts create mode 100644 apps/vscode/src/protocol-runtime.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9509470..74a1576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,10 @@ jobs: - name: Build run: pnpm build + - name: Package VS Code extension + if: runner.os == 'Linux' + run: pnpm --filter deepcode package --out "${RUNNER_TEMP}/deepcode.vsix" + link-check: name: Docs link check runs-on: ubuntu-latest diff --git a/README.md b/README.md index bff7b32..d2a98fa 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ packages/ apps/ cli/ # deepcode-cli — Node.js CLI (npm publishable) desktop/ # @deepcode/desktop — Tauri 2 + React Mac client - vscode/ # @deepcode/vscode — VS Code extension (v1.1) + vscode/ # deepcode — VS Code extension (app-server protocol client) lsp/ # @deepcode/lsp — LSP bridge for Neovim/Emacs/Sublime (v1.1) docs/ design/ # internal design docs diff --git a/apps/server/src/client.ts b/apps/server/src/client.ts index 70f0b00..748ef67 100644 --- a/apps/server/src/client.ts +++ b/apps/server/src/client.ts @@ -28,20 +28,16 @@ export class SpawnedAppServerConnection implements ProtocolClientConnection { if (this.child) throw new Error('app-server connection is already open'); this.closing = false; this.stderr = ''; - const entrypoint = fileURLToPath(new URL('./cli.js', import.meta.url)); - const child = spawn( - this.options.command ?? process.execPath, - this.options.args ?? [entrypoint], - { - cwd: this.options.cwd, - env: { - ...process.env, - ...this.options.env, - ...(this.options.home ? { DEEPCODE_HOME: this.options.home } : {}), - }, - stdio: ['pipe', 'pipe', 'pipe'], + const args = this.options.args ?? [fileURLToPath(new URL('./cli.js', import.meta.url))]; + const child = spawn(this.options.command ?? process.execPath, args, { + cwd: this.options.cwd, + env: { + ...process.env, + ...this.options.env, + ...(this.options.home ? { DEEPCODE_HOME: this.options.home } : {}), }, - ); + stdio: ['pipe', 'pipe', 'pipe'], + }); this.child = child; this.lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); this.lines.on('line', onMessage); diff --git a/apps/server/src/editor-entry.ts b/apps/server/src/editor-entry.ts new file mode 100644 index 0000000..90f0536 --- /dev/null +++ b/apps/server/src/editor-entry.ts @@ -0,0 +1,10 @@ +import process from 'node:process'; + +import { runAppServer } from './run.js'; + +const home = process.env.DEEPCODE_HOME ?? `${process.env.HOME ?? process.cwd()}/.deepcode`; + +runAppServer({ input: process.stdin, output: process.stdout, home }).catch((error) => { + process.stderr.write(`DeepCode app-server fatal: ${(error as Error).message ?? String(error)}\n`); + process.exitCode = 1; +}); diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore new file mode 100644 index 0000000..200435f --- /dev/null +++ b/apps/vscode/.vscodeignore @@ -0,0 +1,9 @@ +src/** +scripts/** +node_modules/** +dist/*.map +dist/.tsbuildinfo +**/*.test.* +tsconfig.json +vitest.config.ts +*.tsbuildinfo diff --git a/apps/vscode/LICENSE b/apps/vscode/LICENSE new file mode 100644 index 0000000..a5cd724 --- /dev/null +++ b/apps/vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Oratis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 3235452..c1485e4 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -1,37 +1,40 @@ -# @deepcode/vscode — DeepCode VS Code extension (v1.1) +# DeepCode VS Code extension -DeepSeek-powered coding agent inside VS Code. Same agent loop as the CLI -and Mac client — Claude-Code parity. +DeepSeek-powered coding agent inside VS Code, backed by the same provider-neutral app-server +protocol and canonical threads as the desktop client. -## Current state — v1.1 skeleton +## Current state -- `package.json` — extension manifest with 3 commands, configuration, - activity bar + chat view, default keybinding (`Cmd/Ctrl+Shift+D`). -- `src/extension.ts` — activate / deactivate + Chat webview + 3 command - stubs. Uses lazy `require('vscode')` so the package type-checks without - `@types/vscode` installed. +- Three commands, an activity-bar chat view, model/effort settings, and a default + `Cmd/Ctrl+Shift+D` keybinding. +- Canonical thread reuse, structured text/tool events, real interrupt plumbing, approval via + warning actions, and AskUserQuestion via QuickPick/InputBox. +- A real extension bundle plus a dedicated app-server child bundle; the extension host never reads + credentials or constructs a provider/runtime. ## Activate the extension toolchain ```bash -pnpm add -D --filter @deepcode/vscode @vscode/vsce @types/vscode +pnpm add -D --filter deepcode @vscode/vsce ``` Then: -| Command | Result | -| ----------------------------------------- | ------------------------------------------------- | -| `pnpm --filter @deepcode/vscode build` | Compile `src/extension.ts` → `dist/extension.cjs` | -| `pnpm --filter @deepcode/vscode package` | Produce a `.vsix` file (vsce) | -| Press F5 in VS Code with this folder open | Launch Extension Development Host | +| Command | Result | +| ----------------------------------------- | ------------------------------------------------ | +| `pnpm --filter deepcode build` | Bundle extension + app-server child into `dist/` | +| `pnpm --filter deepcode package` | Produce a `.vsix` file (vsce) | +| Press F5 in VS Code with this folder open | Launch Extension Development Host | ## Architecture - The extension runs in the VS Code **extension host** (Node process). -- Talks directly to `@deepcode/core` — no IPC layer needed (the extension - host IS a Node runtime). -- Long-running agent loops dispatch to a child process to avoid blocking - the host (TODO in v1.1-rest). +- A single owned app-server child contains credentials, provider, RuntimeHost, tools, permissions, + and canonical session storage. +- The extension uses the shared `ProtocolClient`; model deltas, tool lifecycle, usage, approval, + questions, and terminal state use the same ids/schema as desktop and LSP. +- Closing the extension closes child stdin, allowing active turns to interrupt and persist before + the process exits. ## Commands @@ -43,17 +46,17 @@ Then: ## Settings -| Key | Type | Default | Notes | -| ----------------- | ------ | ----------------- | -------------------------------------------- | -| `deepcode.apiKey` | string | `""` | Falls back to `~/.deepcode/credentials.json` | -| `deepcode.model` | enum | `"deepseek-chat"` | Standard alias + concrete model names | -| `deepcode.effort` | enum | `"medium"` | low / medium / high / xhigh / max | +| Key | Type | Default | Notes | +| ----------------- | ---- | ----------------- | ------------------------------------- | +| `deepcode.model` | enum | `"deepseek-chat"` | Standard alias + concrete model names | +| `deepcode.effort` | enum | `"medium"` | low / medium / high / xhigh / max | + +Credentials stay in the shared DeepCode credential store and are resolved only by the child. ## Roadmap -- Real `runAgent` invocation in `deepcode.run` (instead of the info popup) - Real diff fetch via `vscode.git` API for `deepcode.review` - File panel showing live edits as the agent works -- Inline tool-approval prompts via QuickPick +- Inline webview approval cards (host-native warning actions work today) - Custom commands via skills (mirror CLI's `/skills` dir) -- LSP-style command palette integration (see `@deepcode/lsp`) +- VS Code Extension Host integration tests in addition to the protocol-runtime unit gate diff --git a/apps/vscode/media/icon.svg b/apps/vscode/media/icon.svg new file mode 100644 index 0000000..d9256b4 --- /dev/null +++ b/apps/vscode/media/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 66a4ec3..10531bf 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1,11 +1,15 @@ { - "name": "@deepcode/vscode", + "name": "deepcode", "displayName": "DeepCode", "description": "DeepSeek-powered coding agent — Claude-Code parity inside VS Code.", "version": "0.0.0", "publisher": "deepcode", "private": true, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/oratis/deepcode.git" + }, "engines": { "node": ">=22", "vscode": "^1.85.0" @@ -39,12 +43,6 @@ "configuration": { "title": "DeepCode", "properties": { - "deepcode.apiKey": { - "type": "string", - "default": "", - "description": "DeepSeek API key. Leave empty to use ~/.deepcode/credentials.json.", - "scope": "machine-overridable" - }, "deepcode.model": { "type": "string", "default": "deepseek-chat", @@ -96,20 +94,23 @@ ] }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "node scripts/build.mjs", + "vscode:prepublish": "pnpm build", "typecheck": "tsc -b", - "test": "vitest run --passWithNoTests", - "package": "vsce package", + "test": "vitest run", + "package": "vsce package --no-dependencies", "clean": "rm -rf dist *.vsix *.tsbuildinfo" }, "dependencies": { - "@deepcode/core": "workspace:*" + "@deepcode/app-server": "workspace:*", + "@deepcode/protocol": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", "@types/vscode": "^1.85.0", + "@vscode/vsce": "^3.9.2", + "esbuild": "^0.21.5", "typescript": "^5.7.0", "vitest": "^2.1.9" - }, - "//notes": "vsce + @types/vscode pull ~30 MB; install when ready to ship via `pnpm add -D --filter @deepcode/vscode @vscode/vsce @types/vscode`" + } } diff --git a/apps/vscode/scripts/build.mjs b/apps/vscode/scripts/build.mjs new file mode 100644 index 0000000..84b2984 --- /dev/null +++ b/apps/vscode/scripts/build.mjs @@ -0,0 +1,49 @@ +import { mkdir, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const outputRoot = resolve(packageRoot, 'dist'); +await mkdir(outputRoot, { recursive: true }); + +await Promise.all([ + build({ + entryPoints: [resolve(packageRoot, 'src', 'extension.ts')], + outfile: resolve(outputRoot, 'extension.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + external: ['vscode'], + define: { + 'import.meta.url': '__deepcode_import_meta_url', + }, + banner: { + js: 'const __deepcode_import_meta_url = require("node:url").pathToFileURL(__filename).href;', + }, + sourcemap: true, + legalComments: 'none', + }), + build({ + entryPoints: [resolve(packageRoot, '..', 'server', 'src', 'editor-entry.ts')], + outfile: resolve(outputRoot, 'app-server.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + minify: true, + sourcemap: false, + legalComments: 'none', + }), +]); + +const [extension, appServer] = await Promise.all([ + stat(resolve(outputRoot, 'extension.cjs')), + stat(resolve(outputRoot, 'app-server.cjs')), +]); +process.stdout.write( + `Built VS Code extension (${extension.size} bytes) + app-server (${appServer.size} bytes)\n`, +); diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index c1e5099..d2e20f4 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -1,17 +1,27 @@ -// VS Code extension entry — DeepCode "Chat" view + 3 commands. -// Spec: docs/DEVELOPMENT_PLAN.md §v1.1 (VS Code extension) +// VS Code extension entry — thin UI over the shared app-server protocol. import type * as vscode from 'vscode'; +import { ProtocolClient, type ProtocolEvent } from '@deepcode/protocol'; +import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; + +import { EditorProtocolRuntime } from './protocol-runtime.js'; -// Type-only import to keep the build clean without @types/vscode installed -// during the M0 phase. Real `vscode` is injected by the host at activation. type V = typeof import('vscode'); +let activeRuntime: EditorProtocolRuntime | undefined; + export async function activate(context: vscode.ExtensionContext): Promise { const vscodeMod = await loadVscode(); const { commands, window, workspace } = vscodeMod; + const appServer = context.asAbsolutePath('dist/app-server.cjs'); + const runtime = new EditorProtocolRuntime( + new ProtocolClient( + new SpawnedAppServerConnection({ command: process.execPath, args: [appServer] }), + ), + () => workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), + ); + activeRuntime = runtime; - // ── Commands ──────────────────────────────────────────────────────── context.subscriptions.push( commands.registerCommand('deepcode.openPanel', () => { void commands.executeCommand('workbench.view.extension.deepcode'); @@ -32,170 +42,208 @@ export async function activate(context: vscode.ExtensionContext): Promise value: 'Explain this code.', }); if (!prompt) return; - const composed = `${prompt}\n\n----- Selected code -----\n${selection}`; - await runAgent(composed, vscodeMod); + await runInOutput(`${prompt}\n\n----- Selected code -----\n${selection}`, vscodeMod, runtime); }), commands.registerCommand('deepcode.review', async () => { - // Pipe current diff through code-review skill via runAgent. - // Uses `git diff` from the workspace root. - const root = workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!root) { + if (!workspace.workspaceFolders?.[0]) { void window.showInformationMessage('DeepCode: open a folder first.'); return; } - const prompt = + await runInOutput( 'Review the current uncommitted diff. Cite file:line for each finding. ' + - 'Categorize as BUG / LATENT / SUGGESTION.'; - await runAgent(prompt, vscodeMod, root); + 'Categorize as BUG / LATENT / SUGGESTION.', + vscodeMod, + runtime, + ); }), - ); - - // ── Chat view provider ────────────────────────────────────────────── - context.subscriptions.push( - window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod)), + window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod, runtime)), ); } -export function deactivate(): void { - /* no-op */ +export async function deactivate(): Promise { + const runtime = activeRuntime; + activeRuntime = undefined; + await runtime?.close(); } -// ────────────────────────────────────────────────────────────────────────── -// Real runAgent invocation — same @deepcode/core code drives CLI / Mac / LSP -// ────────────────────────────────────────────────────────────────────────── - -async function runAgent( +async function runInOutput( userMessage: string, vscodeMod: V, - cwd: string = process.cwd(), + runtime: EditorProtocolRuntime, ): Promise { const out = vscodeMod.window.createOutputChannel('DeepCode'); out.show(true); out.appendLine(`▎ DeepCode · ${new Date().toLocaleTimeString()}`); - out.appendLine(` ${userMessage.slice(0, 200)}${userMessage.length > 200 ? '…' : ''}`); + out.appendLine(` ${truncate(userMessage, 200)}`); out.appendLine(''); try { - const core = await import('@deepcode/core'); - const credsStore = new core.CredentialsStore(); - const creds = await core.resolveCredentials({ store: credsStore }); - if (!creds.apiKey && !creds.authToken) { + await runtime.start(modelInput(userMessage, vscodeMod), (event) => { + projectOutputEvent(event, out); + void respondToInteraction(event, vscodeMod, runtime); + }); + } catch (error) { + out.appendLine(`\n✕ ${(error as Error).message ?? String(error)}`); + } +} + +function modelInput(text: string, vscodeMod: V) { + const config = vscodeMod.workspace.getConfiguration('deepcode'); + return { + text, + model: config.get('model', 'deepseek-chat'), + effort: config.get('effort', 'medium'), + mode: 'default', + }; +} + +function projectOutputEvent(event: ProtocolEvent, out: vscode.OutputChannel): void { + switch (event.type) { + case 'item.delta': + out.append(event.delta); + break; + case 'tool.started': + out.appendLine(`\n[${event.name}] ${formatInput(event.input)}`); + break; + case 'tool.completed': out.appendLine( - '✕ No DeepSeek credentials. Run `deepcode` once in a terminal to onboard, or set DEEPSEEK_API_KEY.', + ` ${event.result.isError ? '✕' : '✓'} ${truncate(event.result.content, 200)}`, ); - return; - } - const provider = new core.DeepSeekProvider({ - apiKey: creds.apiKey ?? '', - authToken: creds.authToken, - baseURL: creds.baseURL, - }); - const runtime = new core.RuntimeHost({ - provider, - tools: new core.ToolRegistry(core.BUILTIN_TOOLS), - cwd, - mode: 'default', - permissions: { allow: [...core.SAFE_READONLY_TOOLS] }, - }); - await runtime.run({ - systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', - userMessage, - model: 'deepseek-chat', - onEvent: (e) => { - if (e.type === 'text_delta') out.append(e.text); - else if (e.type === 'tool_use') out.appendLine(`\n[${e.name}] ${formatInput(e.input)}`); - else if (e.type === 'tool_result') - out.appendLine(` ${e.result.isError ? '✕' : '✓'} ${truncate(e.result.content, 200)}`); - else if (e.type === 'error') out.appendLine(`\n✕ ${e.error}`); - }, - }); - out.appendLine('\n'); - } catch (err) { - out.appendLine(`\n✕ ${(err as Error).message ?? String(err)}`); + break; + case 'approval.requested': + out.appendLine(`\n[approval] ${event.toolName}: ${event.reason}`); + break; + case 'user-input.requested': + out.appendLine(`\n[input] ${event.question}`); + break; + case 'turn.completed': + out.appendLine('\n'); + break; + case 'turn.interrupted': + out.appendLine('\n⏹ interrupted\n'); + break; + case 'turn.failed': + out.appendLine(`\n✕ ${turnError(event.turn) ?? 'turn failed'}\n`); + break; + } +} + +async function respondToInteraction( + event: ProtocolEvent, + vscodeMod: V, + runtime: EditorProtocolRuntime, +): Promise { + if (event.type === 'approval.requested') { + const choice = await vscodeMod.window.showWarningMessage( + `${event.toolName}: ${event.reason}`, + 'Allow once', + 'Deny', + 'Always allow', + ); + const decision = + choice === 'Always allow' ? 'always' : choice === 'Allow once' ? 'allow' : 'deny'; + await runtime.approve(event.turnId, event.requestId, decision); + } else if (event.type === 'user-input.requested') { + const answer = event.options.length + ? await vscodeMod.window.showQuickPick( + event.options.map((option) => ({ label: option.label, description: option.description })), + { placeHolder: event.question }, + ) + : await vscodeMod.window.showInputBox({ prompt: event.question }); + await runtime.answer( + event.turnId, + event.requestId, + typeof answer === 'string' ? answer : (answer?.label ?? ''), + ); } } function formatInput(input: Record): string { for (const key of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) { - const v = input[key]; - if (typeof v === 'string') return v; + const value = input[key]; + if (typeof value === 'string') return value; } return JSON.stringify(input).slice(0, 80); } -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + '…' : s; +function turnError(turn: Extract['turn']) { + return [...turn.items].reverse().find((item) => item.type === 'error')?.payload.message as + | string + | undefined; +} + +function truncate(value: string, length: number): string { + return value.length > length ? `${value.slice(0, length)}…` : value; } class ChatViewProvider implements vscode.WebviewViewProvider { - constructor(private readonly vscodeMod: V) {} + constructor( + private readonly vscodeMod: V, + private readonly runtime: EditorProtocolRuntime, + ) {} resolveWebviewView(view: vscode.WebviewView): void { view.webview.options = { enableScripts: true }; view.webview.html = chatHtml(); - view.webview.onDidReceiveMessage((msg: unknown) => { - void this.handleMessage(view, msg as { kind: string; text?: string }); + view.webview.onDidReceiveMessage((message: unknown) => { + void this.handleMessage(view, message as { kind: string; text?: string }); }); } private async handleMessage( view: vscode.WebviewView, - msg: { kind: string; text?: string }, + message: { kind: string; text?: string }, ): Promise { - if (msg.kind !== 'send' || !msg.text) return; + if (message.kind !== 'send' || !message.text) return; try { - const core = await import('@deepcode/core'); - const credsStore = new core.CredentialsStore(); - const creds = await core.resolveCredentials({ store: credsStore }); - if (!creds.apiKey && !creds.authToken) { - view.webview.postMessage({ - kind: 'assistant', - text: '(No DeepSeek credentials. Run `deepcode` in a terminal to onboard.)', - }); - return; - } - const provider = new core.DeepSeekProvider({ - apiKey: creds.apiKey ?? '', - authToken: creds.authToken, - baseURL: creds.baseURL, + await this.runtime.start(modelInput(message.text, this.vscodeMod), (event) => { + projectWebviewEvent(event, view); + void respondToInteraction(event, this.vscodeMod, this.runtime); }); - let buffer = ''; - const runtime = new core.RuntimeHost({ - provider, - tools: new core.ToolRegistry(core.BUILTIN_TOOLS), - cwd: this.vscodeMod.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), - mode: 'default', - permissions: { allow: [...core.SAFE_READONLY_TOOLS] }, + } catch (error) { + void view.webview.postMessage({ + kind: 'assistant', + text: `✕ ${(error as Error).message ?? String(error)}`, + }); + } + } +} + +function projectWebviewEvent(event: ProtocolEvent, view: vscode.WebviewView): void { + switch (event.type) { + case 'item.delta': + void view.webview.postMessage({ kind: 'assistant_stream', text: event.delta }); + break; + case 'tool.started': + void view.webview.postMessage({ + kind: 'tool', + text: `[${event.name}] ${formatInput(event.input)}`, }); - await runtime.run({ - systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', - userMessage: msg.text, - model: 'deepseek-chat', - onEvent: (e) => { - if (e.type === 'text_delta') { - buffer += e.text; - view.webview.postMessage({ kind: 'assistant_stream', text: e.text }); - } else if (e.type === 'tool_use') { - view.webview.postMessage({ - kind: 'tool', - text: `[${e.name}] ${formatInput(e.input)}`, - }); - } else if (e.type === 'tool_result') { - view.webview.postMessage({ - kind: 'tool', - text: (e.result.isError ? '✕ ' : '✓ ') + truncate(e.result.content, 200), - }); - } else if (e.type === 'error') { - view.webview.postMessage({ kind: 'assistant', text: `✕ ${e.error}` }); - } - }, + break; + case 'tool.completed': + void view.webview.postMessage({ + kind: 'tool', + text: `${event.result.isError ? '✕' : '✓'} ${truncate(event.result.content, 200)}`, }); - if (buffer) view.webview.postMessage({ kind: 'assistant_end' }); - } catch (err) { - view.webview.postMessage({ + break; + case 'approval.requested': + void view.webview.postMessage({ + kind: 'tool', + text: `[approval] ${event.toolName}: ${event.reason}`, + }); + break; + case 'user-input.requested': + void view.webview.postMessage({ kind: 'tool', text: `[input] ${event.question}` }); + break; + case 'turn.completed': + case 'turn.interrupted': + void view.webview.postMessage({ kind: 'assistant_end' }); + break; + case 'turn.failed': + void view.webview.postMessage({ kind: 'assistant', - text: `✕ ${(err as Error).message ?? String(err)}`, + text: `✕ ${turnError(event.turn) ?? 'turn failed'}`, }); - } + break; } } diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts new file mode 100644 index 0000000..114e550 --- /dev/null +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -0,0 +1,141 @@ +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ProtocolRequest, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { EditorProtocolRuntime } from './protocol-runtime.js'; + +class FakeClient { + handlers = new Set<(event: ProtocolEvent) => void>(); + requests: ProtocolRequest[] = []; + thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + turns: [], + }; + turn: TurnSnapshot = { + id: 'turn-1', + threadId: 'thread-1', + status: 'in_progress', + startedAt: '2026-08-01T00:00:01.000Z', + items: [], + }; + + async connect(): Promise { + return { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + }, + }; + } + + subscribe(handler: (event: ProtocolEvent) => void) { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + async request(method: ProtocolMethod, params: Record = {}): Promise { + this.requests.push({ id: this.requests.length + 1, method, params }); + if (method === 'thread/start' || method === 'thread/read' || method === 'thread/resume') { + return this.thread as T; + } + if (method === 'turn/start') { + this.emit({ type: 'turn.started', threadId: this.thread.id, turn: this.turn }); + this.emit({ + type: 'item.delta', + threadId: this.thread.id, + turnId: this.turn.id, + itemId: 'assistant', + delta: 'hello', + }); + return this.turn as T; + } + if (method === 'turn/interrupt') return { interrupted: true } as T; + return { accepted: true } as T; + } + + async close() {} + + emit(event: ProtocolEvent) { + for (const handler of this.handlers) handler(event); + } +} + +describe('EditorProtocolRuntime', () => { + it('buffers fast turn events and reuses the canonical thread', async () => { + const client = new FakeClient(); + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + const events: ProtocolEvent[] = []; + + await expect(runtime.start({ text: 'hello' }, (event) => events.push(event))).resolves.toEqual({ + threadId: 'thread-1', + turnId: 'turn-1', + }); + expect(events.map((event) => event.type)).toEqual(['turn.started', 'item.delta']); + + await runtime.start({ text: 'again' }, () => undefined); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/start', + 'turn/start', + 'thread/read', + 'turn/start', + ]); + }); + + it('routes approval, user input, and interruption with server ids', async () => { + const client = new FakeClient(); + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + await runtime.start({ text: 'edit' }, () => undefined); + + await expect(runtime.approve('turn-1', 'approval-1', 'allow')).resolves.toEqual({ + accepted: true, + }); + await expect(runtime.answer('turn-1', 'question-1', 'All')).resolves.toEqual({ + accepted: true, + }); + await expect(runtime.interrupt('turn-1')).resolves.toBe(true); + + expect(client.requests.slice(-3)).toEqual([ + expect.objectContaining({ + method: 'approval/respond', + params: expect.objectContaining({ threadId: 'thread-1', requestId: 'approval-1' }), + }), + expect.objectContaining({ + method: 'user-input/respond', + params: expect.objectContaining({ threadId: 'thread-1', answer: 'All' }), + }), + expect.objectContaining({ method: 'turn/interrupt' }), + ]); + }); + + it('drops routing state on terminal events and closes the client', async () => { + const client = new FakeClient(); + client.close = vi.fn(); + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + const handler = vi.fn(); + await runtime.start({ text: 'done' }, handler); + client.turn = { + ...client.turn, + status: 'completed', + completedAt: '2026-08-01T00:00:02.000Z', + }; + client.emit({ type: 'turn.completed', threadId: 'thread-1', turn: client.turn }); + + await expect(runtime.interrupt('turn-1')).resolves.toBe(false); + await runtime.close(); + expect(client.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/vscode/src/protocol-runtime.ts b/apps/vscode/src/protocol-runtime.ts new file mode 100644 index 0000000..84d1363 --- /dev/null +++ b/apps/vscode/src/protocol-runtime.ts @@ -0,0 +1,187 @@ +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; + +export interface EditorProtocolClient { + connect(): Promise; + request(method: ProtocolMethod, params?: Record): Promise; + subscribe(handler: (event: ProtocolEvent) => void): () => void; + close(): Promise; +} + +export interface StartEditorTurn { + text: string; + threadId?: string; + model?: string; + effort?: string; + mode?: string; +} + +type EventHandler = (event: ProtocolEvent) => void; + +/** Owns one editor's canonical thread and routes events by server turn id. */ +export class EditorProtocolRuntime { + private threadId?: string; + private readonly turnThreads = new Map(); + private readonly handlers = new Map(); + private readonly queued = new Map(); + private readonly unsubscribe: () => void; + + constructor( + private readonly client: EditorProtocolClient, + private readonly cwd: () => string, + ) { + this.unsubscribe = client.subscribe((event) => this.route(event)); + } + + async start( + input: StartEditorTurn, + onEvent: EventHandler, + ): Promise<{ threadId: string; turnId: string }> { + if (!input.text.trim()) throw new Error('prompt is required'); + await this.client.connect(); + const thread = await this.ensureThread(input.threadId); + const turn = await this.client.request('turn/start', { + threadId: thread.id, + input: { + text: input.text, + ...(input.model ? { model: input.model } : {}), + ...(input.effort ? { effort: input.effort } : {}), + ...(input.mode ? { mode: input.mode } : {}), + }, + }); + this.turnThreads.set(turn.id, thread.id); + this.handlers.set(turn.id, onEvent); + this.flush(turn.id); + return { threadId: thread.id, turnId: turn.id }; + } + + async resume(threadId: string): Promise { + await this.client.connect(); + const thread = await this.client.request('thread/resume', { threadId }); + this.threadId = thread.id; + return thread; + } + + async read(threadId: string): Promise { + await this.client.connect(); + return this.client.request('thread/read', { threadId }); + } + + async interrupt(turnId: string): Promise { + const threadId = this.turnThreads.get(turnId); + if (!threadId) return false; + const result = await this.client.request<{ interrupted: boolean }>('turn/interrupt', { + threadId, + turnId, + }); + return result.interrupted; + } + + approve( + turnId: string, + requestId: string, + decision: 'allow' | 'deny' | 'always', + ): Promise<{ accepted: boolean }> { + return this.interactionRequest('approval/respond', turnId, { requestId, decision }); + } + + answer(turnId: string, requestId: string, answer: string): Promise<{ accepted: boolean }> { + return this.interactionRequest('user-input/respond', turnId, { requestId, answer }); + } + + async close(): Promise { + this.unsubscribe(); + this.handlers.clear(); + this.turnThreads.clear(); + this.queued.clear(); + await this.client.close(); + } + + private async ensureThread(requested?: string): Promise { + if (requested && requested !== this.threadId) { + const thread = await this.client.request('thread/resume', { + threadId: requested, + }); + this.threadId = thread.id; + return thread; + } + if (this.threadId) { + return this.client.request('thread/read', { threadId: this.threadId }); + } + const thread = await this.client.request('thread/start', { cwd: this.cwd() }); + this.threadId = thread.id; + return thread; + } + + private async interactionRequest( + method: 'approval/respond' | 'user-input/respond', + turnId: string, + params: Record, + ): Promise<{ accepted: boolean }> { + const threadId = this.turnThreads.get(turnId); + if (!threadId) throw new Error(`Active turn not found: ${turnId}`); + return this.client.request(method, { threadId, turnId, ...params }); + } + + private route(event: ProtocolEvent): void { + const turnId = turnIdFrom(event); + if (!turnId) return; + const handler = this.handlers.get(turnId); + if (!handler) { + const queued = this.queued.get(turnId) ?? []; + queued.push(event); + this.queued.set(turnId, queued); + return; + } + this.deliver(handler, event); + } + + private flush(turnId: string): void { + const handler = this.handlers.get(turnId); + if (!handler) return; + const events = this.queued.get(turnId) ?? []; + this.queued.delete(turnId); + for (const event of events) this.deliver(handler, event); + } + + private deliver(handler: EventHandler, event: ProtocolEvent): void { + handler(event); + if (isTerminal(event)) { + const turnId = event.turn.id; + this.handlers.delete(turnId); + this.turnThreads.delete(turnId); + this.queued.delete(turnId); + } + } +} + +function turnIdFrom(event: ProtocolEvent): string | undefined { + if (event.type === 'thread.started') return undefined; + if ( + event.type === 'turn.started' || + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ) { + return event.turn.id; + } + return event.turnId; +} + +function isTerminal( + event: ProtocolEvent, +): event is Extract< + ProtocolEvent, + { type: 'turn.completed' | 'turn.interrupted' | 'turn.failed' } +> { + return ( + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ); +} diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json index 1319e84..adbc0ff 100644 --- a/apps/vscode/tsconfig.json +++ b/apps/vscode/tsconfig.json @@ -6,8 +6,8 @@ "composite": true, "tsBuildInfoFile": "./dist/.tsbuildinfo", "lib": ["ES2022"], - "module": "CommonJS", - "moduleResolution": "node", + "module": "ESNext", + "moduleResolution": "Bundler", "types": ["node"], "skipLibCheck": true, "isolatedModules": false, @@ -15,5 +15,5 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], - "references": [{ "path": "../../packages/core" }] + "references": [{ "path": "../../packages/protocol" }, { "path": "../server" }] } diff --git a/apps/vscode/vitest.config.ts b/apps/vscode/vitest.config.ts index 533d53d..1f7972f 100644 --- a/apps/vscode/vitest.config.ts +++ b/apps/vscode/vitest.config.ts @@ -1,5 +1,3 @@ -// No tests yet (extension runs in VS Code host; integration tests need -// @vscode/test-electron which is heavy). export default { test: { include: ['src/**/*.test.ts'] }, configFile: false, diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 10fcf78..2fc2c60 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -302,11 +302,13 @@ model tool call ### PR 7 — VS Code 与 LSP 收敛 -- VS Code 改用同一 runtime/protocol,删除重复 provider/runtime 组装。 +- VS Code 已改用 shared `ProtocolClient` + 独立 app-server bundle,删除重复 provider/runtime/credential + 组装;扩展 build 现在生成真实 `extension.cjs` 与 child bundle。 - LSP 已改为 shared `ProtocolClient` + 独立 app-server 子进程;不再读取凭证或组装 `DeepSeekProvider`/`RuntimeHost`,并公开 read/resume/interrupt/approval/user-input 命令与原生事件。 -- 支持 read/resume、structured tool items、approval、interrupt 与 diff context。 -- LSP 只承担编辑器兼容;移除 `passWithNoTests`,增加真正测试。 +- 两个编辑器入口均支持 canonical thread、structured tool items、approval、AskUserQuestion 与 + interrupt;VS Code diff context 继续由 agent 在 workspace 中读取,后续再接 `vscode.git` 优化。 +- LSP 只承担编辑器兼容;VS Code 与 LSP 均已有真正协议测试,不再使用 `passWithNoTests`。 验收:完成后的 thread 可在 CLI、desktop、VS Code 间恢复;事件和权限语义一致。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58c190a..f30187e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,9 +168,12 @@ importers: apps/vscode: dependencies: - '@deepcode/core': + '@deepcode/app-server': specifier: workspace:* - version: link:../../packages/core + version: link:../server + '@deepcode/protocol': + specifier: workspace:* + version: link:../../packages/protocol devDependencies: '@types/node': specifier: ^22.10.0 @@ -178,6 +181,12 @@ importers: '@types/vscode': specifier: ^1.85.0 version: 1.120.0 + '@vscode/vsce': + specifier: ^3.9.2 + version: 3.9.2 + esbuild: + specifier: ^0.21.5 + version: 0.21.5 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -221,6 +230,56 @@ importers: packages: + '@azu/format-text@1.0.2': + resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} + + '@azu/style-format@1.0.1': + resolution: {integrity: sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==} + + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.0': + resolution: {integrity: sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/identity@4.13.1': + resolution: {integrity: sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==} + engines: {node: '>=20.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + + '@azure/msal-browser@5.17.3': + resolution: {integrity: sha512-qMabD7Xrm/UgRhs+/IVyCTZRjUl8Qb+uGsRrCkaKNWsiTwRaeyStJbChE7/ySNTNYtiWo7khfF7vUDd0wGMnLw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.11.3': + resolution: {integrity: sha512-VeXOW+t3Rdd9XGX6lVyIg3DhtjMR1JD8ARKcsnGbJFUWwAmF3sHL7GwZc/ZjEUfHESResAonETRYCuG06OBT7A==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.4.3': + resolution: {integrity: sha512-tumCMmzrRhKmTbYQg/7OlfbrIKcKaf8Ed0Fw3suUpRT3owFYljznVgxcfHe8RycQXY9uyROGiLD1GjhpF45AwA==} + engines: {node: '>=20'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -528,6 +587,18 @@ packages: '@cfworker/json-schema': optional: true + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} @@ -661,6 +732,55 @@ packages: cpu: [x64] os: [win32] + '@secretlint/config-creator@10.2.2': + resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/config-loader@10.2.2': + resolution: {integrity: sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/core@10.2.2': + resolution: {integrity: sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==} + engines: {node: '>=20.0.0'} + + '@secretlint/formatter@10.2.2': + resolution: {integrity: sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==} + engines: {node: '>=20.0.0'} + + '@secretlint/node@10.2.2': + resolution: {integrity: sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/profiler@10.2.2': + resolution: {integrity: sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==} + + '@secretlint/resolver@10.2.2': + resolution: {integrity: sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==} + + '@secretlint/secretlint-formatter-sarif@10.2.2': + resolution: {integrity: sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==} + + '@secretlint/secretlint-rule-no-dotenv@10.2.2': + resolution: {integrity: sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==} + engines: {node: '>=20.0.0'} + + '@secretlint/secretlint-rule-preset-recommend@10.2.2': + resolution: {integrity: sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==} + engines: {node: '>=20.0.0'} + + '@secretlint/source-creator@10.2.2': + resolution: {integrity: sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==} + engines: {node: '>=20.0.0'} + + '@secretlint/types@10.2.2': + resolution: {integrity: sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==} + engines: {node: '>=20.0.0'} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + '@tauri-apps/api@2.11.0': resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} @@ -753,6 +873,22 @@ packages: '@tauri-apps/plugin-updater@2.10.1': resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@textlint/ast-node-types@15.8.0': + resolution: {integrity: sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==} + + '@textlint/linter-formatter@15.8.0': + resolution: {integrity: sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w==} + engines: {node: '>=20.18.0'} + + '@textlint/module-interop@15.8.0': + resolution: {integrity: sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg==} + + '@textlint/resolver@15.8.0': + resolution: {integrity: sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA==} + + '@textlint/types@15.8.0': + resolution: {integrity: sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -780,6 +916,9 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -791,6 +930,9 @@ packages: '@types/react@18.3.29': resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + '@types/sarif@2.1.7': + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/vscode@1.120.0': resolution: {integrity: sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==} @@ -853,6 +995,10 @@ packages: resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typespec/ts-http-runtime@0.3.8': + resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} + engines: {node: '>=22.0.0'} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -888,6 +1034,59 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vscode/vsce-sign-alpine-arm64@2.0.6': + resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} + cpu: [arm64] + os: [alpine] + + '@vscode/vsce-sign-alpine-x64@2.0.6': + resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==} + cpu: [x64] + os: [alpine] + + '@vscode/vsce-sign-darwin-arm64@2.0.6': + resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==} + cpu: [arm64] + os: [darwin] + + '@vscode/vsce-sign-darwin-x64@2.0.6': + resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==} + cpu: [x64] + os: [darwin] + + '@vscode/vsce-sign-linux-arm64@2.0.6': + resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==} + cpu: [arm64] + os: [linux] + + '@vscode/vsce-sign-linux-arm@2.0.6': + resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==} + cpu: [arm] + os: [linux] + + '@vscode/vsce-sign-linux-x64@2.0.6': + resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==} + cpu: [x64] + os: [linux] + + '@vscode/vsce-sign-win32-arm64@2.0.6': + resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==} + cpu: [arm64] + os: [win32] + + '@vscode/vsce-sign-win32-x64@2.0.6': + resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==} + cpu: [x64] + os: [win32] + + '@vscode/vsce-sign@2.0.9': + resolution: {integrity: sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==} + + '@vscode/vsce@3.9.2': + resolution: {integrity: sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==} + engines: {node: '>= 20'} + hasBin: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -902,6 +1101,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -916,32 +1119,94 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + azure-devops-node-api@12.5.0: + resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.32: resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} engines: {node: '>=6.0.0'} hasBin: true + binaryextensions@6.11.0: + resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} + engines: {node: '>=4'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boundary@2.0.0: + resolution: {integrity: sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -965,10 +1230,47 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cockatiel@3.2.1: + resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} + engines: {node: '>=16'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1000,6 +1302,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1012,31 +1321,104 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + editions@6.22.0: + resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} + engines: {ecmascript: '>= es5', node: '>=4'} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} electron-to-chromium@1.5.363: resolution: {integrity: sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1052,6 +1434,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1125,6 +1511,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1142,6 +1532,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1151,6 +1545,9 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1164,6 +1561,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1179,6 +1580,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1187,6 +1592,13 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1212,39 +1624,95 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1257,9 +1725,16 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -1268,20 +1743,46 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istextorbinary@9.5.0: + resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} + engines: {node: '>=4'} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -1292,6 +1793,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1317,17 +1822,70 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keytar@7.9.0: + resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1335,16 +1893,34 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -1353,26 +1929,67 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1380,10 +1997,28 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + + node-addon-api@4.3.0: + resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} + node-sarif-builder@3.4.0: + resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} + engines: {node: '>=20'} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1399,6 +2034,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + openai@6.39.1: resolution: {integrity: sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A==} hasBin: true @@ -1423,6 +2062,26 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@7.0.6: + resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} + engines: {node: '>=18'} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse-semver@1.1.1: + resolution: {integrity: sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1435,9 +2094,17 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1445,9 +2112,16 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -1466,10 +2140,23 @@ packages: engines: {node: '>=20'} hasBin: true + pluralize@2.0.0: + resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1483,6 +2170,13 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1491,6 +2185,9 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1499,6 +2196,13 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc-config-loader@4.1.4: + resolution: {integrity: sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1512,10 +2216,26 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + + read@1.0.7: + resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} + engines: {node: '>=0.8'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1525,12 +2245,35 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + secretlint@10.2.2: + resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} + engines: {node: '>=20.0.0'} + hasBin: true + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1578,10 +2321,36 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1592,6 +2361,58 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + structured-source@4.0.0: + resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + terminal-link@4.0.0: + resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} + engines: {node: '>=18'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + textextensions@6.11.0: + resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} + engines: {node: '>=4'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1614,6 +2435,14 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1624,14 +2453,31 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typed-rest-client@1.8.11: + resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} + typescript-eslint@8.60.0: resolution: {integrity: sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1644,9 +2490,31 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1660,10 +2528,23 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + version-range@4.15.0: + resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} + engines: {node: '>=4'} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1725,6 +2606,15 @@ packages: jsdom: optional: true + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1742,9 +2632,31 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + + yazl@2.5.1: + resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1759,6 +2671,94 @@ packages: snapshots: + '@azu/format-text@1.0.2': {} + + '@azu/style-format@1.0.1': + dependencies: + '@azu/format-text': 1.0.2 + + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-rest-pipeline@1.25.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/identity@4.13.1': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@azure/msal-browser': 5.17.3 + '@azure/msal-node': 5.4.3 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.4.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@5.17.3': + dependencies: + '@azure/msal-common': 16.11.3 + + '@azure/msal-common@16.11.3': {} + + '@azure/msal-node@5.4.3': + dependencies: + '@azure/msal-common': 16.11.3 + jsonwebtoken: 9.0.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2033,6 +3033,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 @@ -2114,6 +3126,82 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@secretlint/config-creator@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + + '@secretlint/config-loader@10.2.2': + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + ajv: 8.20.0 + debug: 4.4.3 + rc-config-loader: 4.1.4 + transitivePeerDependencies: + - supports-color + + '@secretlint/core@10.2.2': + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3 + structured-source: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@secretlint/formatter@10.2.2': + dependencies: + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + '@textlint/linter-formatter': 15.8.0 + '@textlint/module-interop': 15.8.0 + '@textlint/types': 15.8.0 + chalk: 5.6.2 + debug: 4.4.3 + pluralize: 8.0.0 + strip-ansi: 7.2.0 + table: 6.9.0 + terminal-link: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@secretlint/node@10.2.2': + dependencies: + '@secretlint/config-loader': 10.2.2 + '@secretlint/core': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/profiler': 10.2.2 + '@secretlint/source-creator': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3 + p-map: 7.0.6 + transitivePeerDependencies: + - supports-color + + '@secretlint/profiler@10.2.2': {} + + '@secretlint/resolver@10.2.2': {} + + '@secretlint/secretlint-formatter-sarif@10.2.2': + dependencies: + node-sarif-builder: 3.4.0 + + '@secretlint/secretlint-rule-no-dotenv@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + + '@secretlint/secretlint-rule-preset-recommend@10.2.2': {} + + '@secretlint/source-creator@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + istextorbinary: 9.5.0 + + '@secretlint/types@10.2.2': {} + + '@sindresorhus/merge-streams@2.3.0': {} + '@tauri-apps/api@2.11.0': {} '@tauri-apps/cli-darwin-arm64@2.11.2': @@ -2187,6 +3275,34 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 + '@textlint/ast-node-types@15.8.0': {} + + '@textlint/linter-formatter@15.8.0': + dependencies: + '@azu/format-text': 1.0.2 + '@azu/style-format': 1.0.1 + '@textlint/module-interop': 15.8.0 + '@textlint/resolver': 15.8.0 + '@textlint/types': 15.8.0 + debug: 4.4.3 + js-yaml: 4.3.1 + lodash: 4.18.1 + pluralize: 2.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + table: 6.9.0 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + '@textlint/module-interop@15.8.0': {} + + '@textlint/resolver@15.8.0': {} + + '@textlint/types@15.8.0': + dependencies: + '@textlint/ast-node-types': 15.8.0 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -2220,6 +3336,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/normalize-package-data@2.4.4': {} + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.29)': @@ -2231,6 +3349,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/sarif@2.1.7': {} + '@types/vscode@1.120.0': {} '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.1(jiti@1.21.7))(typescript@5.9.3))(eslint@10.4.1(jiti@1.21.7))(typescript@5.9.3)': @@ -2324,6 +3444,14 @@ snapshots: '@typescript-eslint/types': 8.60.0 eslint-visitor-keys: 5.0.1 + '@typespec/ts-http-runtime@0.3.8': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.19))': dependencies: '@babel/core': 7.29.7 @@ -2376,6 +3504,81 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vscode/vsce-sign-alpine-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-alpine-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-darwin-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-darwin-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-arm@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-win32-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-win32-x64@2.0.6': + optional: true + + '@vscode/vsce-sign@2.0.9': + optionalDependencies: + '@vscode/vsce-sign-alpine-arm64': 2.0.6 + '@vscode/vsce-sign-alpine-x64': 2.0.6 + '@vscode/vsce-sign-darwin-arm64': 2.0.6 + '@vscode/vsce-sign-darwin-x64': 2.0.6 + '@vscode/vsce-sign-linux-arm': 2.0.6 + '@vscode/vsce-sign-linux-arm64': 2.0.6 + '@vscode/vsce-sign-linux-x64': 2.0.6 + '@vscode/vsce-sign-win32-arm64': 2.0.6 + '@vscode/vsce-sign-win32-x64': 2.0.6 + + '@vscode/vsce@3.9.2': + dependencies: + '@azure/identity': 4.13.1 + '@secretlint/node': 10.2.2 + '@secretlint/secretlint-formatter-sarif': 10.2.2 + '@secretlint/secretlint-rule-no-dotenv': 10.2.2 + '@secretlint/secretlint-rule-preset-recommend': 10.2.2 + '@vscode/vsce-sign': 2.0.9 + azure-devops-node-api: 12.5.0 + chalk: 4.1.2 + cheerio: 1.2.0 + cockatiel: 3.2.1 + commander: 12.1.0 + form-data: 4.0.6 + glob: 13.0.6 + hosted-git-info: 4.1.0 + jsonc-parser: 3.3.1 + leven: 3.1.0 + markdown-it: 14.3.0 + mime: 1.6.0 + minimatch: 10.2.5 + parse-semver: 1.1.1 + read: 1.0.7 + secretlint: 10.2.2 + semver: 7.8.1 + tmp: 0.2.7 + typed-rest-client: 1.8.11 + url-join: 4.0.1 + xml2js: 0.5.0 + yauzl: 3.4.0 + yazl: 2.5.1 + optionalDependencies: + keytar: 7.9.0 + transitivePeerDependencies: + - supports-color + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -2387,6 +3590,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -2405,12 +3610,49 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + azure-devops-node-api@12.5.0: + dependencies: + tunnel: 0.0.6 + typed-rest-client: 1.8.11 + balanced-match@4.0.4: {} + base64-js@1.5.1: + optional: true + baseline-browser-mapping@2.10.32: {} + binaryextensions@6.11.0: + dependencies: + editions: 6.22.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -2425,10 +3667,18 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + + boundary@2.0.0: {} + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.32 @@ -2437,6 +3687,20 @@ snapshots: node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} cac@6.7.14: {} @@ -2461,8 +3725,55 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + check-error@2.1.3: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.29.0 + whatwg-mimetype: 4.0.0 + + chownr@1.1.4: + optional: true + + cockatiel@3.2.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -2486,30 +3797,108 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + csstype@3.2.3: {} debug@4.4.3: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + deep-eql@5.0.2: {} + deep-extend@0.6.0: + optional: true + deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + delayed-stream@1.0.0: {} + depd@2.0.0: {} + detect-libc@2.1.2: + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + editions@6.22.0: + dependencies: + version-range: 4.15.0 + ee-first@1.1.1: {} electron-to-chromium@1.5.363: {} + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2520,6 +3909,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2630,6 +4026,9 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + expand-template@2.0.3: + optional: true + expect-type@1.3.0: {} express-rate-limit@8.5.2(express@5.2.1): @@ -2672,12 +4071,24 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} fast-uri@3.1.2: {} + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2686,6 +4097,10 @@ snapshots: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -2709,10 +4124,27 @@ snapshots: flatted@3.4.2: {} + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@2.0.0: {} + fs-constants@1.0.0: + optional: true + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.2: optional: true @@ -2741,20 +4173,69 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + github-from-package@0.0.0: + optional: true + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globby@14.1.0: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.3: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hono@4.12.23: {} + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -2763,34 +4244,80 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + husky@9.1.7: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: + optional: true + ignore@5.3.2: {} ignore@7.0.5: {} imurmurhash@0.1.4: {} + index-to-position@1.2.0: {} + inherits@2.0.4: {} + ini@1.3.8: + optional: true + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + is-promise@4.0.0: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} + istextorbinary@9.5.0: + dependencies: + binaryextensions: 6.11.0 + editions: 6.22.0 + textextensions: 6.11.0 + jiti@1.21.7: optional: true @@ -2798,6 +4325,10 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2812,59 +4343,194 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keytar@7.9.0: + dependencies: + node-addon-api: 4.3.0 + prebuild-install: 7.1.3 + optional: true + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + leven@3.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash.truncate@4.4.2: {} + + lodash@4.18.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 loupe@3.2.1: {} + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdurl@2.1.0: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + + mimic-response@3.1.0: + optional: true + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 + minimist@1.2.8: + optional: true + + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: + optional: true + ms@2.1.3: {} + mute-stream@0.0.8: {} + nanoid@3.3.12: {} + napi-build-utils@2.0.0: + optional: true + natural-compare@1.4.0: {} negotiator@1.0.0: {} + node-abi@3.94.0: + dependencies: + semver: 7.8.1 + optional: true + + node-addon-api@4.3.0: + optional: true + node-releases@2.0.46: {} + node-sarif-builder@3.4.0: + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.4.0 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.8.1 + validate-npm-package-license: 3.0.4 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -2877,6 +4543,13 @@ snapshots: dependencies: wrappy: 1.0.2 + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + openai@6.39.1(zod@4.4.3): optionalDependencies: zod: 4.4.3 @@ -2898,20 +4571,56 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@7.0.6: {} + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse-semver@1.1.1: + dependencies: + semver: 5.7.2 + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} + path-type@6.0.0: {} + pathe@1.1.2: {} pathval@2.0.1: {} + pend@1.2.0: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} pkce-challenge@5.0.1: {} @@ -2924,12 +4633,32 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pluralize@2.0.0: {} + + pluralize@8.0.0: {} + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + optional: true + prelude-ls@1.2.1: {} prettier@3.8.3: {} @@ -2939,12 +4668,22 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + punycode.js@2.3.1: {} + punycode@2.3.1: {} qs@6.15.2: dependencies: side-channel: 1.1.0 + queue-microtask@1.2.3: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -2954,6 +4693,23 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc-config-loader@4.1.4: + dependencies: + debug: 4.4.3 + js-yaml: 4.3.1 + json5: 2.2.3 + require-from-string: 2.0.2 + transitivePeerDependencies: + - supports-color + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -2966,8 +4722,29 @@ snapshots: dependencies: loose-envify: 1.4.0 + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 + + read@1.0.7: + dependencies: + mute-stream: 0.0.8 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + require-from-string@2.0.2: {} + reusify@1.1.0: {} + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -3009,12 +4786,36 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + sax@1.6.1: {} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 + secretlint@10.2.2: + dependencies: + '@secretlint/config-creator': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/node': 10.2.2 + '@secretlint/profiler': 10.2.2 + debug: 4.4.3 + globby: 14.1.0 + read-pkg: 9.0.1 + transitivePeerDependencies: + - supports-color + + semver@5.7.2: {} + semver@6.3.1: {} semver@7.8.1: {} @@ -3082,14 +4883,117 @@ snapshots: siginfo@2.0.0: {} + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + source-map-js@1.2.1: {} + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + stackback@0.0.2: {} statuses@2.0.2: {} std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: + optional: true + + structured-source@4.0.0: + dependencies: + boundary: 2.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + terminal-link@4.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 3.2.0 + + text-table@0.2.0: {} + + textextensions@6.11.0: + dependencies: + editions: 6.22.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -3105,22 +5009,45 @@ snapshots: tinyspy@3.0.2: {} + tmp@0.2.7: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + tunnel@0.0.6: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + type-is@2.1.0: dependencies: content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 + typed-rest-client@1.8.11: + dependencies: + qs: 6.15.2 + tunnel: 0.0.6 + underscore: 1.13.8 + typescript-eslint@8.60.0(eslint@10.4.1(jiti@1.21.7))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.1(jiti@1.21.7))(typescript@5.9.3))(eslint@10.4.1(jiti@1.21.7))(typescript@5.9.3) @@ -3134,8 +5061,20 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + + underscore@1.13.8: {} + undici-types@6.21.0: {} + undici@7.29.0: {} + + unicorn-magic@0.1.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + unpipe@1.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -3148,8 +5087,20 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@4.0.1: {} + + util-deprecate@1.0.2: + optional: true + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + vary@1.1.2: {} + version-range@4.15.0: {} + vite-node@2.1.9(@types/node@22.19.19): dependencies: cac: 6.7.14 @@ -3212,6 +5163,12 @@ snapshots: - supports-color - terser + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3225,8 +5182,29 @@ snapshots: wrappy@1.0.2: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xml2js@0.5.0: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + yallist@3.1.1: {} + yallist@4.0.0: {} + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + + yazl@2.5.1: + dependencies: + buffer-crc32: 0.2.13 + yocto-queue@0.1.0: {} zod-to-json-schema@3.25.2(zod@4.4.3): From e80cbacd9d2f68df01d3705a85a87db1f9c2b206 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 15:54:34 +0800 Subject: [PATCH 21/23] feat: add trust-aware config diagnostics --- apps/cli/src/trust.ts | 78 ++--------------- apps/desktop/src/lib/protocol-agent.test.ts | 1 + apps/desktop/src/lib/protocol-client.test.ts | 1 + apps/desktop/src/preview-app.tsx | 1 + apps/lsp/src/handler.test.ts | 1 + apps/server/README.md | 4 + apps/server/src/client.test.ts | 3 +- apps/server/src/default-runtime.ts | 8 +- apps/server/src/run.test.ts | 64 ++++++++++++++ apps/server/src/run.ts | 8 ++ apps/server/src/server.test.ts | 48 +++++++++++ apps/server/src/server.ts | 8 ++ apps/vscode/src/protocol-runtime.test.ts | 1 + docs/CODEX_ALIGNMENT_PLAN.md | 4 +- docs/design/app-server-v1.md | 37 ++++---- docs/design/runtime-protocol-v1.md | 3 +- docs/security-model.md | 9 +- packages/core/src/config/diagnostics.test.ts | 61 +++++++++++++ packages/core/src/config/diagnostics.ts | 89 +++++++++++++++++++ packages/core/src/config/index.ts | 19 ++++ packages/core/src/config/loader.test.ts | 30 +++++++ packages/core/src/config/loader.ts | 90 ++++++++++++++++++- packages/core/src/config/schema.ts | 63 +------------- packages/core/src/config/trust-gate.test.ts | 49 +++++++++-- packages/core/src/config/trust-gate.ts | 30 ++++++- packages/core/src/config/trust-store.test.ts | 41 +++++++++ packages/core/src/config/trust-store.ts | 91 ++++++++++++++++++++ packages/core/src/config/validation.ts | 57 ++++++++++++ packages/core/src/index.ts | 9 ++ packages/protocol/src/client.test.ts | 1 + packages/protocol/src/codec.test.ts | 2 +- packages/protocol/src/codec.ts | 1 + packages/protocol/src/runtime.test.ts | 1 + packages/protocol/src/runtime.ts | 2 + packages/protocol/src/types.ts | 24 ++++++ 35 files changed, 767 insertions(+), 172 deletions(-) create mode 100644 apps/server/src/run.test.ts create mode 100644 packages/core/src/config/diagnostics.test.ts create mode 100644 packages/core/src/config/diagnostics.ts create mode 100644 packages/core/src/config/trust-store.test.ts create mode 100644 packages/core/src/config/trust-store.ts create mode 100644 packages/core/src/config/validation.ts diff --git a/apps/cli/src/trust.ts b/apps/cli/src/trust.ts index 89ab5d8..ba37821 100644 --- a/apps/cli/src/trust.ts +++ b/apps/cli/src/trust.ts @@ -1,72 +1,6 @@ -// Trust dialog — track which directories the user has approved for full feature access. -// Spec: docs/DEVELOPMENT_PLAN.md §3.15.10 -// M2: tracks state to ~/.deepcode/trusted-dirs.json; CLI prompt for new dirs. -// Hooks/MCP/apiKeyHelper gating is consulted by their owners (deferred to M3). - -import { promises as fs } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; - -export interface TrustState { - dirs: Record; -} - -/** A fresh empty state. Must be a factory — returning a shared object literal - * would let `trust()`/`untrust()` mutate `dirs` on the shared instance, leaking - * entries into later `load()`s of a not-yet-created store file. */ -function emptyState(): TrustState { - return { dirs: {} }; -} - -export interface TrustStoreOpts { - home?: string; -} - -export class TrustStore { - private readonly home: string; - constructor(opts: TrustStoreOpts = {}) { - this.home = opts.home ?? homedir(); - } - - filePath(): string { - return join(this.home, '.deepcode', 'trusted-dirs.json'); - } - - async load(): Promise { - try { - const raw = await fs.readFile(this.filePath(), 'utf8'); - return JSON.parse(raw) as TrustState; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return emptyState(); - throw err; - } - } - - async save(state: TrustState): Promise { - const path = this.filePath(); - await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8'); - } - - async statusFor(cwd: string): Promise<'trusted' | 'plan-only' | 'untrusted'> { - const abs = resolve(cwd); - const state = await this.load(); - const entry = state.dirs[abs]; - if (!entry) return 'untrusted'; - return entry.mode === 'plan-only' ? 'plan-only' : 'trusted'; - } - - async trust(cwd: string, mode: 'full' | 'plan-only'): Promise { - const abs = resolve(cwd); - const state = await this.load(); - state.dirs[abs] = { trustedAt: new Date().toISOString(), mode }; - await this.save(state); - } - - async untrust(cwd: string): Promise { - const abs = resolve(cwd); - const state = await this.load(); - delete state.dirs[abs]; - await this.save(state); - } -} +// Compatibility name for the shared core trust store. +export { DirectoryTrustStore as TrustStore } from '@deepcode/core'; +export type { + DirectoryTrustState as TrustState, + DirectoryTrustStoreOptions as TrustStoreOpts, +} from '@deepcode/core'; diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index d3eae14..0884d63 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -23,6 +23,7 @@ class FakeTransport implements ProtocolTransport { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true, }, }; } diff --git a/apps/desktop/src/lib/protocol-client.test.ts b/apps/desktop/src/lib/protocol-client.test.ts index ef9a32c..5ee0b2e 100644 --- a/apps/desktop/src/lib/protocol-client.test.ts +++ b/apps/desktop/src/lib/protocol-client.test.ts @@ -43,6 +43,7 @@ class FakeBridge implements ProtocolClientBridge { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true, }, } : { ok: true }, diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index 62b35e5..58d41fc 100644 --- a/apps/desktop/src/preview-app.tsx +++ b/apps/desktop/src/preview-app.tsx @@ -189,6 +189,7 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true, }, }); break; diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index b1bd65e..aa4af20 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -19,6 +19,7 @@ const capabilities: InitializeResult = { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true, }, }; diff --git a/apps/server/README.md b/apps/server/README.md index a8dd7d1..9a44c9a 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -18,3 +18,7 @@ After a workspace build, run `node apps/server/dist/cli.js` and send one JSON re ``` The transport is experimental. Clients must negotiate `protocolVersion` before using it. + +`config/diagnostics` accepts a workspace `cwd` and returns a value-free report containing loaded +layers, leaf provenance, trust-gated fields, and validation issues. Configuration values and +credentials never cross this protocol boundary. diff --git a/apps/server/src/client.test.ts b/apps/server/src/client.test.ts index f6fe9c9..c4e4507 100644 --- a/apps/server/src/client.test.ts +++ b/apps/server/src/client.test.ts @@ -13,7 +13,8 @@ lines.on('line', (line) => { const result = request.method === 'initialize' ? { protocolVersion: 1, capabilities: { threadResume: true, turnInterrupt: true, completedItemPersistence: true, - transientDeltas: true, structuredToolEvents: true, interactiveRequests: true + transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true } } : { echoed: request.method }; process.stdout.write(JSON.stringify({ id: request.id, result }) + '\n'); diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 771bb60..23827aa 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -1,5 +1,5 @@ import { CredentialsStore, resolveCredentials } from '@deepcode/core/credentials'; -import { loadSettings } from '@deepcode/core/config'; +import { DirectoryTrustStore, gateUntrustedSettings, loadSettings } from '@deepcode/core/config'; import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; import { RuntimeHost, SAFE_READONLY_TOOLS } from '@deepcode/core/runtime'; import { SessionManager } from '@deepcode/core/sessions'; @@ -11,15 +11,15 @@ export function createDefaultTurnExecutor( home?: string, options: { forceFileCredentials?: boolean } = {}, ): RuntimeHostExecutor { + const trustStore = new DirectoryTrustStore({ directory: home }); const sessionManager = new SessionManager({ root: home ? `${home}/sessions` : undefined, }); return new RuntimeHostExecutor({ createHost: async (cwd, mode) => { const loaded = await loadSettings({ cwd, directory: home }); - // Until trust provenance moves into RuntimeHost, only user-level settings - // may widen the desktop sidecar's permissions or sandbox profile. - const settings = loaded.layers.user ?? {}; + const trustStatus = await trustStore.statusFor(cwd); + const { settings } = gateUntrustedSettings(loaded, trustStatus); const credentials = await resolveCredentials({ store: new CredentialsStore({ directory: home, diff --git a/apps/server/src/run.test.ts b/apps/server/src/run.test.ts new file mode 100644 index 0000000..e51c1a5 --- /dev/null +++ b/apps/server/src/run.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { writeSettings } from '@deepcode/core/config'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { runAppServer } from './run.js'; + +let root: string | undefined; + +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +describe('runAppServer', () => { + it('wires trust-aware configuration diagnostics through stdio', async () => { + root = await mkdtemp(join(tmpdir(), 'dc-app-server-')); + const cwd = join(root, 'workspace'); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + permissions: { allow: ['Bash'] }, + }); + const input = new PassThrough(); + const output = new PassThrough(); + let raw = ''; + output.setEncoding('utf8'); + output.on('data', (chunk: string) => { + raw += chunk; + }); + + input.end( + `${JSON.stringify({ id: 1, method: 'initialize', params: {} })}\n` + + `${JSON.stringify({ id: 2, method: 'config/diagnostics', params: { cwd } })}\n`, + ); + await runAppServer({ + input, + output, + home: root, + executor: { execute: async () => ({}) }, + }); + + const responses = raw + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { id: number; result: Record }); + expect(responses[0]?.result).toEqual( + expect.objectContaining({ + capabilities: expect.objectContaining({ configDiagnostics: true }), + }), + ); + expect(responses[1]).toEqual( + expect.objectContaining({ + id: 2, + result: expect.objectContaining({ + cwd, + trustStatus: 'untrusted', + gated: ['permissions'], + }), + }), + ); + }); +}); diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts index eb59030..dbaa697 100644 --- a/apps/server/src/run.ts +++ b/apps/server/src/run.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import type { Readable, Writable } from 'node:stream'; import type { ProtocolNotification } from '@deepcode/protocol'; +import { diagnoseSettings, DirectoryTrustStore } from '@deepcode/core/config'; import { createDefaultTurnExecutor } from './default-runtime.js'; import { AppServer, type TurnExecutor } from './server.js'; @@ -18,6 +19,7 @@ export interface RunAppServerOptions { export async function runAppServer(options: RunAppServerOptions): Promise { const writer = new ProtocolLineWriter(options.output); + const trustStore = new DirectoryTrustStore({ directory: options.home }); const server = new AppServer({ executor: options.executor ?? @@ -28,6 +30,12 @@ export async function runAppServer(options: RunAppServerOptions): Promise join(options.home, 'threads-v1'), join(options.home, 'sessions'), ), + configDiagnostics: async (cwd) => + diagnoseSettings({ + cwd, + directory: options.home, + trustStatus: await trustStore.statusFor(cwd), + }), onEvent: (event) => { const notification: ProtocolNotification = { method: 'event', params: event }; void writer.enqueue(notification); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b441358..efb2fd1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -33,6 +33,54 @@ function deterministicOptions() { } describe('AppServer', () => { + it('advertises and returns value-free configuration diagnostics when provided', async () => { + const server = new AppServer({ + executor: { execute: async () => ({}) }, + configDiagnostics: async (cwd) => ({ + cwd, + trustStatus: 'untrusted', + layers: [], + provenance: { '/model': { layer: 'user', path: '/home/.deepcode/settings.json' } }, + gated: [], + issues: [], + }), + }); + + await expect(server.handle(request(1, 'initialize'))).resolves.toEqual({ + id: 1, + result: expect.objectContaining({ + capabilities: expect.objectContaining({ configDiagnostics: true }), + }), + }); + await expect( + server.handle(request(2, 'config/diagnostics', { cwd: '/workspace' })), + ).resolves.toEqual({ + id: 2, + result: expect.objectContaining({ + cwd: '/workspace', + provenance: expect.objectContaining({ + '/model': expect.objectContaining({ layer: 'user' }), + }), + }), + }); + }); + + it('does not advertise unavailable configuration diagnostics', async () => { + const server = new AppServer({ executor: { execute: async () => ({}) } }); + const initialized = await server.handle(request(1, 'initialize')); + expect(initialized.result).toEqual( + expect.objectContaining({ + capabilities: expect.objectContaining({ configDiagnostics: false }), + }), + ); + await expect( + server.handle(request(2, 'config/diagnostics', { cwd: '/workspace' })), + ).resolves.toEqual({ + id: 2, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + }); + it('routes initialization and thread lifecycle requests', async () => { const server = new AppServer({ executor: { execute: async () => ({}) }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f86c14a..3d577a0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -3,6 +3,7 @@ import { ProtocolInvariantError, ProtocolRuntime, type CompletedItemType, + type ConfigDiagnosticsResult, type ProtocolEvent, type ProtocolRequest, type ProtocolResponse, @@ -53,6 +54,7 @@ export interface AppServerOptions { now?: () => string; newId?: (prefix: 'thread' | 'turn' | 'item') => string; onEvent?: (event: ProtocolEvent) => void; + configDiagnostics?: (cwd: string) => Promise; } interface ActiveTurn { @@ -90,6 +92,7 @@ export class AppServer { now: options.now, newId: options.newId, onEvent: options.onEvent, + configDiagnostics: options.configDiagnostics !== undefined, }); } @@ -133,6 +136,11 @@ export class AppServer { switch (request.method) { case 'initialize': return this.lifecycle.initialize(); + case 'config/diagnostics': + if (!this.options.configDiagnostics) { + throw new RequestValidationError('Configuration diagnostics are not available'); + } + return this.options.configDiagnostics(requiredString(request.params, 'cwd')); case 'thread/start': return this.lifecycle.startThread(requiredString(request.params, 'cwd')); case 'thread/read': diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index 114e550..19d9c83 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -38,6 +38,7 @@ class FakeClient { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true, }, }; } diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 2fc2c60..4619bdc 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -314,7 +314,9 @@ model tool call ### PR 8 — 配置、扩展、多代理与 review 收尾 -- 现有 JSON config 增加 provenance/diagnostics;统一 `AGENTS.md`、`DEEPCODE.md`、MCP、skills、plugins、hooks。 +- JSON config 已增加逐叶 JSON Pointer provenance 与不含值的 diagnostics;CLI/desktop/editor + app-server 现在共享 core trust store,未信任项目不能通过 permissions/sandbox/env 等字段扩大权限。 +- 下一步统一 `AGENTS.md`、`DEEPCODE.md`、MCP、skills、plugins、hooks。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 - diff review、可定位反馈、trace id、结构化日志与脱敏导出。 - 删除完成迁移的旧 IPC/facade;更新所有用户文档。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index c4763df..8d7e17b 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -33,16 +33,17 @@ by expecting partial deltas to replay. ## Methods -| Method | Required parameters | Result | -| -------------------- | ------------------------------- | ------------------------------------------------- | -| `initialize` | none | version and capabilities | -| `thread/start` | `cwd` | new thread snapshot | -| `thread/read` | `threadId` | thread snapshot or null | -| `thread/resume` | `threadId` | resumable snapshot | -| `turn/start` | `threadId`, object `input` | in-progress turn snapshot | -| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race | -| `approval/respond` | thread, turn, request, decision | whether the pending request accepted the response | -| `user-input/respond` | thread, turn, request, answer | whether the pending request accepted the response | +| Method | Required parameters | Result | +| -------------------- | ------------------------------- | -------------------------------------------------- | +| `initialize` | none | version and capabilities | +| `thread/start` | `cwd` | new thread snapshot | +| `thread/read` | `threadId` | thread snapshot or null | +| `thread/resume` | `threadId` | resumable snapshot | +| `turn/start` | `threadId`, object `input` | in-progress turn snapshot | +| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race | +| `approval/respond` | thread, turn, request, decision | whether the pending request accepted the response | +| `user-input/respond` | thread, turn, request, answer | whether the pending request accepted the response | +| `config/diagnostics` | workspace cwd | value-free layers, provenance, trust gates, issues | `turn/start` returns before model work finishes. The server emits transient deltas while the turn runs, then persists new provider-history messages as completed items before emitting exactly one @@ -69,11 +70,16 @@ thread, turn, request id, and request kind. Interrupt and shutdown resolve pendi waiting for the executor, so an abandoned UI cannot strand the server. The desktop sidecar loads credentials from its private data directory in file-only mode because -Tauri onboarding writes that file and never returns its secret fields to the webview. It consumes -only user-level permissions/sandbox settings until project trust provenance moves into -`RuntimeHost`; project files cannot widen the desktop runtime boundary in the meantime. The -canonical SessionManager remains attached for pre/post file snapshots, with message appends -disabled because `CanonicalThreadStore` is the single message materializer. +Tauri onboarding writes that file and never returns its secret fields to the webview. Every host +uses the core directory-trust store: untrusted project/local layers cannot replace permissions, +auto mode, sandbox, environment, hooks, MCP, helpers, or status-line configuration. The canonical +SessionManager remains attached for pre/post file snapshots, with message appends disabled because +`CanonicalThreadStore` is the single message materializer. + +Configuration diagnostics expose only JSON-pointer key paths and source filenames. Values are +never serialized, so provider credentials, hook headers, MCP environment values, and helpers stay +inside the app-server. The report distinguishes discovered layers from effective trust gating and +includes shallow schema issues. ## Entrypoints @@ -97,6 +103,5 @@ closes stdin first so the server can interrupt and persist active turns before a ## Deferred from this slice -- config provenance; - thread listing, archive, fork, and search; - multi-client subscriptions or active-turn attachment; diff --git a/docs/design/runtime-protocol-v1.md b/docs/design/runtime-protocol-v1.md index 3d9ca15..4fdb32d 100644 --- a/docs/design/runtime-protocol-v1.md +++ b/docs/design/runtime-protocol-v1.md @@ -53,7 +53,8 @@ the referenced thread immediately after receiving an event. Clients call `initialize` before other methods and inspect both `protocolVersion` and advertised capabilities. Version 1 advertises thread resume, turn interruption, completed-item persistence, -transient deltas, structured tool events, and interactive requests. +transient deltas, structured tool events, interactive requests, and the optional availability of +value-free configuration diagnostics. Unknown methods and non-object request parameters are rejected by the line-oriented JSON codec. Future incompatible lifecycle changes require a new protocol version; optional behavior should be diff --git a/docs/security-model.md b/docs/security-model.md index ed4d147..3c51d19 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -1,6 +1,6 @@ # DeepCode Security Model -> Last updated: 2026-06-02 (M3.5-ext: Linux selective per-domain network allowlist landed) +> Last updated: 2026-08-01 (shared app-server trust and config provenance) This document is the **single source of truth** for what DeepCode protects against, what it doesn't, and how each layer composes. If you're reviewing a @@ -30,7 +30,12 @@ First time DeepCode opens a folder, you're asked **"Do you trust this directory?"**. If you say no, the agent runs in a heavily restricted mode: no exec, no writes outside the project, no `bypassPermissions` mode allowed. -Decisions persist in `~/.deepcode/trust.json`. +Decisions persist in `~/.deepcode/trusted-dirs.json`. The CLI, desktop sidecar, VS Code, and LSP +app-server entrypoints all consult the same core trust store. Until a directory is trusted, +project/local settings cannot replace provider endpoints, model/cost policy, permissions, auto +mode, sandbox, environment, hooks, MCP, credential helpers, executable voice paths, worktree/update +policy, or status-line commands. `config/diagnostics` reports which fields were gated without +returning their values. ### Layer 1 — Mode + Permissions diff --git a/packages/core/src/config/diagnostics.test.ts b/packages/core/src/config/diagnostics.test.ts new file mode 100644 index 0000000..10b53a7 --- /dev/null +++ b/packages/core/src/config/diagnostics.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { diagnoseSettings } from './diagnostics.js'; +import { writeSettings } from './loader.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +describe('diagnoseSettings', () => { + it('reports sources, validation, and trust gating without returning values', async () => { + const home = await mkdtemp(join(tmpdir(), 'dc-diagnostics-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-diagnostics-cwd-')); + roots.push(home, cwd); + await writeSettings(join(home, '.deepcode', 'settings.json'), { model: 'deepseek-chat' }); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + permissions: { allow: ['Bash'] }, + effortLevel: 'turbo' as 'max', + env: { SECRET_VALUE: 'never-export-this' }, + }); + + const report = await diagnoseSettings({ cwd, home, trustStatus: 'untrusted' }); + expect(report.trustStatus).toBe('untrusted'); + expect(report.gated).toEqual(['effortLevel', 'permissions', 'env']); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'schema_validation', severity: 'error' }), + expect.objectContaining({ + code: 'untrusted_setting_gated', + pointer: '/permissions', + source: expect.objectContaining({ layer: 'project' }), + }), + ]), + ); + expect(report.layers.find((layer) => layer.layer === 'project')).toEqual( + expect.objectContaining({ present: true, trusted: false }), + ); + expect(JSON.stringify(report)).not.toContain('never-export-this'); + }); + + it('marks project layers trusted and emits no gate warnings after trust', async () => { + const home = await mkdtemp(join(tmpdir(), 'dc-diagnostics-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-diagnostics-cwd-')); + roots.push(home, cwd); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + permissions: { allow: ['Read'] }, + }); + + const report = await diagnoseSettings({ cwd, home, trustStatus: 'trusted' }); + expect(report.gated).toEqual([]); + expect(report.issues).toEqual([]); + expect(report.layers.find((layer) => layer.layer === 'project')?.trusted).toBe(true); + }); +}); diff --git a/packages/core/src/config/diagnostics.ts b/packages/core/src/config/diagnostics.ts new file mode 100644 index 0000000..3dd3c29 --- /dev/null +++ b/packages/core/src/config/diagnostics.ts @@ -0,0 +1,89 @@ +import { resolve } from 'node:path'; + +import { loadSettings, type LoadSettingsOpts, type SettingsLayerName } from './loader.js'; +import { validateSettingsShallow } from './validation.js'; +import { gateUntrustedSettings, type TrustStatus } from './trust-gate.js'; + +export type SettingsDiagnosticSeverity = 'info' | 'warning' | 'error'; + +export interface SettingsDiagnosticIssue { + severity: SettingsDiagnosticSeverity; + code: 'schema_validation' | 'untrusted_setting_gated'; + message: string; + pointer?: string; + source?: { layer: SettingsLayerName; path: string }; +} + +export interface SettingsLayerDiagnostic { + layer: SettingsLayerName; + path: string; + present: boolean; + trusted: boolean; +} + +export interface SettingsDiagnostics { + cwd: string; + trustStatus: TrustStatus; + layers: SettingsLayerDiagnostic[]; + /** Winning sources before trust gating. Values are deliberately omitted. */ + provenance: Record; + gated: string[]; + issues: SettingsDiagnosticIssue[]; +} + +export interface DiagnoseSettingsOptions extends LoadSettingsOpts { + trustStatus: TrustStatus; +} + +/** Build a value-free diagnostic report suitable for protocol/UI/log export. */ +export async function diagnoseSettings( + options: DiagnoseSettingsOptions, +): Promise { + const loaded = await loadSettings(options); + const gate = gateUntrustedSettings(loaded, options.trustStatus); + const issues: SettingsDiagnosticIssue[] = validateSettingsShallow( + loaded.merged as Record, + ).map((message) => ({ severity: 'error', code: 'schema_validation', message })); + + for (const field of gate.gated) { + const pointer = `/${field}`; + issues.push({ + severity: 'warning', + code: 'untrusted_setting_gated', + message: `Ignored project setting ${pointer} until this directory is trusted`, + pointer, + source: sourceForPrefix(loaded.provenance, pointer), + }); + } + + const layerPaths: Record = { + user: loaded.sources.userPath, + project: loaded.sources.projectPath, + local: loaded.sources.localPath, + override: loaded.sources.overridePath, + }; + return { + cwd: resolve(options.cwd), + trustStatus: options.trustStatus, + layers: (['user', 'project', 'local', 'override'] as const) + .filter((layer) => layerPaths[layer] !== undefined) + .map((layer) => ({ + layer, + path: layerPaths[layer]!, + present: loaded.layers[layer] !== undefined, + trusted: layer === 'user' || layer === 'override' || options.trustStatus === 'trusted', + })), + provenance: loaded.provenance, + gated: [...gate.gated], + issues, + }; +} + +function sourceForPrefix( + provenance: SettingsDiagnostics['provenance'], + pointer: string, +): SettingsDiagnostics['provenance'][string] | undefined { + return Object.entries(provenance).find( + ([candidate]) => candidate === pointer || candidate.startsWith(`${pointer}/`), + )?.[1]; +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 32bf474..f534b96 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -26,6 +26,8 @@ export { appendAllowMatcher, type LoadedSettings, type LoadSettingsOpts, + type SettingsLayerName, + type SettingsValueSource, } from './loader.js'; export { @@ -36,6 +38,23 @@ export { type GateResult, } from './trust-gate.js'; +export { + DirectoryTrustStore, + type DirectoryTrustState, + type DirectoryTrustStoreOptions, +} from './trust-store.js'; + +export { + diagnoseSettings, + type DiagnoseSettingsOptions, + type SettingsDiagnostics, + type SettingsDiagnosticIssue, + type SettingsDiagnosticSeverity, + type SettingsLayerDiagnostic, +} from './diagnostics.js'; + +export { validateSettingsShallow } from './validation.js'; + export { evaluatePermission, matchRule, diff --git a/packages/core/src/config/loader.test.ts b/packages/core/src/config/loader.test.ts index 00ea1ca..84d34d6 100644 --- a/packages/core/src/config/loader.test.ts +++ b/packages/core/src/config/loader.test.ts @@ -84,6 +84,27 @@ describe('settings loader', () => { expect(s.merged.model).toBe('override'); }); + it('tracks the winning source of each leaf without exposing values', async () => { + const userPath = join(home, '.deepcode', 'settings.json'); + const projectPath = join(cwd, '.deepcode', 'settings.json'); + await writeSettings(userPath, { + model: 'deepseek-chat', + permissions: { allow: ['Read'] }, + }); + await writeSettings(projectPath, { + model: 'deepseek-reasoner', + permissions: { deny: ['Bash'] }, + }); + + const loaded = await loadSettings({ cwd, home }); + expect(loaded.provenance).toEqual({ + '/model': { layer: 'project', path: projectPath }, + '/permissions/allow': { layer: 'user', path: userPath }, + '/permissions/deny': { layer: 'project', path: projectPath }, + }); + expect(JSON.stringify(loaded.provenance)).not.toContain('deepseek-reasoner'); + }); + it('deepMerge merges nested objects, arrays replace', () => { const merged = deepMerge>( { a: { x: 1, y: 2 }, list: [1, 2] }, @@ -112,6 +133,15 @@ describe('settings loader', () => { await expect(loadSettings({ cwd, home })).rejects.toThrow(/parse/i); }); + it('rejects non-object settings and prototype-pollution keys', async () => { + const path = join(home, '.deepcode', 'settings.json'); + await fs.mkdir(join(home, '.deepcode'), { recursive: true }); + await fs.writeFile(path, '[]'); + await expect(loadSettings({ cwd, home })).rejects.toThrow(/JSON object/); + await fs.writeFile(path, '{"permissions":{"__proto__":{"allow":["Bash"]}}}'); + await expect(loadSettings({ cwd, home })).rejects.toThrow(/Unsafe settings key/); + }); + it('merges permissions objects (not arrays)', async () => { await writeSettings(join(home, '.deepcode', 'settings.json'), { permissions: { allow: ['Read'] }, diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 0d19416..4e4ddc5 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -11,6 +11,13 @@ import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import type { DeepCodeSettings } from './types.js'; +export type SettingsLayerName = 'user' | 'project' | 'local' | 'override'; + +export interface SettingsValueSource { + layer: SettingsLayerName; + path: string; +} + export interface LoadedSettings { merged: DeepCodeSettings; layers: { @@ -26,6 +33,8 @@ export interface LoadedSettings { localPath: string; overridePath?: string; }; + /** Winning source for each leaf setting, keyed by RFC 6901 JSON pointer. */ + provenance: Record; } export interface LoadSettingsOpts { @@ -50,7 +59,7 @@ export function settingsPaths(opts: LoadSettingsOpts): LoadedSettings['sources'] async function readJson(path: string): Promise { try { const raw = await fs.readFile(path, 'utf8'); - return JSON.parse(raw) as DeepCodeSettings; + return parseSettings(raw, path); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') return undefined; @@ -63,7 +72,7 @@ async function readJson(path: string): Promise { async function readJsonRequired(path: string): Promise { try { const raw = await fs.readFile(path, 'utf8'); - return JSON.parse(raw) as DeepCodeSettings; + return parseSettings(raw, path); } catch (err) { throw new Error(`--settings: cannot load ${path}: ${(err as Error).message}`); } @@ -88,18 +97,93 @@ export async function loadSettings(opts: LoadSettingsOpts): Promise, ) as DeepCodeSettings; } + const resolvedSources = { ...sources, overridePath: opts.settingsPath }; return { merged, layers: { user, project, local, override }, - sources: { ...sources, overridePath: opts.settingsPath }, + sources: resolvedSources, + provenance: settingsProvenance( + { user, project, local, override }, + { + user: resolvedSources.userPath, + project: resolvedSources.projectPath, + local: resolvedSources.localPath, + override: resolvedSources.overridePath, + }, + ), }; } +function parseSettings(raw: string, path: string): DeepCodeSettings { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) throw new Error(`Settings in ${path} must be a JSON object`); + assertSafeValue(parsed, '', path); + return parsed as DeepCodeSettings; +} + +const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +function assertSafeValue(value: unknown, pointer: string, path: string): void { + if (Array.isArray(value)) { + value.forEach((entry, index) => assertSafeValue(entry, `${pointer}/${index}`, path)); + return; + } + if (!isRecord(value)) return; + for (const [key, entry] of Object.entries(value)) { + if (UNSAFE_KEYS.has(key)) { + throw new Error(`Unsafe settings key ${pointer}/${escapePointer(key)} in ${path}`); + } + assertSafeValue(entry, `${pointer}/${escapePointer(key)}`, path); + } +} + +function settingsProvenance( + layers: LoadedSettings['layers'], + paths: Record, +): Record { + const provenance: Record = {}; + for (const layer of ['user', 'project', 'local', 'override'] as const) { + const settings = layers[layer]; + const path = paths[layer]; + if (settings && path) + collectProvenance(settings as Record, '', layer, path, provenance); + } + return provenance; +} + +function collectProvenance( + value: Record, + pointer: string, + layer: SettingsLayerName, + path: string, + output: Record, +): void { + for (const [key, entry] of Object.entries(value)) { + if (entry === undefined) continue; + const child = `${pointer}/${escapePointer(key)}`; + if (isRecord(entry) && Object.keys(entry).length > 0) { + collectProvenance(entry, child, layer, path, output); + } else { + output[child] = { layer, path }; + } + } +} + +function escapePointer(value: string): string { + return value.replaceAll('~', '~0').replaceAll('/', '~1'); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + /** * Deep-merge: objects merged recursively; arrays/scalars in later overwrite earlier. * (Arrays are NOT concatenated — settings semantics are "later replaces earlier".) */ export function deepMerge>(a: T, b: T): T { + assertSafeValue(a, '', 'settings merge input'); + assertSafeValue(b, '', 'settings merge input'); const out: Record = { ...a }; for (const key of Object.keys(b)) { const av = (a as Record)[key]; diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index d648032..f944aab 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -35,65 +35,4 @@ export async function settingsSchemaObject(): Promise> { return JSON.parse(raw) as Record; } -/** - * Lightweight validation: checks required-ish fields + enum membership for - * the fields users most often misspell. Returns an array of error strings; - * empty array = valid (or at least no detected issues). - * - * This is NOT a full draft-07 validator; for that, route through an - * external library. The goal here is fast feedback in `/doctor` without - * dragging ajv into the runtime. - */ -export function validateSettingsShallow(settings: Record): string[] { - const errors: string[] = []; - - const modelEnum = ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro']; - if (settings['model'] !== undefined && !modelEnum.includes(settings['model'] as string)) { - errors.push(`settings.model "${settings['model']}" not in ${modelEnum.join(' | ')}`); - } - - const effortEnum = ['low', 'medium', 'high', 'xhigh', 'max']; - if ( - settings['effortLevel'] !== undefined && - !effortEnum.includes(settings['effortLevel'] as string) - ) { - errors.push( - `settings.effortLevel "${settings['effortLevel']}" not in ${effortEnum.join(' | ')}`, - ); - } - - const modeEnum = ['default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions']; - const perm = settings['permissions'] as { defaultMode?: string } | undefined; - if (perm?.defaultMode && !modeEnum.includes(perm.defaultMode)) { - errors.push(`permissions.defaultMode "${perm.defaultMode}" not in ${modeEnum.join(' | ')}`); - } - - const hooks = settings['hooks'] as Record | undefined; - if (hooks) { - const validEvents = [ - 'PreToolUse', - 'PostToolUse', - 'Stop', - 'SubagentStop', - 'PreCompact', - 'PostCompact', - 'SessionStart', - 'SessionEnd', - 'UserPromptSubmit', - 'Notification', - ]; - for (const k of Object.keys(hooks)) { - if (!validEvents.includes(k)) { - errors.push(`hooks.${k} is not a known event (valid: ${validEvents.join(', ')})`); - } - } - } - - const voiceProviderEnum = ['whisper.cpp', 'stub']; - const voice = settings['voice'] as { provider?: string } | undefined; - if (voice?.provider && !voiceProviderEnum.includes(voice.provider)) { - errors.push(`voice.provider "${voice.provider}" not in ${voiceProviderEnum.join(' | ')}`); - } - - return errors; -} +export { validateSettingsShallow } from './validation.js'; diff --git a/packages/core/src/config/trust-gate.test.ts b/packages/core/src/config/trust-gate.test.ts index 414283b..ac3fee7 100644 --- a/packages/core/src/config/trust-gate.test.ts +++ b/packages/core/src/config/trust-gate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { LoadedSettings } from './loader.js'; -import { gateUntrustedSettings, TRUST_GATED_FIELDS } from './trust-gate.js'; +import { gateUntrustedSettings } from './trust-gate.js'; function loaded(layers: LoadedSettings['layers']): LoadedSettings { // Minimal merge mirroring loader semantics (project/local override user). @@ -9,6 +9,7 @@ function loaded(layers: LoadedSettings['layers']): LoadedSettings { merged, layers, sources: { userPath: '/u', projectPath: '/p', localPath: '/l' }, + provenance: {}, }; } @@ -35,7 +36,7 @@ describe('gateUntrustedSettings', () => { }, }); const r = gateUntrustedSettings(l, 'untrusted'); - expect(r.gated.sort()).toEqual([...TRUST_GATED_FIELDS].sort()); + expect(r.gated.sort()).toEqual(['apiKeyHelper', 'hooks', 'mcpServers', 'statusLine'].sort()); expect(r.settings.hooks).toBeUndefined(); expect(r.settings.mcpServers).toBeUndefined(); expect(r.settings.apiKeyHelper).toBeUndefined(); @@ -55,6 +56,42 @@ describe('gateUntrustedSettings', () => { expect(r.gated).toContain('apiKeyHelper'); }); + it('untrusted: cannot widen permissions, auto mode, environment, or sandbox', () => { + const l = loaded({ + user: { permissions: { allow: ['Read'] }, sandbox: { enabled: true } }, + project: { + permissions: { allow: ['Bash'] }, + autoMode: { allow: ['Bash'] }, + env: { NODE_OPTIONS: '--require ./payload.cjs' }, + sandbox: { enabled: false }, + }, + }); + const result = gateUntrustedSettings(l, 'untrusted'); + expect(result.gated).toEqual(['permissions', 'autoMode', 'sandbox', 'env']); + expect(result.settings.permissions).toEqual({ allow: ['Read'] }); + expect(result.settings.sandbox).toEqual({ enabled: true }); + expect(result.settings.autoMode).toBeUndefined(); + expect(result.settings.env).toBeUndefined(); + }); + + it('untrusted: cannot redirect credentials or raise model cost policy', () => { + const l = loaded({ + user: { baseURL: 'https://api.deepseek.com/v1', effortLevel: 'low' }, + project: { + baseURL: 'https://attacker.invalid/v1', + model: 'deepseek-reasoner', + effortLevel: 'max', + effortBudgets: { max: { maxTurnYuan: 999 } }, + }, + }); + const result = gateUntrustedSettings(l, 'untrusted'); + expect(result.gated).toEqual(['model', 'baseURL', 'effortLevel', 'effortBudgets']); + expect(result.settings.baseURL).toBe('https://api.deepseek.com/v1'); + expect(result.settings.model).toBeUndefined(); + expect(result.settings.effortLevel).toBe('low'); + expect(result.settings.effortBudgets).toBeUndefined(); + }); + it('untrusted: --settings override is trusted — its exec fields survive', () => { const l = loaded({ user: { model: 'deepseek-chat' }, @@ -78,13 +115,13 @@ describe('gateUntrustedSettings', () => { expect(r.settings.mcpServers).toBeUndefined(); }); - it('untrusted: nothing to gate when project/local set no exec fields', () => { - const l = loaded({ user: { hooks: {} }, project: { model: 'deepseek-reasoner' } }); + it('untrusted: nothing to gate when project/local set no authority fields', () => { + const l = loaded({ user: { hooks: {} }, project: { language: 'zh-CN' } }); const r = gateUntrustedSettings(l, 'untrusted'); expect(r.gated).toEqual([]); - // user-layer hooks preserved; project's model still applies + // user-layer hooks and presentation-only project settings survive. expect(r.settings.hooks).toEqual({}); - expect(r.settings.model).toBe('deepseek-reasoner'); + expect(r.settings.language).toBe('zh-CN'); }); it('plan-only gates exec fields exactly like untrusted', () => { diff --git a/packages/core/src/config/trust-gate.ts b/packages/core/src/config/trust-gate.ts index c6c3a20..c28a014 100644 --- a/packages/core/src/config/trust-gate.ts +++ b/packages/core/src/config/trust-gate.ts @@ -9,8 +9,32 @@ import type { DeepCodeSettings } from './types.js'; export type TrustStatus = 'trusted' | 'plan-only' | 'untrusted'; -/** Project/local settings fields that can execute arbitrary shell/processes. */ -export const TRUST_GATED_FIELDS = ['hooks', 'mcpServers', 'apiKeyHelper', 'statusLine'] as const; +/** Project/local settings fields that can execute code or widen runtime authority. */ +export const TRUST_GATED_FIELDS = [ + 'model', + 'baseURL', + 'forceLoginMethod', + 'effortLevel', + 'effortBudgets', + 'effortOverrides', + 'alwaysThinkingEnabled', + 'permissions', + 'autoMode', + 'sandbox', + 'env', + 'hooks', + 'allowedHttpHookUrls', + 'httpHookAllowedEnvVars', + 'mcpServers', + 'enableAllProjectMcpServers', + 'enabledMcpjsonServers', + 'apiKeyHelper', + 'statusLine', + 'voice', + 'worktree', + 'update', + 'plugins', +] as const; export type TrustGatedField = (typeof TRUST_GATED_FIELDS)[number]; export interface GateResult { @@ -30,7 +54,7 @@ function copyOrDelete(dst: DeepCodeSettings, src: DeepCodeSettings, key: TrustGa * Return the effective settings for a directory at the given trust `status`. * * - `trusted` → merged settings unchanged. - * - `untrusted` / `plan-only` → each exec-bearing field is reset to the + * - `untrusted` / `plan-only` → each authority-bearing field is reset to the * user-global layer's value (or removed if the user layer doesn't set it), * so a project's `.deepcode/settings.json` can't run code until the user * trusts the directory. `gated` lists which fields were actually stripped diff --git a/packages/core/src/config/trust-store.test.ts b/packages/core/src/config/trust-store.test.ts new file mode 100644 index 0000000..fa63ff2 --- /dev/null +++ b/packages/core/src/config/trust-store.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { DirectoryTrustStore } from './trust-store.js'; + +let directory: string | undefined; + +afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe('DirectoryTrustStore', () => { + it('supports the app-server data-directory layout', async () => { + directory = await mkdtemp(join(tmpdir(), 'dc-trust-directory-')); + const store = new DirectoryTrustStore({ directory }); + await store.trust('/workspace', 'full'); + expect(store.filePath()).toBe(join(directory, 'trusted-dirs.json')); + await expect(store.statusFor('/workspace')).resolves.toBe('trusted'); + }); + + it('rejects malformed trust state instead of silently trusting it', async () => { + directory = await mkdtemp(join(tmpdir(), 'dc-trust-directory-')); + const store = new DirectoryTrustStore({ directory }); + await writeFile(store.filePath(), '{"dirs":{"/workspace":{"mode":"full"}}}'); + await expect(store.statusFor('/workspace')).rejects.toThrow(/Invalid trust entry/); + }); + + it('rejects prototype-pollution keys in trust state', async () => { + directory = await mkdtemp(join(tmpdir(), 'dc-trust-directory-')); + const store = new DirectoryTrustStore({ directory }); + await writeFile( + store.filePath(), + '{"dirs":{"__proto__":{"trustedAt":"2026-08-01T00:00:00Z","mode":"full"}}}', + ); + await expect(store.load()).rejects.toThrow(/Invalid trust entry key/); + }); +}); diff --git a/packages/core/src/config/trust-store.ts b/packages/core/src/config/trust-store.ts new file mode 100644 index 0000000..d883023 --- /dev/null +++ b/packages/core/src/config/trust-store.ts @@ -0,0 +1,91 @@ +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +import type { TrustStatus } from './trust-gate.js'; + +export interface DirectoryTrustState { + dirs: Record; +} + +export interface DirectoryTrustStoreOptions { + /** User home. Ignored when `directory` is supplied. */ + home?: string; + /** Direct DeepCode data directory, for app-server sidecars and tests. */ + directory?: string; +} + +/** Canonical directory-trust store shared by every runtime host. */ +export class DirectoryTrustStore { + private readonly directory: string; + + constructor(options: DirectoryTrustStoreOptions = {}) { + this.directory = options.directory ?? join(options.home ?? homedir(), '.deepcode'); + } + + filePath(): string { + return join(this.directory, 'trusted-dirs.json'); + } + + async load(): Promise { + try { + const parsed = JSON.parse(await fs.readFile(this.filePath(), 'utf8')) as unknown; + return validateState(parsed); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { dirs: {} }; + throw new Error(`Failed to load directory trust: ${(error as Error).message}`); + } + } + + async save(state: DirectoryTrustState): Promise { + const path = this.filePath(); + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(path, `${JSON.stringify(validateState(state), null, 2)}\n`, 'utf8'); + } + + async statusFor(cwd: string): Promise { + const entry = (await this.load()).dirs[resolve(cwd)]; + if (!entry) return 'untrusted'; + return entry.mode === 'plan-only' ? 'plan-only' : 'trusted'; + } + + async trust(cwd: string, mode: 'full' | 'plan-only'): Promise { + const state = await this.load(); + state.dirs[resolve(cwd)] = { trustedAt: new Date().toISOString(), mode }; + await this.save(state); + } + + async untrust(cwd: string): Promise { + const state = await this.load(); + delete state.dirs[resolve(cwd)]; + await this.save(state); + } +} + +function validateState(value: unknown): DirectoryTrustState { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('trusted-dirs.json must contain an object'); + } + const dirs = (value as { dirs?: unknown }).dirs; + if (!dirs || typeof dirs !== 'object' || Array.isArray(dirs)) { + throw new Error('trusted-dirs.json must contain a dirs object'); + } + const validated: DirectoryTrustState = { dirs: {} }; + for (const [path, raw] of Object.entries(dirs)) { + if (path === '__proto__' || path === 'prototype' || path === 'constructor') { + throw new Error(`Invalid trust entry key ${path}`); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`Invalid trust entry for ${path}`); + } + const entry = raw as { trustedAt?: unknown; mode?: unknown }; + if ( + typeof entry.trustedAt !== 'string' || + (entry.mode !== 'full' && entry.mode !== 'plan-only') + ) { + throw new Error(`Invalid trust entry for ${path}`); + } + validated.dirs[path] = { trustedAt: entry.trustedAt, mode: entry.mode }; + } + return validated; +} diff --git a/packages/core/src/config/validation.ts b/packages/core/src/config/validation.ts new file mode 100644 index 0000000..c93b558 --- /dev/null +++ b/packages/core/src/config/validation.ts @@ -0,0 +1,57 @@ +/** + * Lightweight validation for diagnostics. Kept independent of the static + * schema reader so CJS sidecar bundles do not need `import.meta.url`. + */ +export function validateSettingsShallow(settings: Record): string[] { + const errors: string[] = []; + + const modelEnum = ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro']; + if (settings['model'] !== undefined && !modelEnum.includes(settings['model'] as string)) { + errors.push(`settings.model "${settings['model']}" not in ${modelEnum.join(' | ')}`); + } + + const effortEnum = ['low', 'medium', 'high', 'xhigh', 'max']; + if ( + settings['effortLevel'] !== undefined && + !effortEnum.includes(settings['effortLevel'] as string) + ) { + errors.push( + `settings.effortLevel "${settings['effortLevel']}" not in ${effortEnum.join(' | ')}`, + ); + } + + const modeEnum = ['default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions']; + const perm = settings['permissions'] as { defaultMode?: string } | undefined; + if (perm?.defaultMode && !modeEnum.includes(perm.defaultMode)) { + errors.push(`permissions.defaultMode "${perm.defaultMode}" not in ${modeEnum.join(' | ')}`); + } + + const hooks = settings['hooks'] as Record | undefined; + if (hooks) { + const validEvents = [ + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'Notification', + ]; + for (const key of Object.keys(hooks)) { + if (!validEvents.includes(key)) { + errors.push(`hooks.${key} is not a known event (valid: ${validEvents.join(', ')})`); + } + } + } + + const voiceProviderEnum = ['whisper.cpp', 'stub']; + const voice = settings['voice'] as { provider?: string } | undefined; + if (voice?.provider && !voiceProviderEnum.includes(voice.provider)) { + errors.push(`voice.provider "${voice.provider}" not in ${voiceProviderEnum.join(' | ')}`); + } + + return errors; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d6aa5d8..3abc386 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -318,6 +318,15 @@ export { validateSettingsShallow, } from './config/schema.js'; +export { + DirectoryTrustStore, + diagnoseSettings, + type DirectoryTrustState, + type DirectoryTrustStoreOptions, + type DiagnoseSettingsOptions, + type SettingsDiagnostics, +} from './config/index.js'; + // Vision (v1.1 — image input abstraction) export { StubVisionProvider, diff --git a/packages/protocol/src/client.test.ts b/packages/protocol/src/client.test.ts index 74993e3..06ecda6 100644 --- a/packages/protocol/src/client.test.ts +++ b/packages/protocol/src/client.test.ts @@ -34,6 +34,7 @@ class FakeConnection implements ProtocolClientConnection { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true, }, } : { ok: true }, diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts index 601b6a6..b4cbcd9 100644 --- a/packages/protocol/src/codec.test.ts +++ b/packages/protocol/src/codec.test.ts @@ -33,7 +33,7 @@ describe('protocol codec', () => { ); }); - it.each(['approval/respond', 'user-input/respond'] as const)( + it.each(['approval/respond', 'user-input/respond', 'config/diagnostics'] as const)( 'accepts the interactive response method %s', (method) => { expect(decodeProtocolRequest(JSON.stringify({ id: 2, method, params: {} }))).toEqual({ diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index dea41ed..7324747 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -7,6 +7,7 @@ import type { const protocolMethods = new Set([ 'initialize', + 'config/diagnostics', 'thread/start', 'thread/read', 'thread/resume', diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts index 92f9092..cc69828 100644 --- a/packages/protocol/src/runtime.test.ts +++ b/packages/protocol/src/runtime.test.ts @@ -35,6 +35,7 @@ describe('ProtocolRuntime', () => { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: false, }, }); }); diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index 45af68b..47e3689 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -40,6 +40,7 @@ export interface ProtocolRuntimeOptions { now?: () => string; newId?: (prefix: 'thread' | 'turn' | 'item') => string; onEvent?: (event: ProtocolEvent) => void; + configDiagnostics?: boolean; } export class ProtocolInvariantError extends Error { @@ -71,6 +72,7 @@ export class ProtocolRuntime { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: this.options.configDiagnostics ?? false, }, }; } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 59bc870..8363c3f 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -117,11 +117,35 @@ export interface InitializeResult { transientDeltas: true; structuredToolEvents: true; interactiveRequests: true; + configDiagnostics: boolean; }; } +export type ConfigLayerName = 'user' | 'project' | 'local' | 'override'; + +export interface ConfigDiagnosticsResult { + cwd: string; + trustStatus: 'trusted' | 'plan-only' | 'untrusted'; + layers: Array<{ + layer: ConfigLayerName; + path: string; + present: boolean; + trusted: boolean; + }>; + provenance: Record; + gated: string[]; + issues: Array<{ + severity: 'info' | 'warning' | 'error'; + code: string; + message: string; + pointer?: string; + source?: { layer: ConfigLayerName; path: string }; + }>; +} + export type ProtocolMethod = | 'initialize' + | 'config/diagnostics' | 'thread/start' | 'thread/read' | 'thread/resume' From c0c7ed62f5fc9f823394d051ec206bbba4109ab5 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 16:00:01 +0800 Subject: [PATCH 22/23] feat: surface config diagnostics in clients --- apps/cli/src/cli.ts | 28 ++++++++++-- apps/desktop/e2e/desktop-preview.spec.ts | 11 +++++ apps/desktop/src/lib/protocol-agent.test.ts | 21 +++++++++ apps/desktop/src/lib/protocol-agent.ts | 13 ++++++ apps/desktop/src/lib/window-shim.ts | 4 ++ apps/desktop/src/preview-app.tsx | 24 +++++++++++ apps/desktop/src/screens/About.tsx | 47 +++++++++++++++++++++ apps/desktop/src/types/global.d.ts | 3 ++ apps/lsp/README.md | 25 +++++------ apps/lsp/src/handler.test.ts | 31 ++++++++++++++ apps/lsp/src/handler.ts | 13 ++++++ apps/vscode/README.md | 13 +++--- apps/vscode/package.json | 5 +++ apps/vscode/src/diagnostics.test.ts | 35 +++++++++++++++ apps/vscode/src/diagnostics.ts | 16 +++++++ apps/vscode/src/extension.ts | 11 +++++ apps/vscode/src/protocol-runtime.test.ts | 36 ++++++++++++++++ apps/vscode/src/protocol-runtime.ts | 9 ++++ docs/CODEX_ALIGNMENT_PLAN.md | 2 + docs/cli-flags.md | 14 +++--- 20 files changed, 333 insertions(+), 28 deletions(-) create mode 100644 apps/vscode/src/diagnostics.test.ts create mode 100644 apps/vscode/src/diagnostics.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 9a33f55..be60d6b 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -3,7 +3,7 @@ // Spec: docs/DEVELOPMENT_PLAN.md §5 / §5a // M2: onboarding + REPL + slash commands + settings + permissions matcher. -import { CredentialsStore, VERSION, redact } from '@deepcode/core'; +import { CredentialsStore, VERSION, diagnoseSettings, redact } from '@deepcode/core'; import { runAppServer } from '@deepcode/app-server'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; @@ -14,6 +14,7 @@ import { helpText, parseArgs } from './parse-args.js'; import { startRepl } from './repl.js'; import { runCronCommand, runSchedulerRun } from './scheduler.js'; import { runTrustCommand } from './trust-cmd.js'; +import { TrustStore } from './trust.js'; import { runPluginsCommand, runSkillsCommand } from './list-cmd.js'; import { runSetupToken } from './setup-token.js'; import { runCompletion } from './completion.js'; @@ -184,11 +185,13 @@ async function main(): Promise { } async function doctor(): Promise { + const cwd = resolve(process.cwd()); process.stdout.write(`DeepCode v${VERSION}\n`); process.stdout.write(`Node: ${process.version}\n`); process.stdout.write(`Platform: ${process.platform} ${process.arch}\n`); process.stdout.write(`Home: ${homedir()}\n`); - process.stdout.write(`CWD: ${resolve(process.cwd())}\n`); + process.stdout.write(`CWD: ${cwd}\n`); + let failed = false; try { const store = new CredentialsStore(); const creds = await store.load(); @@ -197,7 +200,26 @@ async function doctor(): Promise { } catch (err) { process.stdout.write(`Credentials error: ${(err as Error).message}\n`); } - return 0; + try { + const trustStatus = await new TrustStore().statusFor(cwd); + const config = await diagnoseSettings({ cwd, trustStatus }); + process.stdout.write(`Configuration trust: ${config.trustStatus}\n`); + for (const layer of config.layers) { + const status = layer.present ? (layer.trusted ? 'active' : 'untrusted') : 'missing'; + process.stdout.write(`Config ${layer.layer}: ${status} (${layer.path})\n`); + } + process.stdout.write( + `Config gated: ${config.gated.length ? config.gated.join(', ') : 'none'}\n`, + ); + for (const issue of config.issues) { + process.stdout.write(`Config ${issue.severity}: [${issue.code}] ${issue.message}\n`); + if (issue.severity === 'error') failed = true; + } + } catch (error) { + process.stdout.write(`Configuration error: ${(error as Error).message}\n`); + failed = true; + } + return failed ? 1 : 0; } main().then( diff --git a/apps/desktop/e2e/desktop-preview.spec.ts b/apps/desktop/e2e/desktop-preview.spec.ts index 7efc2a7..116cec0 100644 --- a/apps/desktop/e2e/desktop-preview.spec.ts +++ b/apps/desktop/e2e/desktop-preview.spec.ts @@ -79,3 +79,14 @@ test('opens source, diff, and history from the file activity rail', async ({ pag await panel.getByRole('button', { name: 'History', exact: true }).click(); await expect(panel.locator('.fp-hist-row')).toHaveCount(3); }); + +test('shows the shared trust-aware configuration diagnostics in About', async ({ page }) => { + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await page.getByRole('button', { name: 'ⓘ About', exact: true }).click(); + + const main = page.getByRole('main'); + await expect(main.getByText('Diagnostics', { exact: true })).toBeVisible(); + await expect(main.getByText('untrusted', { exact: true })).toBeVisible(); + await expect(main.getByText('permissions', { exact: true })).toBeVisible(); + await expect(main.getByText('1', { exact: true })).toBeVisible(); +}); diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index 0884d63..8be61c9 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -1,4 +1,5 @@ import type { + ConfigDiagnosticsResult, InitializeResult, ProtocolEvent, ProtocolMethod, @@ -41,10 +42,20 @@ class FakeTransport implements ProtocolTransport { if (method === 'thread/resume') return thread as T; if (method === 'turn/start') return turn as T; if (method === 'turn/interrupt') return { interrupted: true } as T; + if (method === 'config/diagnostics') return diagnostics as T; return { accepted: true } as T; } } +const diagnostics: ConfigDiagnosticsResult = { + cwd: '/workspace', + trustStatus: 'untrusted', + layers: [], + provenance: {}, + gated: ['permissions'], + issues: [], +}; + const thread: ThreadSnapshot = { id: 'thread-1', cwd: '/workspace', @@ -62,6 +73,16 @@ const turn: TurnSnapshot = { }; describe('DesktopProtocolAgent', () => { + it('reads value-free diagnostics from the shared app-server', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + + await expect(agent.diagnostics('/workspace')).resolves.toEqual(diagnostics); + expect(transport.requests).toEqual([ + { method: 'config/diagnostics', params: { cwd: '/workspace' } }, + ]); + }); + it('buffers fast server events until turn/start returns, then projects them in order', async () => { vi.useFakeTimers(); const transport = new FakeTransport(); diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts index 87662ea..24d4424 100644 --- a/apps/desktop/src/lib/protocol-agent.ts +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -1,4 +1,5 @@ import type { + ConfigDiagnosticsResult, InitializeResult, ProtocolEvent, ProtocolMethod, @@ -83,6 +84,14 @@ export class DesktopProtocolAgent { return thread; } + async diagnostics(cwd: string): Promise { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.configDiagnostics) { + throw new Error('The app-server does not support configuration diagnostics'); + } + return this.transport.request('config/diagnostics', { cwd }); + } + clear(): void { void this.interruptActiveTurns(); this.threadId = null; @@ -293,6 +302,10 @@ export function clearProtocolThread(): void { defaultAgent.clear(); } +export function getConfigDiagnostics(cwd: string) { + return defaultAgent.diagnostics(cwd); +} + export function abortProtocolTurn(turnId: string) { return defaultAgent.abort(turnId); } diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index db81581..64e92df 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -8,6 +8,7 @@ import { abortProtocolTurn, answerProtocolRequest, approveProtocolRequest, + getConfigDiagnostics, installProtocolAgentEmitter, resumeProtocolThread, startProtocolTurn, @@ -59,6 +60,9 @@ export function installTauriShim(): void { load() { return loadSettingsFile(); }, + diagnostics({ cwd }) { + return getConfigDiagnostics(cwd); + }, }, sessions: { async list() { diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index 58d41fc..47a8904 100644 --- a/apps/desktop/src/preview-app.tsx +++ b/apps/desktop/src/preview-app.tsx @@ -193,6 +193,30 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise { }, }); break; + case 'config/diagnostics': + await respond({ + cwd: String(request.params.cwd), + trustStatus: 'untrusted', + layers: [ + { + layer: 'project', + path: `${String(request.params.cwd)}/.deepcode/settings.json`, + present: true, + trusted: false, + }, + ], + provenance: {}, + gated: ['permissions'], + issues: [ + { + severity: 'warning', + code: 'untrusted_setting_gated', + message: 'Ignored project setting /permissions until this directory is trusted', + pointer: '/permissions', + }, + ], + }); + break; case 'thread/start': { activeThreadId = `preview-thread-${nextThread++}`; const thread = threadSnapshot(activeThreadId); diff --git a/apps/desktop/src/screens/About.tsx b/apps/desktop/src/screens/About.tsx index 20d5199..da43975 100644 --- a/apps/desktop/src/screens/About.tsx +++ b/apps/desktop/src/screens/About.tsx @@ -2,6 +2,7 @@ // Brand mark + version + diagnostics + docs links. import { useEffect, useState } from 'react'; +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; import { BrandMark } from '../components/BrandMark.js'; import { Card, Row, Screen, SectionTitle } from '../components/Screen.js'; import { loadProjectPath } from '../lib/project.js'; @@ -12,6 +13,8 @@ interface Diag { hasCreds: boolean; baseURL?: string; projectPath?: string; + config?: ConfigDiagnosticsResult; + configError?: string; } export function AboutScreen(): JSX.Element { @@ -24,11 +27,22 @@ export function AboutScreen(): JSX.Element { window.deepcode.creds.load(), loadProjectPath(), ]); + let config: ConfigDiagnosticsResult | undefined; + let configError: string | undefined; + if (projectPath) { + try { + config = await window.deepcode.settings.diagnostics({ cwd: projectPath }); + } catch (error) { + configError = (error as Error).message ?? String(error); + } + } setDiag({ version, hasCreds: creds.hasKey, baseURL: creds.baseURL, projectPath, + config, + configError, }); })(); }, []); @@ -97,6 +111,39 @@ export function AboutScreen(): JSX.Element { {diag.baseURL ?? 'https://api.deepseek.com/v1'} + + {diag.config ? ( + + {diag.config.trustStatus} + + ) : ( + + {diag.configError ?? 'choose a project to inspect'} + + )} + + {diag.config && ( + <> + + {diag.config.layers.filter((layer) => layer.present).length} loaded ·{' '} + {Object.keys(diag.config.provenance).length} effective keys + + + {diag.config.gated.length ? diag.config.gated.join(', ') : 'none'} + + + + {diag.config.issues.length} + + + + )} Paths diff --git a/apps/desktop/src/types/global.d.ts b/apps/desktop/src/types/global.d.ts index 2283b13..4b15ad5 100644 --- a/apps/desktop/src/types/global.d.ts +++ b/apps/desktop/src/types/global.d.ts @@ -1,6 +1,8 @@ // Canonical renderer types. Window.deepcode is installed at runtime by // src/lib/window-shim.ts (which uses Tauri's invoke() under the hood). +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; + export interface UpdateInfo { version: string; releaseNotes?: string; @@ -45,6 +47,7 @@ export interface DeepCodeAPI { }; settings: { load: () => Promise>; + diagnostics: (args: { cwd: string }) => Promise; }; sessions: { list: (args?: { limit?: number }) => Promise; diff --git a/apps/lsp/README.md b/apps/lsp/README.md index 297f62e..ca1872e 100644 --- a/apps/lsp/README.md +++ b/apps/lsp/README.md @@ -6,15 +6,16 @@ LSP plugin) can drive DeepCode via `workspace/executeCommand`. ## Custom commands -| Command | Args | Returns | -| --------------------------- | ----------------------------------------------- | ------------------------- | -| `deepcode.runAgent` | `{ prompt, threadId?, model?, effort?, mode? }` | `{ threadId, turnId }` | -| `deepcode.abort` | `{ turnId }` | `{ aborted }` | -| `deepcode.readThread` | `{ threadId }` | protocol thread snapshot | -| `deepcode.resumeThread` | `{ threadId }` | resumed protocol snapshot | -| `deepcode.respondApproval` | `{ turnId, requestId, decision }` | `{ accepted }` | -| `deepcode.respondUserInput` | `{ turnId, requestId, answer }` | `{ accepted }` | -| `deepcode.listSkills` | none | `{ skills: SkillRow[] }` | +| Command | Args | Returns | +| ---------------------------- | ----------------------------------------------- | -------------------------------------------------- | +| `deepcode.runAgent` | `{ prompt, threadId?, model?, effort?, mode? }` | `{ threadId, turnId }` | +| `deepcode.abort` | `{ turnId }` | `{ aborted }` | +| `deepcode.readThread` | `{ threadId }` | protocol thread snapshot | +| `deepcode.resumeThread` | `{ threadId }` | resumed protocol snapshot | +| `deepcode.respondApproval` | `{ turnId, requestId, decision }` | `{ accepted }` | +| `deepcode.respondUserInput` | `{ turnId, requestId, answer }` | `{ accepted }` | +| `deepcode.listSkills` | none | `{ skills: SkillRow[] }` | +| `deepcode.configDiagnostics` | none | value-free config sources, trust gates, and issues | Lifecycle, structured tool, usage, approval, and user-input events are sent unchanged as `deepcode/protocolEvent` notifications: @@ -115,6 +116,6 @@ In `Preferences → Package Settings → LSP → Settings`: ## Current scope -The bridge covers thread start/read/resume, turn start/interrupt, structured events, approvals, and -AskUserQuestion. Multi-client attachment and shared-daemon authentication remain intentionally out -of scope for protocol v1. +The bridge covers thread start/read/resume, turn start/interrupt, structured events, approvals, +AskUserQuestion, and configuration diagnostics. Multi-client attachment and shared-daemon +authentication remain intentionally out of scope for protocol v1. diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index aa4af20..fd97a96 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -1,4 +1,5 @@ import type { + ConfigDiagnosticsResult, InitializeResult, ProtocolEvent, ProtocolMethod, @@ -23,6 +24,15 @@ const capabilities: InitializeResult = { }, }; +const diagnostics: ConfigDiagnosticsResult = { + cwd: '/tmp/x', + trustStatus: 'untrusted', + layers: [], + provenance: {}, + gated: ['permissions'], + issues: [], +}; + class FakeClient { subscribers = new Set<(event: ProtocolEvent) => void>(); requests: ProtocolRequest[] = []; @@ -94,6 +104,8 @@ class FakeClient { case 'approval/respond': case 'user-input/respond': return { accepted: true } as T; + case 'config/diagnostics': + return diagnostics as T; default: throw new Error(`Unexpected method: ${method}`); } @@ -132,12 +144,31 @@ describe('handleMessage — initialize', () => { 'deepcode.resumeThread', 'deepcode.respondApproval', 'deepcode.respondUserInput', + 'deepcode.configDiagnostics', ]), ); }); }); describe('handleMessage — protocol commands', () => { + it('returns app-server configuration diagnostics for the LSP workspace', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + + await handleMessage( + { jsonrpc: '2.0', id: 1, method: 'initialize', params: { rootUri: 'file:///tmp/x' } }, + (message) => out.push(message), + ); + await execute(2, 'deepcode.configDiagnostics', {}, (message) => out.push(message)); + + expect(out.find((message) => message.id === 2)?.result).toEqual(diagnostics); + expect(client.requests.at(-1)).toMatchObject({ + method: 'config/diagnostics', + params: { cwd: '/tmp/x' }, + }); + }); + it('starts a canonical thread and emits native protocol events in order', async () => { const client = new FakeClient(); __test.setClientFactory(() => client); diff --git a/apps/lsp/src/handler.ts b/apps/lsp/src/handler.ts index 31393e1..27a7ac9 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; import { ProtocolClient, + type ConfigDiagnosticsResult, type InitializeResult, type ProtocolEvent, type ProtocolMethod, @@ -64,6 +65,7 @@ const COMMANDS = [ 'deepcode.respondApproval', 'deepcode.respondUserInput', 'deepcode.listSkills', + 'deepcode.configDiagnostics', ]; export async function handleMessage(msg: LspMessage, send: SendFn): Promise { @@ -166,6 +168,8 @@ async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): ); case 'deepcode.listSkills': return handleListSkills(); + case 'deepcode.configDiagnostics': + return handleConfigDiagnostics(); default: throw new Error(`Unknown command: ${params.command}`); } @@ -388,6 +392,15 @@ async function handleListSkills(): Promise<{ skills: unknown[] }> { }; } +async function handleConfigDiagnostics(): Promise { + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.configDiagnostics) { + throw new Error('The app-server does not support configuration diagnostics'); + } + return client.request('config/diagnostics', { cwd: workspacePath() }); +} + export const __test = { state, dispatch, diff --git a/apps/vscode/README.md b/apps/vscode/README.md index c1485e4..43a9171 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -5,7 +5,7 @@ protocol and canonical threads as the desktop client. ## Current state -- Three commands, an activity-bar chat view, model/effort settings, and a default +- Four commands, an activity-bar chat view, model/effort settings, and a default `Cmd/Ctrl+Shift+D` keybinding. - Canonical thread reuse, structured text/tool events, real interrupt plumbing, approval via warning actions, and AskUserQuestion via QuickPick/InputBox. @@ -38,11 +38,12 @@ Then: ## Commands -| ID | Default keybinding | What it does | -| -------------------- | ------------------ | --------------------------------------- | -| `deepcode.openPanel` | `Cmd/Ctrl+Shift+D` | Reveal the DeepCode chat view | -| `deepcode.run` | (palette) | Run agent on the selected text | -| `deepcode.review` | (palette) | Run `code-review` skill on current diff | +| ID | Default keybinding | What it does | +| -------------------------- | ------------------ | ------------------------------------------------------- | +| `deepcode.openPanel` | `Cmd/Ctrl+Shift+D` | Reveal the DeepCode chat view | +| `deepcode.run` | (palette) | Run agent on the selected text | +| `deepcode.review` | (palette) | Run `code-review` skill on current diff | +| `deepcode.showDiagnostics` | (palette) | Show value-free config sources, trust gates, and issues | ## Settings diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 10531bf..186e904 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -23,6 +23,7 @@ "activationEvents": [ "onCommand:deepcode.openPanel", "onCommand:deepcode.run", + "onCommand:deepcode.showDiagnostics", "onStartupFinished" ], "contributes": { @@ -38,6 +39,10 @@ { "command": "deepcode.review", "title": "DeepCode: Review current diff" + }, + { + "command": "deepcode.showDiagnostics", + "title": "DeepCode: Show Configuration Diagnostics" } ], "configuration": { diff --git a/apps/vscode/src/diagnostics.test.ts b/apps/vscode/src/diagnostics.test.ts new file mode 100644 index 0000000..b1df6cd --- /dev/null +++ b/apps/vscode/src/diagnostics.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { formatConfigDiagnostics } from './diagnostics.js'; + +describe('formatConfigDiagnostics', () => { + it('renders trust, source layers, gates, and issues without configuration values', () => { + const output = formatConfigDiagnostics({ + cwd: '/workspace', + trustStatus: 'untrusted', + layers: [ + { + layer: 'project', + path: '/workspace/.deepcode/settings.json', + present: true, + trusted: false, + }, + ], + provenance: { '/env/API_TOKEN': { layer: 'project', path: '/settings.json' } }, + gated: ['env'], + issues: [ + { + severity: 'warning', + code: 'untrusted_setting_gated', + message: 'Ignored project setting /env', + }, + ], + }).join('\n'); + + expect(output).toContain('Trust: untrusted'); + expect(output).toContain('project untrusted'); + expect(output).toContain('Gated fields: env'); + expect(output).toContain('WARNING untrusted_setting_gated'); + expect(output).not.toContain('secret-value'); + }); +}); diff --git a/apps/vscode/src/diagnostics.ts b/apps/vscode/src/diagnostics.ts new file mode 100644 index 0000000..7d17681 --- /dev/null +++ b/apps/vscode/src/diagnostics.ts @@ -0,0 +1,16 @@ +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; + +export function formatConfigDiagnostics(report: ConfigDiagnosticsResult): string[] { + const lines = [`DeepCode configuration · ${report.cwd}`, `Trust: ${report.trustStatus}`, '']; + lines.push('Layers:'); + for (const layer of report.layers) { + const state = layer.present ? (layer.trusted ? 'active' : 'untrusted') : 'missing'; + lines.push(` ${layer.layer.padEnd(8)} ${state.padEnd(9)} ${layer.path}`); + } + lines.push('', `Gated fields: ${report.gated.length ? report.gated.join(', ') : 'none'}`); + lines.push(`Issues: ${report.issues.length}`); + for (const issue of report.issues) { + lines.push(` ${issue.severity.toUpperCase()} ${issue.code}: ${issue.message}`); + } + return lines; +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index d2e20f4..7757e47 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -5,6 +5,7 @@ import { ProtocolClient, type ProtocolEvent } from '@deepcode/protocol'; import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; import { EditorProtocolRuntime } from './protocol-runtime.js'; +import { formatConfigDiagnostics } from './diagnostics.js'; type V = typeof import('vscode'); @@ -56,6 +57,16 @@ export async function activate(context: vscode.ExtensionContext): Promise runtime, ); }), + commands.registerCommand('deepcode.showDiagnostics', async () => { + const out = window.createOutputChannel('DeepCode Diagnostics'); + out.show(true); + try { + const report = await runtime.diagnostics(); + for (const line of formatConfigDiagnostics(report)) out.appendLine(line); + } catch (error) { + out.appendLine(`✕ ${(error as Error).message ?? String(error)}`); + } + }), window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod, runtime)), ); } diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index 19d9c83..c9f3dd0 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -1,4 +1,5 @@ import type { + ConfigDiagnosticsResult, InitializeResult, ProtocolEvent, ProtocolMethod, @@ -65,6 +66,7 @@ class FakeClient { return this.turn as T; } if (method === 'turn/interrupt') return { interrupted: true } as T; + if (method === 'config/diagnostics') return diagnostics as T; return { accepted: true } as T; } @@ -75,7 +77,41 @@ class FakeClient { } } +const diagnostics: ConfigDiagnosticsResult = { + cwd: '/workspace', + trustStatus: 'untrusted', + layers: [], + provenance: {}, + gated: ['permissions'], + issues: [], +}; + describe('EditorProtocolRuntime', () => { + it('reads configuration diagnostics from the app-server for the editor workspace', async () => { + const client = new FakeClient(); + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + + await expect(runtime.diagnostics()).resolves.toEqual(diagnostics); + expect(client.requests).toEqual([ + expect.objectContaining({ method: 'config/diagnostics', params: { cwd: '/workspace' } }), + ]); + }); + + it('honors diagnostics capability negotiation', async () => { + const client = new FakeClient(); + client.connect = async () => { + const initialized = await new FakeClient().connect(); + return { + ...initialized, + capabilities: { ...initialized.capabilities, configDiagnostics: false }, + }; + }; + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + + await expect(runtime.diagnostics()).rejects.toThrow(/does not support/); + expect(client.requests).toEqual([]); + }); + it('buffers fast turn events and reuses the canonical thread', async () => { const client = new FakeClient(); const runtime = new EditorProtocolRuntime(client, () => '/workspace'); diff --git a/apps/vscode/src/protocol-runtime.ts b/apps/vscode/src/protocol-runtime.ts index 84d1363..920140e 100644 --- a/apps/vscode/src/protocol-runtime.ts +++ b/apps/vscode/src/protocol-runtime.ts @@ -1,4 +1,5 @@ import type { + ConfigDiagnosticsResult, InitializeResult, ProtocolEvent, ProtocolMethod, @@ -72,6 +73,14 @@ export class EditorProtocolRuntime { return this.client.request('thread/read', { threadId }); } + async diagnostics(): Promise { + const initialized = await this.client.connect(); + if (!initialized.capabilities.configDiagnostics) { + throw new Error('The app-server does not support configuration diagnostics'); + } + return this.client.request('config/diagnostics', { cwd: this.cwd() }); + } + async interrupt(turnId: string): Promise { const threadId = this.turnThreads.get(turnId); if (!threadId) return false; diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 4619bdc..98b8e4e 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -316,6 +316,8 @@ model tool call - JSON config 已增加逐叶 JSON Pointer provenance 与不含值的 diagnostics;CLI/desktop/editor app-server 现在共享 core trust store,未信任项目不能通过 permissions/sandbox/env 等字段扩大权限。 +- CLI doctor、Desktop About、VS Code command 与 LSP command 均消费同一个 diagnostics DTO, + 客户端不再自行解释配置来源或 trust gate。 - 下一步统一 `AGENTS.md`、`DEEPCODE.md`、MCP、skills、plugins、hooks。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 - diff review、可定位反馈、trace id、结构化日志与脱敏导出。 diff --git a/docs/cli-flags.md b/docs/cli-flags.md index 174b99e..b227788 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -15,13 +15,13 @@ deepcode upgrade # CLI self-update ## Action triggers -| Flag | Effect | Milestone | -| ------------------------ | ----------------------------------------- | --------- | -| `-h`, `--help` | Print usage | M2 ✅ | -| `-v`, `--version` | Print version | M2 ✅ | -| `doctor` | Health check (node / paths / API key) | M2 ✅ | -| `upgrade` | Print `npm i -g deepcode-cli@latest` hint | M2 ✅ | -| `-p`, `--print ` | Headless one-shot | M8 | +| Flag | Effect | Milestone | +| ------------------------ | ------------------------------------------------------------------- | --------- | +| `-h`, `--help` | Print usage | M2 ✅ | +| `-v`, `--version` | Print version | M2 ✅ | +| `doctor` | Health check (runtime, credentials, config provenance/trust/issues) | M2 ✅ | +| `upgrade` | Print `npm i -g deepcode-cli@latest` hint | M2 ✅ | +| `-p`, `--print ` | Headless one-shot | M8 | ## Session shaping From f80bfe91da44684e41fe7cb54773df6e2dc97b96 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 16:12:18 +0800 Subject: [PATCH 23/23] feat: compose app server runtime context --- apps/desktop/src/screens/Repl.tsx | 24 ++- apps/server/README.md | 5 + apps/server/src/default-runtime.ts | 37 ++-- apps/server/src/index.ts | 1 + apps/server/src/runtime-composition.test.ts | 73 ++++++++ apps/server/src/runtime-composition.ts | 80 +++++++++ apps/server/src/runtime-executor.test.ts | 71 +++++++- apps/server/src/runtime-executor.ts | 178 +++++++++++--------- apps/vscode/README.md | 10 +- apps/vscode/src/extension.ts | 8 +- apps/vscode/src/settings.test.ts | 30 ++++ apps/vscode/src/settings.ts | 22 +++ docs/CODEX_ALIGNMENT_PLAN.md | 7 +- docs/design/app-server-v1.md | 7 + packages/core/package.json | 16 ++ packages/core/src/memory/loader.ts | 11 +- packages/core/src/output-styles/loader.ts | 5 +- packages/core/src/skills/loader.ts | 5 +- 18 files changed, 480 insertions(+), 110 deletions(-) create mode 100644 apps/server/src/runtime-composition.test.ts create mode 100644 apps/server/src/runtime-composition.ts create mode 100644 apps/vscode/src/settings.test.ts create mode 100644 apps/vscode/src/settings.ts diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 5be107a..6015d50 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -81,6 +81,7 @@ const MAX_RECENT_FILES = 8; type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; +type AgentMode = 'default' | 'acceptEdits' | 'plan' | 'auto' | 'dontAsk' | 'bypassPermissions'; const EFFORT_OPTIONS: DropdownOption[] = [ // `meta` is the per-turn output-token budget (maxTokens) the effort maps to in @@ -133,9 +134,7 @@ const MODEL_OPTIONS: DropdownOption<'deepseek-chat' | 'deepseek-reasoner'>[] = [ }, ]; -const MODE_OPTIONS: DropdownOption< - 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions' ->[] = [ +const MODE_OPTIONS: DropdownOption[] = [ { value: 'default', label: 'Default', @@ -154,6 +153,12 @@ const MODE_OPTIONS: DropdownOption< meta: '◐', description: 'Read-only — write tools blocked. Use for exploring.', }, + { + value: 'auto', + label: 'Auto', + meta: '◈', + description: 'Classify each tool call and apply the configured automatic policy.', + }, { value: 'dontAsk', label: "Don't ask", @@ -248,9 +253,7 @@ export function ReplScreen({ // current model (deepseek-reasoner output is ¥16/M vs chat's ¥2/M). const modelRef = useRef(model); modelRef.current = model; - const [mode, setMode] = useState< - 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions' - >('default'); + const [mode, setMode] = useState('default'); const [usage, setUsage] = useState<{ inputTokens: number; outputTokens: number }>({ inputTokens: 0, outputTokens: 0, @@ -296,11 +299,20 @@ export function ReplScreen({ const s = (await loadSettingsFile()) as { effortLevel?: string; model?: string; + permissions?: { defaultMode?: string }; }; if (s.effortLevel && (EFFORTS as string[]).includes(s.effortLevel)) { setEffort(s.effortLevel as Effort); } if (s.model) setModel(s.model); + if ( + s.permissions?.defaultMode && + ['default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions'].includes( + s.permissions.defaultMode, + ) + ) { + setMode(s.permissions.defaultMode as AgentMode); + } } catch { /* defaults */ } diff --git a/apps/server/README.md b/apps/server/README.md index 9a44c9a..22ba93f 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -22,3 +22,8 @@ The transport is experimental. Clients must negotiate `protocolVersion` before u `config/diagnostics` accepts a workspace `cwd` and returns a value-free report containing loaded layers, leaf provenance, trust-gated fields, and validation issues. Configuration values and credentials never cross this protocol boundary. + +Each turn leases a host composition for its workspace. The backend loads user/project +`DEEPCODE.md`, `AGENTS.md`, rules, memory, skills, output style, hooks, and settings defaults before +calling `RuntimeHost`; clients remain unaware of those files. The lease has an explicit async close +hook so later MCP/plugin resources cannot leak across turns. diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 23827aa..143cc6a 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -3,9 +3,9 @@ import { DirectoryTrustStore, gateUntrustedSettings, loadSettings } from '@deepc import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; import { RuntimeHost, SAFE_READONLY_TOOLS } from '@deepcode/core/runtime'; import { SessionManager } from '@deepcode/core/sessions'; -import { BUILTIN_TOOLS, ToolRegistry } from '@deepcode/core/tools'; import { RuntimeHostExecutor } from './runtime-executor.js'; +import { composeRuntime, resolveComposedMode } from './runtime-composition.js'; export function createDefaultTurnExecutor( home?: string, @@ -16,10 +16,11 @@ export function createDefaultTurnExecutor( root: home ? `${home}/sessions` : undefined, }); return new RuntimeHostExecutor({ - createHost: async (cwd, mode) => { + createHost: async (cwd, mode, context) => { const loaded = await loadSettings({ cwd, directory: home }); const trustStatus = await trustStore.statusFor(cwd); const { settings } = gateUntrustedSettings(loaded, trustStatus); + const effectiveMode = resolveComposedMode(mode, context.modeExplicit, settings); const credentials = await resolveCredentials({ store: new CredentialsStore({ directory: home, @@ -32,19 +33,27 @@ export function createDefaultTurnExecutor( 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', ); } - return new RuntimeHost({ - provider: new DeepSeekProvider({ - apiKey: credentials.apiKey ?? '', - authToken: credentials.authToken, - baseURL: credentials.baseURL ?? settings.baseURL, + const composition = await composeRuntime({ cwd, directory: home, settings }); + return { + host: new RuntimeHost({ + provider: new DeepSeekProvider({ + apiKey: credentials.apiKey ?? '', + authToken: credentials.authToken, + baseURL: credentials.baseURL ?? settings.baseURL, + }), + tools: composition.tools, + cwd, + mode: effectiveMode, + permissions: settings.permissions ?? { allow: [...SAFE_READONLY_TOOLS] }, + hooks: composition.hooks, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + pluginDirs: composition.pluginDirs, }), - tools: new ToolRegistry(BUILTIN_TOOLS), - cwd, - mode, - permissions: settings.permissions ?? { allow: [...SAFE_READONLY_TOOLS] }, - autoMode: settings.autoMode, - sandboxConfig: settings.sandbox, - }); + systemPrompt: composition.systemPrompt, + model: composition.model, + effort: composition.effort, + }; }, sessionManager, }); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 1ea2807..657db0b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,3 +5,4 @@ export * from './default-runtime.js'; export * from './stdio.js'; export * from './run.js'; export * from './client.js'; +export * from './runtime-composition.js'; diff --git a/apps/server/src/runtime-composition.test.ts b/apps/server/src/runtime-composition.test.ts new file mode 100644 index 0000000..8f52758 --- /dev/null +++ b/apps/server/src/runtime-composition.test.ts @@ -0,0 +1,73 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { composeRuntime, resolveComposedMode } from './runtime-composition.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +describe('composeRuntime', () => { + it('uses trusted settings mode unless the client explicitly overrides it', () => { + const settings = { permissions: { defaultMode: 'plan' as const } }; + expect(resolveComposedMode('default', false, settings)).toBe('plan'); + expect(resolveComposedMode('auto', true, settings)).toBe('auto'); + }); + + it('assembles memory, AGENTS, skills, style, hooks, and model defaults', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dc-composition-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-composition-cwd-')); + roots.push(directory, cwd); + await writeFile(join(directory, 'DEEPCODE.md'), 'User-level instructions.'); + await writeFile(join(cwd, 'AGENTS.md'), 'Project agent instructions.'); + await mkdir(join(directory, 'skills', 'verify'), { recursive: true }); + await writeFile( + join(directory, 'skills', 'verify', 'SKILL.md'), + '---\nname: verify\ndescription: Verify the result.\n---\nRun the relevant tests.', + ); + await mkdir(join(directory, 'output-styles'), { recursive: true }); + await writeFile( + join(directory, 'output-styles', 'focused.md'), + '---\nname: focused\n---\nReport only material findings.', + ); + + const composition = await composeRuntime({ + cwd, + directory, + settings: { + model: 'deepseek-reasoner', + effortLevel: 'high', + outputStyle: 'focused', + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'prompt', prompt: 'Additional hook context.' }] }], + }, + }, + }); + + expect(composition.systemPrompt).toContain('User-level instructions.'); + expect(composition.systemPrompt).toContain('Project agent instructions.'); + expect(composition.systemPrompt).toContain('verify'); + expect(composition.systemPrompt).toContain('Report only material findings.'); + expect(composition.tools.get('Read')).toBeDefined(); + expect(composition.tools.get('Bash')).toBeDefined(); + expect(composition.tools.get('Skill')).toBeDefined(); + expect(composition.model).toBe('deepseek-reasoner'); + expect(composition.effort).toBe('high'); + await expect( + composition.hooks.dispatch({ + event: 'UserPromptSubmit', + cwd, + triggeredAt: '2026-08-01T00:00:00.000Z', + payload: { prompt: 'test' }, + }), + ).resolves.toEqual( + expect.objectContaining({ stdout: expect.stringContaining('hook context') }), + ); + }); +}); diff --git a/apps/server/src/runtime-composition.ts b/apps/server/src/runtime-composition.ts new file mode 100644 index 0000000..b4d4fa2 --- /dev/null +++ b/apps/server/src/runtime-composition.ts @@ -0,0 +1,80 @@ +import type { Effort, Mode } from '@deepcode/core'; +import type { DeepCodeSettings } from '@deepcode/core/config'; +import { HookDispatcher } from '@deepcode/core/hooks'; +import { loadMemory } from '@deepcode/core/memory'; +import { applyStyle, findStyle, loadOutputStyles } from '@deepcode/core/output-styles'; +import { buildSkillsDescriptionBlock, loadSkills, makeSkillTool } from '@deepcode/core/skills'; +import { BUILTIN_TOOLS, ToolRegistry } from '@deepcode/core/tools'; + +export const DEFAULT_APP_SERVER_SYSTEM_PROMPT = + 'You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their ' + + 'codebase using the available tools. Be concise and accurate. When you modify files, briefly ' + + 'explain what you changed and why.'; + +export interface RuntimeCompositionOptions { + cwd: string; + directory?: string; + settings: DeepCodeSettings; + pluginDirs?: string[]; +} + +export interface RuntimeComposition { + tools: ToolRegistry; + hooks: HookDispatcher; + systemPrompt: string; + model: string; + effort: Effort; + pluginDirs: string[]; +} + +export function resolveComposedMode( + requested: Mode, + modeExplicit: boolean, + settings: DeepCodeSettings, +): Mode { + return modeExplicit ? requested : (settings.permissions?.defaultMode ?? requested); +} + +/** Compose filesystem-backed instructions and tools inside the trusted host. */ +export async function composeRuntime( + options: RuntimeCompositionOptions, +): Promise { + const { cwd, directory, settings } = options; + const pluginDirs = options.pluginDirs ?? []; + const [memory, skills, styles] = await Promise.all([ + loadMemory({ + cwd, + directory, + maxBytes: (settings.memoryLoadCapKB ?? 100) * 1024, + }), + loadSkills({ + cwd, + directory, + pluginDirs, + overrides: settings.skillOverrides, + }), + loadOutputStyles({ cwd, directory }), + ]); + + const tools = new ToolRegistry(BUILTIN_TOOLS); + if (skills.length > 0) tools.register(makeSkillTool(skills)); + + let systemPrompt = DEFAULT_APP_SERVER_SYSTEM_PROMPT; + if (memory.text) systemPrompt += `\n\n${memory.text}`; + const skillsBlock = buildSkillsDescriptionBlock(skills); + if (skillsBlock) systemPrompt += `\n\n${skillsBlock}`; + systemPrompt = applyStyle(systemPrompt, findStyle(styles, settings.outputStyle ?? 'default')); + + return { + tools, + hooks: new HookDispatcher({ + hooks: settings.hooks, + disableAllHooks: settings.disableAllHooks, + allowedHttpHookUrls: settings.allowedHttpHookUrls, + }), + systemPrompt, + model: settings.model ?? 'deepseek-chat', + effort: settings.effortLevel ?? 'medium', + pluginDirs, + }; +} diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts index 3e7c8a8..404a798 100644 --- a/apps/server/src/runtime-executor.test.ts +++ b/apps/server/src/runtime-executor.test.ts @@ -11,7 +11,7 @@ import { type ProviderRunOpts, } from '@deepcode/core'; import type { ThreadSnapshot, TurnSnapshot } from '@deepcode/protocol'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { RuntimeHostExecutor, historyFromThread } from './runtime-executor.js'; @@ -63,8 +63,10 @@ const thread: ThreadSnapshot = { class StreamingProvider implements Provider { readonly name = 'streaming-test'; seenMessages: ProviderRunOpts['messages'] = []; + seenOptions?: ProviderRunOpts; async runTurn(options: ProviderRunOpts): Promise { + this.seenOptions = options; this.seenMessages = options.messages; options.handlers?.onTextDelta?.('new '); options.handlers?.onTextDelta?.('answer'); @@ -150,6 +152,73 @@ describe('RuntimeHostExecutor', () => { }); }); + it('uses per-turn composition defaults and always releases the host lease', async () => { + const provider = new StreamingProvider(); + const close = vi.fn(); + const executor = new RuntimeHostExecutor({ + createHost: () => ({ + host: new RuntimeHost({ provider, tools: new ToolRegistry(), cwd: '/workspace' }), + systemPrompt: 'composed instructions', + model: 'deepseek-reasoner', + effort: 'low', + close, + }), + }); + + await executor.execute({ + thread: { ...thread, turns: [] }, + turn: { + id: 'turn-lease', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { text: 'use composition' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }); + + expect(provider.seenOptions).toEqual( + expect.objectContaining({ + systemPrompt: 'composed instructions', + model: 'deepseek-reasoner', + maxTokens: 1_500, + }), + ); + expect(close).toHaveBeenCalledOnce(); + }); + + it('does not let an invalid mode suppress the trusted composed default', async () => { + const createHost = vi.fn( + (_cwd: string, _mode: string) => + new RuntimeHost({ + provider: new StreamingProvider(), + tools: new ToolRegistry(), + cwd: '/workspace', + }), + ); + const executor = new RuntimeHostExecutor({ createHost }); + + await executor.execute({ + thread: { ...thread, turns: [] }, + turn: { + id: 'turn-invalid-mode', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { text: 'use defaults', mode: 'invalid' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }); + + expect(createHost).toHaveBeenCalledWith('/workspace', 'default', { modeExplicit: false }); + }); + it('ignores non-message protocol items when rebuilding provider history', () => { const withError: ThreadSnapshot = { ...thread, diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts index c97e1bd..26174fe 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -12,12 +12,24 @@ import type { CompletedItem, ThreadSnapshot } from '@deepcode/protocol'; import type { TurnExecutionArgs, TurnExecutionItem, TurnExecutor } from './server.js'; export interface RuntimeHostExecutorOptions { - createHost: (cwd: string, mode: Mode) => Promise | RuntimeHost; + createHost: ( + cwd: string, + mode: Mode, + context: { modeExplicit: boolean }, + ) => Promise | RuntimeHost | RuntimeHostLease; systemPrompt?: string; model?: string; sessionManager?: SessionManager; } +export interface RuntimeHostLease { + host: RuntimeHost; + systemPrompt?: string; + model?: string; + effort?: Effort; + close?: () => Promise | void; +} + const DEFAULT_SYSTEM_PROMPT = 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise and accurate.'; @@ -25,83 +37,95 @@ export class RuntimeHostExecutor implements TurnExecutor { constructor(private readonly options: RuntimeHostExecutorOptions) {} async execute(args: TurnExecutionArgs) { - const mode = parseMode(args.input.mode); - const host = await this.options.createHost(args.thread.cwd, mode); - const history = historyFromThread(args.thread); - const baselineLength = history.length; - const text = typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input); - const streamingItemId = `${args.turn.id}-assistant`; - const events: AgentEvent[] = []; - const interactionItems: TurnExecutionItem[] = []; - const effort = parseEffort(args.input.effort); - const effortParams = EFFORT_PARAMS[effort]; - const result = await host.run({ - cwd: args.thread.cwd, - systemPrompt: this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, - userMessage: text, - history, - model: - typeof args.input.model === 'string' - ? args.input.model - : (this.options.model ?? 'deepseek-chat'), - maxTokens: effortParams.maxTokens, - temperature: effortParams.temperature, - signal: args.signal, - session: this.options.sessionManager - ? { manager: this.options.sessionManager, id: args.thread.id } - : undefined, - persistSessionMessages: false, - systemReminders: false, - approval: async (toolName, _input, verdict) => { - const decision = await args.requestApproval( - toolName, - verdict.reason ?? `Approve ${toolName}?`, - ); - interactionItems.push({ - type: 'approval', - payload: { toolName, decision, reason: verdict.reason }, - }); - return decision === 'always' ? 'always' : decision === 'allow'; - }, - askUser: async (request) => { - const answer = await args.requestUserInput(request); - interactionItems.push({ type: 'ask_user', payload: { ...request, answer } }); - return answer; - }, - onEvent: (event) => { - events.push(event); - switch (event.type) { - case 'text_delta': - args.publishDelta(streamingItemId, event.text); - break; - case 'tool_use': - args.publishToolStarted(event.id, event.name, event.input); - break; - case 'tool_result': - args.publishToolCompleted(event.id, event.result); - break; - case 'usage': - args.publishUsage({ - inputTokens: event.inputTokens, - outputTokens: event.outputTokens, - reasoningTokens: event.reasoningTokens, - cacheReadTokens: event.cacheReadTokens, - }); - break; - } - }, + const requestedMode = args.input.mode; + const modeExplicit = isMode(requestedMode); + const mode: Mode = modeExplicit ? requestedMode : 'default'; + const created = await this.options.createHost(args.thread.cwd, mode, { + modeExplicit, }); + const lease: RuntimeHostLease = 'host' in created ? created : { host: created }; + try { + const history = historyFromThread(args.thread); + const baselineLength = history.length; + const text = + typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input); + const streamingItemId = `${args.turn.id}-assistant`; + const events: AgentEvent[] = []; + const interactionItems: TurnExecutionItem[] = []; + const effort = parseEffort(args.input.effort ?? lease.effort); + const effortParams = EFFORT_PARAMS[effort]; + const result = await lease.host.run({ + cwd: args.thread.cwd, + systemPrompt: lease.systemPrompt ?? this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, + userMessage: text, + history, + model: + typeof args.input.model === 'string' + ? args.input.model + : (lease.model ?? this.options.model ?? 'deepseek-chat'), + maxTokens: effortParams.maxTokens, + temperature: effortParams.temperature, + signal: args.signal, + session: this.options.sessionManager + ? { manager: this.options.sessionManager, id: args.thread.id } + : undefined, + persistSessionMessages: false, + systemReminders: false, + approval: async (toolName, _input, verdict) => { + const decision = await args.requestApproval( + toolName, + verdict.reason ?? `Approve ${toolName}?`, + ); + interactionItems.push({ + type: 'approval', + payload: { toolName, decision, reason: verdict.reason }, + }); + return decision === 'always' ? 'always' : decision === 'allow'; + }, + askUser: async (request) => { + const answer = await args.requestUserInput(request); + interactionItems.push({ type: 'ask_user', payload: { ...request, answer } }); + return answer; + }, + onEvent: (event) => { + events.push(event); + switch (event.type) { + case 'text_delta': + args.publishDelta(streamingItemId, event.text); + break; + case 'tool_use': + args.publishToolStarted(event.id, event.name, event.input); + break; + case 'tool_result': + args.publishToolCompleted(event.id, event.result); + break; + case 'usage': + args.publishUsage({ + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + reasoningTokens: event.reasoningTokens, + cacheReadTokens: event.cacheReadTokens, + }); + break; + } + }, + }); - const newMessages = result.history.slice(baselineLength); - const items = [...interactionItems, ...completedItemsFromMessages(newMessages, text)]; - if (result.stopReason === 'error') { - const error = [...events].reverse().find((event) => event.type === 'error'); - if (error?.type === 'error') items.push({ type: 'error', payload: { message: error.error } }); + const newMessages = result.history.slice(baselineLength); + const items = [...interactionItems, ...completedItemsFromMessages(newMessages, text)]; + if (result.stopReason === 'error') { + const error = [...events].reverse().find((event) => event.type === 'error'); + if (error?.type === 'error') { + items.push({ type: 'error', payload: { message: error.error } }); + } + } + return { + items, + status: result.stopReason === 'error' ? ('failed' as const) : ('completed' as const), + }; + } finally { + await lease.close?.(); } - return { - items, - status: result.stopReason === 'error' ? ('failed' as const) : ('completed' as const), - }; } } @@ -115,8 +139,8 @@ const MODES = new Set([ ]); const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); -function parseMode(value: unknown): Mode { - return typeof value === 'string' && MODES.has(value as Mode) ? (value as Mode) : 'default'; +function isMode(value: unknown): value is Mode { + return typeof value === 'string' && MODES.has(value as Mode); } function parseEffort(value: unknown): Effort { diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 43a9171..0f570b5 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -47,12 +47,14 @@ Then: ## Settings -| Key | Type | Default | Notes | -| ----------------- | ---- | ----------------- | ------------------------------------- | -| `deepcode.model` | enum | `"deepseek-chat"` | Standard alias + concrete model names | -| `deepcode.effort` | enum | `"medium"` | low / medium / high / xhigh / max | +| Key | Type | Default | Notes | +| ----------------- | ---- | ----------------- | ------------------------------------------------ | +| `deepcode.model` | enum | `"deepseek-chat"` | Explicit VS Code value overrides shared settings | +| `deepcode.effort` | enum | `"medium"` | Explicit value overrides shared settings | Credentials stay in the shared DeepCode credential store and are resolved only by the child. +Manifest defaults are not sent as turn overrides; without a user/workspace value, the app-server's +trusted `settings.json` model and effort remain authoritative. ## Roadmap diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 7757e47..9a18ba6 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -6,6 +6,7 @@ import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; import { EditorProtocolRuntime } from './protocol-runtime.js'; import { formatConfigDiagnostics } from './diagnostics.js'; +import { explicitConfigValue } from './settings.js'; type V = typeof import('vscode'); @@ -99,11 +100,12 @@ async function runInOutput( function modelInput(text: string, vscodeMod: V) { const config = vscodeMod.workspace.getConfiguration('deepcode'); + const model = explicitConfigValue(config.inspect('model')); + const effort = explicitConfigValue(config.inspect('effort')); return { text, - model: config.get('model', 'deepseek-chat'), - effort: config.get('effort', 'medium'), - mode: 'default', + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), }; } diff --git a/apps/vscode/src/settings.test.ts b/apps/vscode/src/settings.test.ts new file mode 100644 index 0000000..fffe1d3 --- /dev/null +++ b/apps/vscode/src/settings.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { explicitConfigValue } from './settings.js'; + +describe('explicitConfigValue', () => { + it('ignores extension manifest defaults', () => { + expect(explicitConfigValue(undefined)).toBeUndefined(); + expect(explicitConfigValue({})).toBeUndefined(); + }); + + it('prefers the narrowest explicit language and resource scopes', () => { + expect( + explicitConfigValue({ + globalValue: 'global', + workspaceValue: 'workspace', + workspaceFolderValue: 'folder', + globalLanguageValue: 'language-global', + workspaceLanguageValue: 'language-workspace', + workspaceFolderLanguageValue: 'language-folder', + }), + ).toBe('language-folder'); + expect( + explicitConfigValue({ + globalValue: 'global', + workspaceValue: 'workspace', + workspaceFolderValue: 'folder', + }), + ).toBe('folder'); + }); +}); diff --git a/apps/vscode/src/settings.ts b/apps/vscode/src/settings.ts new file mode 100644 index 0000000..547a78d --- /dev/null +++ b/apps/vscode/src/settings.ts @@ -0,0 +1,22 @@ +export interface ConfigurationInspection { + globalValue?: T; + workspaceValue?: T; + workspaceFolderValue?: T; + globalLanguageValue?: T; + workspaceLanguageValue?: T; + workspaceFolderLanguageValue?: T; +} + +/** Return only a value explicitly configured by the user or workspace. */ +export function explicitConfigValue( + inspection: ConfigurationInspection | undefined, +): T | undefined { + return ( + inspection?.workspaceFolderLanguageValue ?? + inspection?.workspaceLanguageValue ?? + inspection?.globalLanguageValue ?? + inspection?.workspaceFolderValue ?? + inspection?.workspaceValue ?? + inspection?.globalValue + ); +} diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 98b8e4e..004deaf 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -318,7 +318,12 @@ model tool call app-server 现在共享 core trust store,未信任项目不能通过 permissions/sandbox/env 等字段扩大权限。 - CLI doctor、Desktop About、VS Code command 与 LSP command 均消费同一个 diagnostics DTO, 客户端不再自行解释配置来源或 trust gate。 -- 下一步统一 `AGENTS.md`、`DEEPCODE.md`、MCP、skills、plugins、hooks。 +- app-server 已在 host 内统一 `AGENTS.md`、`DEEPCODE.md`、rules、memory、user/project skills、 + output style、hooks 与 model/effort/mode defaults;turn-scoped lease 为下一步 MCP/plugin cleanup + 建立统一生命周期。 +- 下一步把 MCP 与 plugin subprocess 接入同一 lease,并统一资源引用/错误诊断。 +- hook 安全继续收敛到定义哈希级审核:未审阅或已变化的非托管 command hook 默认跳过, + 并在 diagnostics 中暴露来源与审核状态;目录 trust 只是第一道门。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 - diff review、可定位反馈、trace id、结构化日志与脱敏导出。 - 删除完成迁移的旧 IPC/facade;更新所有用户文档。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index 8d7e17b..d83bc46 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -81,6 +81,12 @@ never serialized, so provider credentials, hook headers, MCP environment values, inside the app-server. The report distinguishes discovered layers from effective trust gating and includes shallow schema issues. +The default executor composes `DEEPCODE.md`, `AGENTS.md`, project rules, persistent memory, +user/project skills, output styles, hooks, and model/effort/mode defaults inside the backend for +every workspace turn. `RuntimeHostExecutor` accepts a turn-scoped lease containing the host and +composed prompt, and releases it in `finally`; this is the lifecycle boundary used by later +MCP/plugin connections. Explicit client model/effort/mode values still override trusted settings. + ## Entrypoints After `pnpm build`, either command starts the same handler: @@ -104,4 +110,5 @@ closes stdin first so the server can interrupt and persist active turns before a ## Deferred from this slice - thread listing, archive, fork, and search; +- MCP/plugin subprocess composition (requires the host lease close path added above); - multi-client subscriptions or active-turn attachment; diff --git a/packages/core/package.json b/packages/core/package.json index 235552e..3d9ce7e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,6 +56,22 @@ "types": "./dist/tools/index.d.ts", "import": "./dist/tools/index.js" }, + "./hooks": { + "types": "./dist/hooks/index.d.ts", + "import": "./dist/hooks/index.js" + }, + "./memory": { + "types": "./dist/memory/index.d.ts", + "import": "./dist/memory/index.js" + }, + "./output-styles": { + "types": "./dist/output-styles/index.d.ts", + "import": "./dist/output-styles/index.js" + }, + "./skills": { + "types": "./dist/skills/index.d.ts", + "import": "./dist/skills/index.js" + }, "./skills/*": "./skills/*", "./package.json": "./package.json" }, diff --git a/packages/core/src/memory/loader.ts b/packages/core/src/memory/loader.ts index 4e07c35..0db879a 100644 --- a/packages/core/src/memory/loader.ts +++ b/packages/core/src/memory/loader.ts @@ -67,6 +67,8 @@ export interface LoadMemoryOpts { cwd: string; /** Override $HOME for tests. */ home?: string; + /** Direct DeepCode data directory (contains DEEPCODE.md and projects/). */ + directory?: string; /** Max bytes total (caller can use this to enforce settings.memoryLoadCapKB). */ maxBytes?: number; /** Max depth for @-import recursion. */ @@ -78,6 +80,7 @@ const DEFAULT_MAX_DEPTH = 4; export async function loadMemory(opts: LoadMemoryOpts): Promise { const home = opts.home ?? homedir(); + const directory = opts.directory ?? join(home, '.deepcode'); const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; const maxDepth = opts.maxImportDepth ?? DEFAULT_MAX_DEPTH; @@ -111,10 +114,14 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise { }; // 1. ~/.deepcode/DEEPCODE.md (user-level) - await addFile(join(home, '.deepcode', 'DEEPCODE.md'), 'user memory', 0); + await addFile(join(directory, 'DEEPCODE.md'), 'user memory', 0); // 1b. Agent/user-written project memory (the `#` remember store). - await addFile(projectMemoryPath(home, opts.cwd), 'project memory', 0); + await addFile( + join(directory, 'projects', projectMemoryKey(opts.cwd), 'memory', 'MEMORY.md'), + 'project memory', + 0, + ); // 2. DEEPCODE.md walking from cwd → root, deepest first const upwards = walkUpwards(opts.cwd, home); diff --git a/packages/core/src/output-styles/loader.ts b/packages/core/src/output-styles/loader.ts index 8912eb7..6eecc40 100644 --- a/packages/core/src/output-styles/loader.ts +++ b/packages/core/src/output-styles/loader.ts @@ -24,6 +24,8 @@ export interface OutputStyle { export interface LoadOutputStylesOpts { cwd: string; home?: string; + /** Direct DeepCode data directory (contains output-styles/). */ + directory?: string; } /** Built-in styles (M4 ships 4 — matches §3.13b table). */ @@ -84,8 +86,9 @@ export const BUILTIN_STYLES: OutputStyle[] = [ export async function loadOutputStyles(opts: LoadOutputStylesOpts): Promise { const home = opts.home ?? homedir(); + const directory = opts.directory ?? join(home, '.deepcode'); const out: OutputStyle[] = [...BUILTIN_STYLES]; - await loadFromDir(join(home, '.deepcode', 'output-styles'), 'user', out); + await loadFromDir(join(directory, 'output-styles'), 'user', out); await loadFromDir(join(opts.cwd, '.deepcode', 'output-styles'), 'project', out); return out; } diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts index cf2e9c0..24e0f33 100644 --- a/packages/core/src/skills/loader.ts +++ b/packages/core/src/skills/loader.ts @@ -42,6 +42,8 @@ export interface Skill { export interface LoadSkillsOpts { cwd: string; home?: string; + /** Direct DeepCode data directory (contains skills/). */ + directory?: string; /** Optional list of plugin directories (M5+). */ pluginDirs?: string[]; /** Skill name → { disabled: true } overrides from settings.json. */ @@ -52,6 +54,7 @@ export interface LoadSkillsOpts { export async function loadSkills(opts: LoadSkillsOpts): Promise { const home = opts.home ?? homedir(); + const directory = opts.directory ?? join(home, '.deepcode'); const out: Skill[] = []; // 1. Built-in skills (shipped with DeepCode) @@ -60,7 +63,7 @@ export async function loadSkills(opts: LoadSkillsOpts): Promise { } // 2. User-level - await loadFromDir(join(home, '.deepcode', 'skills'), 'user', out); + await loadFromDir(join(directory, 'skills'), 'user', out); // 3. Project-level await loadFromDir(join(opts.cwd, '.deepcode', 'skills'), 'project', out);